refactor: TCP socket addresses — sync to chat DB, topo_node, config file single source

- chat: collect_my_addrs extends to TCP + IPv6, chat_core_sync_my_addresses writes TCP addrs
- topo_node: replace CFG_SERVER with TCP_SOCKET for TCP addresses, simplify NAT logic
- etcp_connections: add IPv6 interface addr resolution for TCP sockets
- Android: INI config file as single source of truth for servers, remove JSON/Gson
- Android: filter temporary IPv6 addresses, fix restart with socket cleanup
- instance_lite: sync addresses after socket creation, proper restart cleanup
This commit is contained in:
evgeny committed 2026-08-03 13:57:42 +03:00
1 parent 740ed2a93a
commit ddf696bcf6
9 files changed
+338 -161

No files matched your search

@@ -35,10 +35,8 @@ class ChatApplication : Application() {
/* First launch config */
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
LogManager.addLog("INFO", "APP", "First launch — configuring")
configProvider.detectInterfaces()
LogManager.addLog("INFO", "APP", "First launch — marking launched")
configProvider.markLaunched()
LogManager.addLog("INFO", "APP", "First launch done, ${configProvider.loadServers().size} interfaces detected")
}
}
}
@@ -7,8 +7,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
@@ -19,6 +17,7 @@ import java.net.InetAddress
import java.net.NetworkInterface
import java.io.File
import java.util.UUID
import java.util.concurrent.ThreadLocalRandom
private val Context.dataStore by preferencesDataStore("utun_config")
@@ -35,7 +34,6 @@ class ConfigProvider(private val context: Context) {
private val debugCategories = stringPreferencesKey("debug_categories")
private val logUdpIp = stringPreferencesKey("log_udp_ip")
private val logUdpPort = intPreferencesKey("log_udp_port")
private val serversJson = stringPreferencesKey("servers")
private val chatStorageAutoload = intPreferencesKey("chat_storage_autoload")
private val chatStorageAutoloadMaxsizeMb = intPreferencesKey("chat_storage_autoload_maxsize_mb")
private val chatStorageMaxsizeGb = intPreferencesKey("chat_storage_maxsize_gb")
@@ -46,9 +44,6 @@ class ConfigProvider(private val context: Context) {
private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers")
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val gson = Gson()
private val random = java.util.concurrent.ThreadLocalRandom.current()
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
return "$dir/keys.conf"
@@ -95,19 +90,6 @@ class ConfigProvider(private val context: Context) {
fun getString(key: String): String? = runBlocking {
when {
key.startsWith("servers.") -> {
val parts = key.removePrefix("servers.").split(".", limit = 2)
val idx = parts[0].toIntOrNull() ?: return@runBlocking null
val field = parts.getOrNull(1) ?: return@runBlocking null
val list = loadServers()
val srv = list.getOrNull(idx) ?: return@runBlocking null
when (field) {
"bindIp" -> srv.bindIp
"protocol" -> srv.protocol
else -> null
}
}
key == "servers.count" -> loadServers().size.toString()
key == "node.private_key" -> readKeysFromFile()?.second ?: context.dataStore.data.first()[nodePrivateKey]
key == "node.public_key" -> readKeysFromFile()?.first ?: context.dataStore.data.first()[nodePublicKey]
key == "node.name" -> context.dataStore.data.first()[nodeName] ?: getDeviceName()
@@ -123,18 +105,6 @@ class ConfigProvider(private val context: Context) {
fun getInt(key: String): Int = runBlocking {
when {
key.startsWith("servers.") -> {
val parts = key.removePrefix("servers.").split(".", limit = 2)
val idx = parts[0].toIntOrNull() ?: return@runBlocking 0
val field = parts.getOrNull(1) ?: return@runBlocking 0
val list = loadServers()
val srv = list.getOrNull(idx) ?: return@runBlocking 0
when (field) {
"port" -> srv.port
else -> 0
}
}
key == "servers.count" -> loadServers().size
key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345
key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999
key == "log_udp.port" -> context.dataStore.data.first()[logUdpPort] ?: 9999
@@ -191,6 +161,20 @@ class ConfigProvider(private val context: Context) {
// fc00::/7 → fc00-fdff, 2000::/3 → 2000-3fff
return hi in 0xFC00..0xFDFF || hi in 0x2000..0x3FFF
}
// 0x20 = IFA_F_TEMPORARY в /proc/net/if_inet6
fun isTemporaryV6(ifName: String, ip: String): Boolean {
val hexAddr = ip.split("%")[0].replace(":", "").uppercase().padStart(32, '0')
return try {
File("/proc/net/if_inet6").useLines { lines ->
lines.any { line ->
val parts = line.trim().split("\\s+".toRegex(), limit = 6)
parts.size >= 6 && parts[0] == hexAddr && parts[5] == ifName &&
parts[4].toIntOrNull(16)?.let { (it and 0x20) != 0 } == true
}
}
} catch (_: Exception) { false }
}
}
/* ── First launch ── */
@@ -207,6 +191,7 @@ class ConfigProvider(private val context: Context) {
suspend fun detectInterfaces(): List<ServerEntry> {
val servers = mutableListOf<ServerEntry>()
val seen = mutableSetOf<String>()
try {
val nis = NetworkInterface.getNetworkInterfaces()
while (nis.hasMoreElements()) {
@@ -216,70 +201,90 @@ class ConfigProvider(private val context: Context) {
val addr = a.address
val ip = when {
addr is Inet4Address -> addr.hostAddress
addr is Inet6Address && isUsableV6(addr) -> addr.hostAddress?.let { it.split("%")[0] }
addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr.hostAddress ?: "") -> addr.hostAddress?.let { it.split("%")[0] }
else -> null
} ?: continue
val key = "${ni.name}/${if (ip.contains(":")) "v6" else "v4"}"
if (!seen.add(key)) continue
servers.add(ServerEntry(
id = UUID.randomUUID().toString(),
ifName = ni.name,
bindIp = ip,
protocol = "tcp",
port = random.nextInt(2000, 10000) // 2000-9999
port = ThreadLocalRandom.current().nextInt(2000, 10000)
))
}
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "CFG", "detectInterfaces failed: ${e.message}")
}
saveServers(servers)
return servers
}
suspend fun saveServers(servers: List<ServerEntry>) {
val json = gson.toJson(servers)
context.dataStore.edit { prefs -> prefs[serversJson] = json }
/* ── Config file as single source of truth for servers ── */
fun configFilePath(): String {
val dir = getString("db.path") ?: context.filesDir.absolutePath
return "$dir/utun.cfg"
}
fun loadServers(): List<ServerEntry> = runBlocking {
val json = context.dataStore.data.first()[serversJson] ?: "[]"
fun loadServersFromConfig(): List<ServerEntry> {
val file = File(configFilePath())
if (!file.exists()) return emptyList()
data class RawSrv(val ifName: String, val family: String, val port: Int, val protocol: String)
val raw = mutableListOf<RawSrv>()
try {
gson.fromJson(json, object : TypeToken<List<ServerEntry>>() {}.type) ?: emptyList()
} catch (e: Exception) {
emptyList()
val lines = file.readLines()
var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp"; var sectionOpen = false
for (line in lines) {
val trimmed = line.trim()
if (trimmed.startsWith("[server:") && trimmed.endsWith("]")) {
if (sectionOpen && ifName.isNotEmpty() && addrPort > 0)
raw.add(RawSrv(ifName, family, addrPort, proto))
val section = trimmed.removeSurrounding("[", "]").removePrefix("server:")
val lastUnderscore = section.lastIndexOf('_')
if (lastUnderscore > 0) {
ifName = section.substring(0, lastUnderscore)
family = section.substring(lastUnderscore + 1)
} else { ifName = ""; family = "" }
addrPort = 0; proto = "tcp"; sectionOpen = true
} else if (trimmed.startsWith("addr=")) {
val addr = trimmed.removePrefix("addr=")
val colonIdx = addr.lastIndexOf(':')
if (colonIdx >= 0) addrPort = addr.substring(colonIdx + 1).toIntOrNull() ?: 0
} else if (trimmed.startsWith("transport=")) {
proto = if (trimmed.removePrefix("transport=") == "udp") "udp" else "tcp"
}
}
if (sectionOpen && ifName.isNotEmpty() && addrPort > 0)
raw.add(RawSrv(ifName, family, addrPort, proto))
} catch (_: Exception) { return emptyList() }
val liveIps = getAvailableInterfaces()
return raw.map { rs ->
val isV6 = rs.family == "v6"
val realIp = liveIps.firstOrNull { (ifn, ip) ->
ifn == rs.ifName && (if (isV6) ip.contains(":") else !ip.contains(":"))
}?.second
ServerEntry(UUID.randomUUID().toString(), rs.ifName,
realIp ?: if (isV6) "::" else "0.0.0.0", rs.protocol, rs.port)
}
}
/** Return all currently available network interface names (for dropdown) */
fun getAvailableInterfaces(): List<Pair<String, String>> = runBlocking {
val list = mutableListOf<Pair<String, String>>()
try {
val nis = NetworkInterface.getNetworkInterfaces()
while (nis.hasMoreElements()) {
val ni = nis.nextElement()
if (!ni.isUp || ni.isLoopback) continue
for (a in ni.interfaceAddresses) {
val addr = a.address
val ip = when {
addr is Inet4Address -> addr.hostAddress
addr is Inet6Address && isUsableV6(addr) -> addr.hostAddress?.let { it.split("%")[0] }
else -> null
} ?: continue
list.add(ni.name to ip)
}
}
} catch (_: Exception) {}
if (list.isEmpty()) list.add("lo" to "127.0.0.1")
list
fun saveServersToConfig(servers: List<ServerEntry>) {
val text = buildConfigFromServers(servers)
File(configFilePath()).writeText(text)
LogManager.addLog("INFO", "CFG", "saveServersToConfig: ${servers.size} servers written" +
servers.joinToString(", ") { "${it.ifName}=${it.protocol}" })
}
/** Build full INI config text for NativeLib.start() */
fun buildConfigText(): String {
/** Build complete INI text from server list (without touching config file) */
private fun buildConfigFromServers(servers: List<ServerEntry>): String {
val pubKey = getString("node.public_key") ?: ""
val privKey = getString("node.private_key") ?: ""
val nodeName = getString("node.name") ?: getDeviceName()
val listenPort = getInt("node.listen_port")
val dbDir = getString("db.path") ?: context.filesDir.absolutePath
return buildString {
val dbDir = getString("db.path") ?: context.filesDir.absolutePath
return buildString {
appendLine("[global]")
if (nodeName.isNotBlank()) appendLine("my_node_name=$nodeName")
if (pubKey.length == 64) appendLine("my_public_key=$pubKey")
@@ -287,12 +292,15 @@ class ConfigProvider(private val context: Context) {
appendLine("db_path=$dbDir")
appendLine("db_sync_enabled=1")
appendLine()
appendLine("[server:main]")
appendLine("addr=0.0.0.0:$listenPort")
appendLine()
appendLine("[server:main6]")
appendLine("addr=[::]:$listenPort")
appendLine()
for (srv in servers) {
val family = if (srv.bindIp.contains(":")) "v6" else "v4"
appendLine("[server:${srv.ifName}_$family]")
appendLine("addr=${if (family == "v6") "[::]" else "0.0.0.0"}:${srv.port}")
appendLine("type=public")
appendLine("transport=${srv.protocol}")
if (family == "v6") appendLine("netif=${srv.ifName}")
appendLine()
}
appendLine("[allowed_keys]")
appendLine("allow_all=yes")
appendLine()
@@ -329,4 +337,42 @@ class ConfigProvider(private val context: Context) {
}
}
}
/** Return all currently available network interface names (for dropdown) */
fun getAvailableInterfaces(): List<Pair<String, String>> = runBlocking {
val list = mutableListOf<Pair<String, String>>()
try {
val nis = NetworkInterface.getNetworkInterfaces()
while (nis.hasMoreElements()) {
val ni = nis.nextElement()
if (!ni.isUp || ni.isLoopback) continue
for (a in ni.interfaceAddresses) {
val addr = a.address
val ip = when {
addr is Inet4Address -> addr.hostAddress
addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr.hostAddress ?: "") -> addr.hostAddress?.let { it.split("%")[0] }
else -> null
} ?: continue
list.add(ni.name to ip)
}
}
} catch (_: Exception) {}
if (list.isEmpty()) list.add("lo" to "127.0.0.1")
list
}
/** Build full INI config text for NativeLib.start() */
fun buildConfigText(): String {
val cfgFile = File(configFilePath())
val existing = if (cfgFile.exists()) loadServersFromConfig() else emptyList()
LogManager.addLog("INFO", "CFG", "buildConfigText: exists=${cfgFile.exists()} srv=${existing.size}" +
existing.joinToString(", ") { "${it.ifName}=${it.protocol}" })
if (!cfgFile.exists() || existing.isEmpty()) {
val detected = runBlocking { detectInterfaces() }
LogManager.addLog("WARN", "CFG", "buildConfigText: auto-detecting ${detected.size} interfaces")
saveServersToConfig(detected)
return buildConfigFromServers(detected)
}
return buildConfigFromServers(existing)
}
}
@@ -123,11 +123,11 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
if (logUdpIp.isNotEmpty() && port > 0) NativeLib.setUdpLogTarget(logUdpIp, port)
val stored = provider.getString("debug.categories") ?: ""
catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap()
servers = provider.loadServers()
servers = provider.loadServersFromConfig()
availableIfaces = provider.getAvailableInterfaces()
}
val save = { s: List<ServerEntry> -> onDirty(true); servers = s; scope.launch { provider.saveServers(s) } }
val save = { s: List<ServerEntry> -> onDirty(true); servers = s; provider.saveServersToConfig(s) }
val setDebug = { level: String ->
onDirty(true); NativeLib.setDebugLevel("", level)
scope.launch { provider.setValue("debug.console_level", level) }; debugLevel = level
@@ -21,6 +21,8 @@
#include "sqlite3.h"
#include "mem.h"
#include "debug_config.h"
#include "transport_layer/etcp_connections.h"
#include "transport_layer/stcp_link.h"
#include "../jni_bridge/android_udp_log.h"
#include <pthread.h>
#include <stdio.h>
@@ -207,6 +209,9 @@ static void* instance_thread(void* arg) {
return NULL;
}
/* sockets are now created — sync my addresses to DB */
chat_core_sync_my_addresses();
/* Bind chat sync via etcp_router */
etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL);
@@ -347,6 +352,14 @@ static void restart_trampoline(void* arg) {
return;
}
/* Clean up old sockets so init_connections reads new config */
while (g_inst->etcp_sockets) etcp_socket_remove(g_inst->etcp_sockets);
g_inst->etcp_sockets = NULL;
while (g_inst->tcp_sockets) tcp_socket_remove(g_inst->tcp_sockets);
g_inst->tcp_sockets = NULL;
if (g_inst->stcp_server) { stcp_link_server_destroy(g_inst->stcp_server); g_inst->stcp_server = NULL; }
g_inst->socket_init_status = 0;
if (utun_instance_init(g_inst) != 0) {
IL_LOGE("restart: utun_instance_init failed");
g_running = 0;
@@ -354,6 +367,8 @@ static void restart_trampoline(void* arg) {
return;
}
chat_core_sync_my_addresses();
if (g_inst->config->global.name[0]) {
chat_core_update_my_name(g_inst->config->global.name);
IL_LOGI("restart: my_name set to '%s'", g_inst->config->global.name);