From 7f0c0804920dafc8c867925da2ebfbfa21a51a0c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:18:55 +0100 Subject: [PATCH] Validate saved Android socket ports before reuse --- .../java/com/utun/chat/data/ConfigProvider.kt | 58 +++++-------- .../java/com/utun/chat/data/ServerConfig.kt | 50 ++++++++++++ .../com/utun/chat/data/ServerConfigTest.kt | 81 +++++++++++++++++++ 3 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 7a3aa0f6..7aba166f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -20,7 +20,6 @@ import java.net.InetAddress import java.net.NetworkInterface import java.io.File import java.io.IOException -import java.util.UUID private val Context.dataStore by preferencesDataStore("utun_config") @@ -633,54 +632,28 @@ class ConfigProvider(private val context: Context) { return "$dir/utun.cfg" } + /** Читает проверенные listen-секции; IP берёт с текущего интерфейса либо оставляет wildcard для отсутствующего. */ fun loadServersFromConfig(includeCommented: Boolean = false): List { 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() - try { - var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp" - var sectionOpen = false; var sectionCommented = false - for (line in file.readLines()) { - val trimmed = line.trim() - val commented = trimmed.startsWith("#") - val body = if (commented) trimmed.removePrefix("#").trim() else trimmed - if (body.startsWith("[") && body.endsWith("]")) { - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) - raw.add(RawSrv(ifName, family, addrPort, proto)) - sectionOpen = false; sectionCommented = commented; addrPort = 0; proto = "tcp"; ifName = ""; family = "" - if (!body.startsWith("[server:")) continue - if (commented && !includeCommented) continue - val section = body.removeSurrounding("[", "]").removePrefix("server:").removeSuffix("_tcp").removeSuffix("_udp") - val lastUnderscore = section.lastIndexOf('_') - if (lastUnderscore > 0) { - ifName = section.substring(0, lastUnderscore) - family = section.substring(lastUnderscore + 1) - } - sectionOpen = true - } else if (sectionOpen && body.startsWith("addr=")) { - val addr = body.removePrefix("addr=") - val colonIdx = addr.lastIndexOf(':') - if (colonIdx >= 0) addrPort = addr.substring(colonIdx + 1).toIntOrNull() ?: 0 - } else if (sectionOpen && body.startsWith("transport=")) { - proto = if (body.removePrefix("transport=") == "udp") "udp" else "tcp" + val saved = try { + file.bufferedReader().use { reader -> + ServerConfig.parse(reader.lineSequence(), includeCommented) { warning -> + LogManager.addLog("WARN", "CFG", warning) } } - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) - raw.add(RawSrv(ifName, family, addrPort, proto)) } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "loadServersFromConfig failed path=${file.path}: ${e.message}") return emptyList() } - + if (saved.isEmpty()) return emptyList() val liveIps = getAvailableInterfaces() - return raw.map { rs -> - val isV6 = rs.family == "v6" + return saved.map { server -> + val isV6 = server.bindIp.contains(':') val realIp = liveIps.firstOrNull { (ifn, ip) -> - ifn == rs.ifName && (if (isV6) ip.contains(":") else !ip.contains(":")) + ifn == server.ifName && ip.contains(':') == isV6 }?.second - ServerEntry(UUID.randomUUID().toString(), rs.ifName, - realIp ?: if (isV6) "::" else "0.0.0.0", rs.protocol, rs.port) + server.copy(bindIp = realIp ?: server.bindIp) } } @@ -803,7 +776,7 @@ class ConfigProvider(private val context: Context) { sb.appendLine() } - /** Return all currently available network interface names (for dropdown) */ + /** Синхронный список (интерфейс, IP) для UI/конфига; ошибка и fallback на loopback видны в CFG-логе. */ fun getAvailableInterfaces(): List> = runBlocking { val list = mutableListOf>() try { @@ -821,8 +794,13 @@ class ConfigProvider(private val context: Context) { list.add(ni.name to ip) } } - } catch (_: Exception) {} - if (list.isEmpty()) list.add("lo" to "127.0.0.1") + } catch (e: Exception) { + LogManager.addLog("ERROR", "CFG", "getAvailableInterfaces failed: ${e.javaClass.simpleName}: ${e.message}") + } + if (list.isEmpty()) { + LogManager.addLog("WARN", "CFG", "getAvailableInterfaces: no usable addresses, using lo=127.0.0.1") + list.add("lo" to "127.0.0.1") + } list } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt new file mode 100644 index 00000000..fb36cc83 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt @@ -0,0 +1,50 @@ +package com.utun.chat.data + +import java.util.UUID + +/** Кодек listen-секций ConfigProvider, без файловой системы/Android. Полученные wildcard IP вызывающий код заменяет текущими. */ +internal object ServerConfig { + private val sectionName = Regex("(.+)_(v4|v6)(?:_(tcp|udp))?") + + /** + * Читает [server:interface_v4/v6_tcp/udp]; без суффикса протокола по умолчанию TCP. + * transport имеет приоритет над суффиксом; порт 1..65535. Остальные секции/поля не относятся к этому кодеку. + * includeCommented восстанавливает полностью закомментированные секции. Неверная секция пропускается с warn. + */ + fun parse(lines: Sequence, includeCommented: Boolean, warn: (String) -> Unit): List { + val result = mutableListOf() + val fields = mutableMapOf() + var name: String? = null + var sectionCommented = false + + fun finishSection() { + val current = name ?: return + val match = sectionName.matchEntire(current) + if (match == null) { warn("saved server '$current': invalid interface/family/protocol section name"); return } + val protocol = fields["transport"] ?: match.groupValues[3].ifEmpty { "tcp" } + if (protocol != "tcp" && protocol != "udp") { warn("saved server '$current': unsupported transport"); return } + val port = fields["addr"]?.substringAfterLast(':', "")?.toIntOrNull() + if (port == null || port !in 1..65535) { warn("saved server '$current': invalid listen port parsed=$port, expected 1..65535"); return } + val ip = if (match.groupValues[2] == "v6") "::" else "0.0.0.0" + result.add(ServerEntry(UUID.randomUUID().toString(), match.groupValues[1], ip, protocol, port)) + } + + for (line in lines) { + val trimmed = line.trim() + val commented = trimmed.startsWith('#') + val body = if (commented) trimmed.removePrefix("#").trim() else trimmed + if (body.startsWith('[') && body.endsWith(']')) { + finishSection() + fields.clear() + sectionCommented = commented + name = if (body.startsWith("[server:") && (!commented || includeCommented)) + body.removeSurrounding("[", "]").removePrefix("server:") else null + } else if (name != null && commented == sectionCommented) { + val equals = body.indexOf('=') + if (equals > 0) fields[body.substring(0, equals).trim()] = body.substring(equals + 1).trim() + } + } + finishSection() + return result + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt new file mode 100644 index 00000000..fa539eb0 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt @@ -0,0 +1,81 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +/** Сохранённые порты проверяются до повторного использования в автосокетах; ошибки сохраняют остальные секции. */ +class ServerConfigTest { + private fun section(name: String, port: String, transport: String = "tcp") = + "[server:$name]\naddr=0.0.0.0:$port\ntransport=$transport\n" + + @Test fun roundTripIdentityUsesInterfaceFamilyAndTransport() { + val config = section("wlan_0_v4_tcp", "2001") + section("wlan_0_v4_udp", "2002", "udp") + + "[server:wlan_0_v6_tcp]\naddr=[::]:2003\ntransport=tcp\n" + val servers = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + assertEquals(listOf(2001, 2002, 2003), servers.map { it.port }) + assertEquals(listOf("wlan_0_v4_tcp", "wlan_0_v4_udp", "wlan_0_v6_tcp"), servers.map { it.sectionName }) + assertEquals(listOf("0.0.0.0", "0.0.0.0", "::"), servers.map { it.bindIp }) + assertEquals(listOf(2001, 2002), AutoSocketProtocols.BOTH.createServers("wlan_0", "192.0.2.1", servers).map { it.port }) + } + + @Test fun invalidSavedPortsAreSkippedBeforeAutoReuse() { + for (port in listOf("0", "-1", "65536", "2147483648", "invalid", "")) { + val warnings = mutableListOf() + val config = section("wlan0_v4_tcp", port) + section("other_v4_tcp", "3456") + val servers = ServerConfig.parse(config.lineSequence(), false, warnings::add) + assertEquals(listOf("other"), servers.map { it.ifName }) + assertEquals(listOf(3456), servers.map { it.port }) + assertEquals(1, warnings.size) + assertTrue(warnings.single(), warnings.single().contains("wlan0_v4_tcp")) + val recovered = AutoSocketProtocols.TCP.createServers("wlan0", "192.0.2.1", servers).single() + assertTrue(recovered.port in 2000..9999) + } + } + + @Test fun bothPortBoundariesArePreserved() { + val config = section("first_v4_tcp", "1") + section("last_v4_udp", "65535", "udp") + assertEquals(listOf(1, 65535), ServerConfig.parse(config.lineSequence(), false) { fail(it) }.map { it.port }) + } + + @Test fun commentedSectionsAreOnlyRestoredWhenRequested() { + val inactive = section("offline_v6_udp", "4567", "udp").lineSequence().filter { it.isNotEmpty() }.joinToString("\n") { "#$it" } + val config = section("live_v4_tcp", "2345") + inactive + val active = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + val all = ServerConfig.parse(config.lineSequence(), true) { fail(it) } + assertEquals(listOf("live"), active.map { it.ifName }) + assertEquals(listOf("live", "offline"), all.map { it.ifName }) + assertEquals("::", all.last().bindIp) + assertEquals("udp", all.last().protocol) + } + + @Test fun commentedFieldsDoNotOverrideActiveFields() { + val config = section("wlan0_v4_tcp", "2345") + "#addr=0.0.0.0:9999\n#transport=udp\n" + for (includeCommented in listOf(false, true)) { + val server = ServerConfig.parse(config.lineSequence(), includeCommented) { fail(it) }.single() + assertEquals(2345, server.port) + assertEquals("tcp", server.protocol) + } + } + + @Test fun unknownSectionsDoNotLeakFieldsIntoServer() { + val config = "[global]\naddr=0.0.0.0:9999\n" + section("wlan0_v4_tcp", "2345") + "[debug]\ntransport=udp\n" + val server = ServerConfig.parse(config.lineSequence(), false) { fail(it) }.single() + assertEquals(2345, server.port) + assertEquals("tcp", server.protocol) + } + + @Test fun invalidSectionAndTransportAreDiagnosed() { + val warnings = mutableListOf() + val config = section("wlan0_unknown_tcp", "2345") + section("wlan0_v4_tcp", "2345", "unknown") + + "[server:missing_v4_tcp]\ntransport=tcp\n" + assertTrue(ServerConfig.parse(config.lineSequence(), false, warnings::add).isEmpty()) + assertEquals(3, warnings.size) + } + + @Test fun transportFieldWinsAndSuffixSuppliesDefault() { + val config = section("wlan0_v4_tcp", "2345", "udp") + "[server:wlan0_v6_udp]\naddr=[::]:3456\n" + + "[server:old_v4]\naddr=0.0.0.0:4567\n" + val servers = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + assertEquals(listOf("udp", "udp", "tcp"), servers.map { it.protocol }) + } +}