diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98f242f7a9..05f43e24a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,8 @@ name: Build on: + # 版本号变更的构建已由 build-apk.yml 覆盖(它的 paths 含 nb4a.properties), + # 这里改为仅手动触发,避免同一次 push 触发两个 workflow workflow_dispatch: - push: - branches: - - main - paths: - - 'nb4a.properties' jobs: libcore: name: Native Build (LibCore) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31a00ba003..2d2d6a832c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,8 @@ name: CI on: - push: - tags-ignore: - - 'v*' - branches: - - '*' + # main 分支的构建统一由 build-apk.yml 负责,这里只在 PR 上做校验,避免同一提交跑两遍 pull_request: + workflow_dispatch: jobs: libcore: name: Native Build (LibCore) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt new file mode 100644 index 0000000000..153cc3aeed --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt @@ -0,0 +1,135 @@ +package io.nekohasekai.sagernet.bg.proto + +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.bg.GuardedProcessPool +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.fmt.buildConfig +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.readableMessage +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.ktx.tryResume +import io.nekohasekai.sagernet.ktx.tryResumeWithException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import libcore.Libcore +import moe.matsuri.nb4a.net.LocalResolverImpl +import org.json.JSONObject +import kotlin.coroutines.suspendCoroutine + +/** + * 整组共用一个内核实例的测速器,对标 Clash 的 `/proxies/{name}/delay`: + * 只 init/launch 一次,随后按 outbound tag 并发拨号,测完一个立刻回显。 + * + * 原实现([TestInstance])每个节点都要新建并启动一次内核,节点多时开销很大。 + * + * 注意:整组共用一个配置,任何一个节点配置非法都会让整个内核起不来。 + * 因此这里对单节点做了隔离:decode 失败时按出错的 outbound 定位节点并剔除重试, + * 被剔除的节点记在 [excluded] 里由调用方标记失败,避免整组显示不可用。 + */ +class BatchTestInstance( + profiles: List, + val link: String, + private val timeout: Int, +) : BoxInstance(profiles.first()) { + + /** 被跳过的节点:profileId -> 原因 */ + val excluded = LinkedHashMap() + + private var activeProfiles: List = profiles + + /** profileId -> outbound tag,由批量配置的 buildChain 生成 */ + val tagMap: Map + get() = config.profileTagMap + + /** + * 构建配置、初始化并启动内核(插件节点会在此一并拉起)。 + * 只需调用一次,之后用 [testTag] 并发测速。 + */ + suspend fun prepare() { + // 整组共用一个配置:任何一个节点配置非法都会让整个内核起不来, + // decode 失败时按出错的 outbound 定位到具体节点,剔除后重建配置重试, + // 避免一个坏节点让整组显示不可用。 + while (true) { + try { + startOnce() + return + } catch (e: Exception) { + Logs.w("batch test prepare failed: ${e.readableMessage}", e) + val badId = resolveFailedProfile(e) + if (badId == null || activeProfiles.size <= 1) { + throw e + } + Logs.w("batch test: drop profile $badId (${e.readableMessage})") + excluded[badId] = e.readableMessage + activeProfiles = activeProfiles.filterNot { it.id == badId } + releaseKernel() + } + } + } + + private suspend fun startOnce() = suspendCoroutine { c -> + processes = GuardedProcessPool { + Logs.w(it) + c.tryResumeWithException(it) + } + runOnDefaultDispatcher { + try { + init() + launch() + if (processes.processCount > 0) { + // wait for plugin start + delay(500) + } + c.tryResume(Unit) + } catch (e: Exception) { + c.tryResumeWithException(e) + } + } + } + + /** 在已启动的实例内测指定节点,返回 RTT(毫秒),失败抛异常 */ + fun testTag(tag: String): Int { + return Libcore.urlTestTag(box, tag, link, timeout) + } + + override fun buildConfig() { + // 剔除节点后,主 profile 可能已不在列表里,取当前可用的第一个 + config = buildConfig(activeProfiles.first(), true, testProfiles = activeProfiles) + } + + override suspend fun loadConfig() { + // don't call destroyAllJsi here + if (BuildConfig.DEBUG) Logs.d(config.config) + box = Libcore.newSingBoxInstance(config.config, LocalResolverImpl) + } + + /** + * 从 decode 错误里定位是哪个节点的 outbound 非法。 + * sing-box 的报错形如 "decode config: outbounds[20].transport: unknown transport type: xhttp", + * 取该 outbound 的 tag,再反查 profileId。 + */ + private fun resolveFailedProfile(e: Throwable): Long? { + val message = generateSequence(e) { it.cause } + .mapNotNull { it.message } + .joinToString(" | ") + // 兼容 "outbounds[20]"(decode 阶段)和 "outbound[1]"(初始化阶段) + val index = Regex("""outbounds?\[(\d+)]""").find(message) + ?.groupValues?.get(1)?.toIntOrNull() ?: return null + val tag = runCatching { + JSONObject(config.config).getJSONArray("outbounds").getJSONObject(index).optString("tag") + }.getOrNull()?.takeIf { it.isNotEmpty() } ?: return null + // 优先精确匹配最终 outbound tag + config.profileTagMap.entries.firstOrNull { it.value == tag }?.let { return it.key } + // 链中间节点的 tag 形如 c-- + return activeProfiles.firstOrNull { tag.endsWith("-${it.id}") }?.id + } + + /** 丢弃上一轮已创建但不可用的内核/插件进程 */ + private fun releaseKernel() { + // box / processes 是父类的 lateinit 属性,子类不能用 ::x.isInitialized 检查, + // 未初始化时访问会抛 UninitializedPropertyAccessException,runCatching 兜住即可 + runCatching { box.close() } + runCatching { processes.close(CoroutineScope(Dispatchers.IO)) } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index ebe2ae752e..6a53549720 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -30,6 +30,7 @@ import io.nekohasekai.sagernet.fmt.snell.SnellBean import io.nekohasekai.sagernet.fmt.snell.buildSingBoxOutboundSnellBean import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean +import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.mkPort import io.nekohasekai.sagernet.utils.PackageCache @@ -54,6 +55,7 @@ const val TAG_BYPASS = "bypass" const val TAG_BLOCK = "block" const val TAG_FRAGMENT = "fragment" const val TAG_DNS_HOSTS = "dns-hosts" +const val TAG_DNS_DIRECT_OUT = "dns-direct-out" const val LOCALHOST = "127.0.0.1" @@ -72,6 +74,59 @@ private fun sanitizeDnsEntry(value: String): String { return value.filterNot { it.isISOControl() }.trim() } +// sing-box 1.12+ 新 DNS 服务器格式:把旧版地址字符串拆成 type / server / server_port / path +private fun DNSServerOptions.applyDnsAddress(address: String) { + val value = address.trim() + val hasScheme = value.contains("://") + val scheme = if (hasScheme) value.substringBefore("://").lowercase() else "" + val hostPart = if (hasScheme) value.substringAfter("://") else value + when (val typeName = scheme.ifEmpty { + when (hostPart.lowercase()) { + "local", "fakeip", "dhcp" -> hostPart.lowercase() + else -> "udp" + } + }) { + "local" -> type = "local" + "fakeip" -> type = "fakeip" + "dhcp" -> { + type = "dhcp" + if (hasScheme && hostPart.isNotBlank()) interface_ = hostPart + } + "https", "h3" -> { + val url = (if (hasScheme) value else "https://$value").toHttpUrlOrNull() + if (url == null) { + // 原版对坏地址更宽容:解析失败降级为 local,不让服务起不来 + Logs.d("invalid DNS address, fallback to local: $address") + type = "local" + return + } + type = typeName + server = url.host + if (url.port != 443) server_port = url.port + path = url.encodedPath.takeIf { it.isNotBlank() && it != "/" } + } + "tls", "quic", "tcp", "udp" -> { + type = typeName + val hostPort = hostPart.trim() + when { + hostPort.startsWith("[") -> { + server = hostPort.substringAfter("[").substringBefore("]") + hostPort.substringAfter("]", "").removePrefix(":").toIntOrNull()?.let { server_port = it } + } + hostPort.count { it == ':' } == 1 -> { + server = hostPort.substringBefore(":") + hostPort.substringAfter(":").toIntOrNull()?.let { server_port = it } + } + else -> server = hostPort + } + } + else -> { + Logs.d("unsupported DNS address, fallback to local: $address") + type = "local" + } + } +} + private fun parseDnsHosts(value: String): Map> { val hosts = linkedMapOf>() value.lineSequence().forEach { line -> @@ -79,7 +134,7 @@ private fun parseDnsHosts(value: String): Map> { if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEach val tokens = trimmed.split("\\s+".toRegex()) if (tokens.size < 2) return@forEach - val domain = tokens.first() + val domain = tokens.first().lowercase() val addresses = tokens.drop(1).filter { it.isIpAddress() } if (addresses.isEmpty()) return@forEach hosts.getOrPut(domain) { mutableListOf() }.addAll(addresses) @@ -101,7 +156,11 @@ private fun serverHostOf(bean: AbstractBean): String? { } fun buildConfig( - proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean = false + proxy: ProxyEntity, + forTest: Boolean = false, + forExport: Boolean = false, + // 整组测速:一次构建组内全部节点的 outbound,配合单实例按 tag 并发测速 + testProfiles: List? = null, ): ConfigBuildResult { if (proxy.type == TYPE_CONFIG) { @@ -267,23 +326,22 @@ fun buildConfig( } endpoint_independent_nat = true mtu = DataStore.mtu - domain_strategy = genDomainStrategy(DataStore.resolveDestination) auto_route = true strict_route = DataStore.strictRoute - sniff = needSniff - sniff_override_destination = needSniffOverride when (ipv6Mode) { IPv6Mode.DISABLE -> { - inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") + address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") } IPv6Mode.ONLY -> { - inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") + address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") } else -> { - inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") - inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") + address = listOf( + VpnService.PRIVATE_VLAN4_CLIENT + "/28", + VpnService.PRIVATE_VLAN6_CLIENT + "/126" + ) } } }) @@ -292,9 +350,6 @@ fun buildConfig( tag = TAG_MIXED listen = bind listen_port = DataStore.mixedPort - domain_strategy = genDomainStrategy(DataStore.resolveDestination) - sniff = needSniff - sniff_override_destination = needSniffOverride if (DataStore.mixedInboundHasAuth) { users = listOf(User().also { u -> u.username = DataStore.mixedUsername @@ -312,9 +367,6 @@ fun buildConfig( override_android_vpn = true rules = mutableListOf() rule_set = mutableListOf() - - // 添加并发拨号设置 - concurrent_dial = DataStore.concurrentDial } // returns outbound tag @@ -489,13 +541,7 @@ fun buildConfig( } } - if (needGlobal && DataStore.enableTLSFragment) { - val outboundMap = currentOutbound.asMap() - val tlsOptions = outboundMap["tls"] as? Map<*, *> - if (tlsOptions?.get("enabled") == true) { - currentOutbound._hack_config_map["detour"] = TAG_FRAGMENT - } - } + // TLS 分片已改为路由动作(route-options tls_fragment),不再用 detour 绕经 fragment 出站 } // internal & external @@ -516,8 +562,10 @@ fun buildConfig( domainListDNSDirectForce.add("full:$serverAddress") } } - _hack_config_map["domain_strategy"] = - if (forTest) "" else defaultServerDomainStrategy + // 1.12+ 用 domain_resolver 替代已废弃的 domain_strategy + _hack_config_map["domain_resolver"] = + if (forTest || defaultServerDomainStrategy.isEmpty()) "dns-direct" + else mapOf("server" to "dns-direct", "strategy" to defaultServerDomainStrategy) _hack_config_map["tag"] = tagOut @@ -588,17 +636,21 @@ fun buildConfig( } // build outbounds - if (buildSelector) { - val list = group.id.let { SagerDatabase.proxyDao.getByGroup(it) } + if (buildSelector || testProfiles != null) { + val list = testProfiles + ?: group?.id?.let { SagerDatabase.proxyDao.getByGroup(it) } + ?: emptyList() list.forEach { tagMap[it.id] = buildChain(it.id, it) } - outbounds.add(0, Outbound_SelectorOptions().apply { - type = "selector" - tag = TAG_PROXY - default_ = tagMap[proxy.id] - outbounds = tagMap.values.toList() - }) + if (buildSelector) { + outbounds.add(0, Outbound_SelectorOptions().apply { + type = "selector" + tag = TAG_PROXY + default_ = tagMap[proxy.id] + outbounds = tagMap.values.toList() + }) + } } else { val mainTag = buildChain(0, proxy) tagMap[proxy.id] = mainTag @@ -809,8 +861,8 @@ fun buildConfig( -2L -> { if (shouldAddDnsRule) { userDNSRuleList += makeDnsRuleObj().apply { - server = "dns-block" - disable_cache = true + _hack_config_map["action"] = "predefined" + _hack_config_map["rcode"] = "NOERROR" } } @@ -820,8 +872,8 @@ fun buildConfig( if (tag.startsWith("ruleset-") && tagInfo != null && !tagInfo.second) { userDNSRuleList += DNSRule_DefaultOptions().apply { rule_set = mutableListOf(tag) - server = "dns-block" - disable_cache = true + _hack_config_map["action"] = "predefined" + _hack_config_map["rcode"] = "NOERROR" } } } @@ -869,16 +921,20 @@ fun buildConfig( type = "direct" }) + // 1.14 拒绝 detour 到「空 direct 出站」,DNS 服务器需要带参数的直连出站 + outbounds.add(Outbound().apply { + tag = TAG_DNS_DIRECT_OUT + type = "direct" + _hack_config_map["udp_fragment"] = false + }) + if (DataStore.enableTLSFragment) { - val fragmentOutbound = Outbound().apply { + // 1.14 移除了 direct outbound 的 fragment 字段,分片改由路由动作实现 + outbounds.add(Outbound().apply { tag = TAG_FRAGMENT type = "direct" - _hack_config_map["fragment"] = Fragment().apply { - length = DataStore.fragmentLength - interval = DataStore.fragmentInterval - }.asMap() - } - outbounds.add(fragmentOutbound) + _hack_config_map["udp_fragment"] = false + }) } fun isExclusiveCustomHost(host: String): Boolean { @@ -917,43 +973,41 @@ fun buildConfig( } dns.servers.add(DNSServerOptions().apply { - address = "rcode://success" - tag = "dns-block" - }) - - dns.servers.add(DNSServerOptions().apply { - address = "local" + type = "local" tag = "dns-local" - detour = TAG_DIRECT + detour = TAG_DNS_DIRECT_OUT }) directDNS.firstOrNull().let { dns.servers.add(DNSServerOptions().apply { - address = it ?: throw Exception("No direct DNS, check your settings!") + applyDnsAddress(it ?: throw Exception("No direct DNS, check your settings!")) tag = "dns-direct" - detour = TAG_DIRECT - address_resolver = "dns-local" - strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) + detour = TAG_DNS_DIRECT_OUT + domain_resolver = "dns-local" }) } remoteDns.firstOrNull().let { // Always use direct DNS for urlTest if (!forTest) dns.servers.add(DNSServerOptions().apply { - address = it ?: throw Exception("No remote DNS, check your settings!") + applyDnsAddress(it ?: throw Exception("No remote DNS, check your settings!")) tag = "dns-remote" - address_resolver = "dns-direct" - strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) + domain_resolver = "dns-direct" }) } if (dnsHosts.isNotEmpty()) { dns.servers.add(DNSServerOptions().apply { + type = "hosts" tag = TAG_DNS_HOSTS - _hack_config_map["type"] = "hosts" - _hack_config_map["predefined"] = dnsHosts + predefined = dnsHosts }) } + // 1.14 起 DNS server 不再支持独立 strategy(domain_strategy 已废弃),统一放到 dns.strategy + autoDnsDomainStrategy(genDomainStrategy(DataStore.resolveDestination))?.let { + dns.strategy = it + } + dns.final_ = if (forTest) "dns-direct" else "dns-remote" // dns object user rules @@ -987,17 +1041,13 @@ fun buildConfig( source_ip_cidr = listOf("224.0.0.0/3", "ff00::/8") action = "reject" }) - // FakeDNS obj + // FakeDNS if (useFakeDns) { - dns.fakeip = DNSFakeIPOptions().apply { - enabled = true - inet4_range = "198.18.0.0/15" - inet6_range = "fc00::/18" - } dns.servers.add(DNSServerOptions().apply { - address = "fakeip" + type = "fakeip" tag = "dns-fake" - strategy = "ipv4_only" + inet4_range = "198.18.0.0/15" + inet6_range = "fc00::/18" }) dns.rules.add(DNSRule_DefaultOptions().apply { inbound = listOf(deviceInboundTag) @@ -1008,8 +1058,9 @@ fun buildConfig( } if (dnsHosts.isNotEmpty()) { dns.rules.add(0, DNSRule_DefaultOptions().apply { + // hosts 服务器精确匹配域名,未命中会返回 NXDOMAIN,因此只在请求阶段命中 hosts 域名时才走它 + domain = dnsHosts.keys.map { "full:${it.lowercase()}" } server = TAG_DNS_HOSTS - _hack_config_map["ip_accept_any"] = true }) } // avoid loopback @@ -1032,13 +1083,12 @@ fun buildConfig( val serverTag = "dns-sub-$gid" dns.servers.add(DNSServerOptions().apply { - address = resolver + applyDnsAddress(resolver) tag = serverTag detour = TAG_DIRECT if (!resolver.isIpAddress()) { - address_resolver = "dns-direct" + domain_resolver = "dns-direct" } - strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("server")) }) dns.rules.add(0, DNSRule_DefaultOptions().apply { makeSingBoxRule(hosts) @@ -1047,6 +1097,32 @@ fun buildConfig( } } + // 1.12+ 起 TLS 分片改为路由动作(原 fragment outbound 字段已移除) + if (DataStore.enableTLSFragment) { + route.rules.add(0, Rule_DefaultOptions().apply { + action = "route-options" + _hack_config_map["tls_fragment"] = true + }) + } + + if (!forTest) { + // 1.12+ 起出站缺省域名解析器由 route 统一指定(1.16 将改为强制) + route._hack_config_map["default_domain_resolver"] = "dns-direct" + // 1.13+ 起 inbound 的 sniff / domain_strategy 字段被移除,改用路由规则动作 + if (needSniff) { + route.rules.add(0, Rule_DefaultOptions().apply { + action = "sniff" + }) + } + val resolveStrategy = genDomainStrategy(DataStore.resolveDestination) + if (resolveStrategy.isNotEmpty()) { + route.rules.add(0, Rule_DefaultOptions().apply { + action = "resolve" + _hack_config_map["strategy"] = resolveStrategy + }) + } + } + if (!forTest) _hack_custom_config = DataStore.globalCustomConfig }.let { val configMap = it.asMap() diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt index 91069e1aaa..aba4124dc2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt @@ -3,6 +3,8 @@ package io.nekohasekai.sagernet.fmt.snell import moe.matsuri.nb4a.SingBoxOptions fun buildSingBoxOutboundSnellBean(bean: SnellBean): SingBoxOptions.Outbound_SnellOptions { + // sing-box 1.14 的 snell outbound 只接受 version 4 / 6 两种联合结构 + val version = if (bean.version == 6) 6 else 4 return SingBoxOptions.Outbound_SnellOptions().apply { type = "snell" server = bean.serverAddress @@ -11,23 +13,25 @@ fun buildSingBoxOutboundSnellBean(bean: SnellBean): SingBoxOptions.Outbound_Snel if (!bean.userKey.isNullOrBlank()) { userkey = bean.userKey } - version = bean.version + this.version = version if (bean.network != null && bean.network.isNotBlank()) { network = bean.network } - if (bean.version == 6) { - if (!bean.mode.isNullOrBlank() && bean.mode != "default") { - mode = bean.mode + if (version == 6) { + // 1.14 的 v6 只有 mode(default / unshaped / unsafe-raw),quic_proxy_mode 已被移除 + val snellMode = bean.mode?.takeIf { it in setOf("default", "unshaped", "unsafe-raw") } + if (snellMode != null && snellMode != "default") { + mode = snellMode } - if (bean.quicProxyMode == true) { - quic_proxy_mode = true - } - } else if (bean.obfsMode != null && bean.obfsMode.isNotBlank()) { - obfs_mode = if (bean.version != null && bean.version >= 4 && bean.obfsMode == "tls") "" else bean.obfsMode - if (obfs_mode.isNotBlank() && bean.obfsHost != null && bean.obfsHost.isNotBlank()) { - obfs_host = bean.obfsHost + } else if (!bean.obfsMode.isNullOrBlank()) { + val obfsModeValue = bean.obfsMode + if (obfsModeValue in setOf("none", "http", "tls")) { + if (obfsModeValue != "none") obfs_mode = obfsModeValue + if (obfs_mode.isNotBlank() && !bean.obfsHost.isNullOrBlank()) { + obfs_host = bean.obfsHost + } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt index 80d125400d..5911c564b7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt @@ -17,6 +17,11 @@ private val supportedKcpHeaderType = arrayOf( "none", "srtp", "utp", "wechat-video", "dtls", "wireguard", "dns" ) +/** sing-box common/tls 实际支持的 uTLS 指纹(小写) */ +private val knownUTLSFingerprints = setOf( + "chrome", "firefox", "edge", "safari", "360", "qq", "ios", "android", "random", "randomized" +) + data class VmessQRCode( var v: String = "", var ps: String = "", @@ -790,20 +795,27 @@ fun buildSingBoxOutboundTLS(bean: StandardV2RayBean): OutboundTLSOptions? { } } if (bean.certificates.isNotBlank()) certificate = bean.certificates - var fp = bean.utlsFingerprint + var fp = bean.utlsFingerprint?.trim()?.lowercase().orEmpty() + // sing-box 的 uTLS 只认这些指纹。Xray 的 "unsafe" 表示禁用 uTLS, + // 直接透传会让内核初始化失败(unknown uTLS fingerprint), + // 进而整组测速/连接全部失败。 if (bean.realityPubKey.isNotBlank()) { reality = OutboundRealityOptions().apply { enabled = true public_key = bean.realityPubKey short_id = bean.realityShortId } - if (fp.isNullOrBlank()) fp = "chrome" + // REALITY 必须启用 uTLS,未知指纹退回最通用的 chrome + if (fp !in knownUTLSFingerprints) fp = "chrome" } - if (fp.isNotBlank()) { + if (fp in knownUTLSFingerprints) { utls = OutboundUTLSOptions().apply { enabled = true fingerprint = fp } + } else if (fp.isNotEmpty()) { + // 未知指纹按「不启用 uTLS」处理,即 Xray 的 unsafe 语义 + Logs.w("unsupported uTLS fingerprint \"$fp\", falling back to default TLS") } if (bean.enableECH) { ech = OutboundECHOptions().apply { @@ -842,6 +854,8 @@ fun buildSingBoxOutboundStandardV2RayBean(bean: StandardV2RayBean): Outbound { if (bean.encryption.isNotBlank() && bean.encryption != "auto") { flow = bean.encryption } + // 内核已通过 patch 支持 VLESS Encryption(mlkem768x25519plus,见 + // buildScript/lib/core/patch-singbox.py),机场的 Clash 订阅可正常导入使用 if (bean.vlessEncryption.isNotBlank() && bean.vlessEncryption != "none") { encryption = bean.vlessEncryption } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt new file mode 100644 index 0000000000..39668b1440 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt @@ -0,0 +1,145 @@ +package io.nekohasekai.sagernet.ktx + +import io.nekohasekai.sagernet.database.DataStore +import libcore.Libcore +import okhttp3.Credentials +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * 通过当前连接请求 Cloudflare trace,获取实际出口 IP 与归属国家。 + * + * 与节点服务器地址的 geoip 查询不同,这里反映的是真实落地: + * 中转/落地分离、CDN 入口等场景都能得到正确结果。 + * + * 有两条通道:优先走本机 mixed inbound(纯 Kotlin,行为可控), + * 失败时退回 libcore 的默认出站请求。 + */ +object EgressInfo { + + data class Result(val ip: String, val countryCode: String) { + + /** 本地化国家名,如 "美国" */ + val countryName: String + get() { + if (countryCode.length != 2) return "" + return try { + Locale.Builder().setRegion(countryCode).build().displayCountry + } catch (e: Exception) { + "" + } + } + + /** ISO 国家码转国旗 emoji,如 "US" -> 🇺🇸 */ + val flag: String + get() { + if (countryCode.length != 2) return "" + val upper = countryCode.uppercase() + val first = upper[0] + val second = upper[1] + if (first !in 'A'..'Z' || second !in 'A'..'Z') return "" + val base = 0x1F1E6 // Regional Indicator Symbol Letter A + return String(Character.toChars(base + (first - 'A'))) + + String(Character.toChars(base + (second - 'A'))) + } + + /** 一行展示文本,如 "🇺🇸 美国" */ + val display: String + get() { + val name = countryName + return when { + flag.isNotEmpty() && name.isNotEmpty() -> "$flag $name" + name.isNotEmpty() -> name + flag.isNotEmpty() -> flag + else -> countryCode + } + } + } + + private const val TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace" + + @Volatile + var current: Result? = null + private set + + fun clear() { + current = null + } + + /** + * 请求 trace 并解析出口 IP / 国家码,阻塞调用,必须在后台线程执行。 + * 失败返回 null(连接刚建立时可能尚未就绪,调用方自行重试)。 + */ + fun fetch(): Result? { + val body = fetchBodyViaMixedInbound() ?: fetchBodyViaLibcore() + if (body.isNullOrBlank()) return null + + var ip = "" + var loc = "" + for (line in body.lineSequence()) { + when { + line.startsWith("ip=") -> ip = line.removePrefix("ip=").trim() + line.startsWith("loc=") -> loc = line.removePrefix("loc=").trim() + } + } + if (ip.isEmpty()) { + Logs.d("egress trace: no ip in response") + return null + } + + val result = Result(ip, loc.uppercase()) + current = result + return result + } + + /** 走本机 mixed inbound(HTTP 代理) */ + private fun fetchBodyViaMixedInbound(): String? { + return try { + val port = DataStore.mixedPort + if (port <= 0) return null + val client = OkHttpClient.Builder() + .proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", port))) + .proxyAuthenticator { _, response -> + if (DataStore.mixedInboundHasAuth) { + response.request.newBuilder() + .header( + "Proxy-Authorization", + Credentials.basic(DataStore.mixedUsername, DataStore.mixedSecret) + ) + .build() + } else { + response.request + } + } + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + client.newCall(Request.Builder().url(TRACE_URL).build()).execute().use { response -> + if (!response.isSuccessful) { + Logs.d("egress trace via mixed: HTTP ${response.code}") + null + } else { + response.body?.string() + } + } + } catch (e: Exception) { + Logs.d("egress trace via mixed failed: ${e.message}") + null + } + } + + /** 退回经内核默认出站请求 */ + private fun fetchBodyViaLibcore(): String? { + return try { + Libcore.httpGet(TRACE_URL, 6000) + } catch (e: Exception) { + Logs.d("egress trace via libcore failed: ${e.message}") + null + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index a6734cd9fa..6c72178ced 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -52,6 +52,7 @@ import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.proto.UrlTest +import io.nekohasekai.sagernet.bg.proto.BatchTestInstance import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProfileManager @@ -65,6 +66,7 @@ import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.group.RawUpdater +import io.nekohasekai.sagernet.ktx.EgressInfo import io.nekohasekai.sagernet.ktx.FixedLinearLayoutManager import io.nekohasekai.sagernet.ktx.FixedGridLayoutManager import io.nekohasekai.sagernet.ktx.Logs @@ -339,17 +341,7 @@ class ConfigurationFragment @JvmOverloads constructor( } } - val searchView = toolbar.findViewById(R.id.action_search) - if (searchView != null) { - searchView.setOnQueryTextListener(this) - searchView.maxWidth = Int.MAX_VALUE - - searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - cancelSearch(searchView) - } - } - } + setupSearchView() groupPager = view.findViewById(R.id.group_pager) tabLayout = view.findViewById(R.id.group_tab) @@ -361,11 +353,20 @@ class ConfigurationFragment @JvmOverloads constructor( groupPager.offscreenPageLimit = 2 TabLayoutMediator(tabLayout, groupPager) { tab, position -> - if (adapter.groupList.size > position) { - tab.text = adapter.groupList[position].displayName() - } - tab.view.setOnLongClickListener { // clear toast - true + val group = adapter.groupList.getOrNull(position) + if (group != null) { + tab.text = group.displayName() + tab.view.setOnLongClickListener { + // 长按直接进入分组详情编辑页(名称/类型/订阅链接,含改名与删除) + if (!group.ungrouped) { + startActivity(Intent(requireContext(), GroupSettingsActivity::class.java).apply { + putExtra(GroupSettingsActivity.EXTRA_GROUP_ID, group.id) + }) + } + true + } + } else { + tab.view.setOnLongClickListener { true } } }.attach() @@ -758,6 +759,7 @@ class ConfigurationFragment @JvmOverloads constructor( if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } + // 国家图标与本轮测试结果一起清理 onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } @@ -863,6 +865,29 @@ class ConfigurationFragment @JvmOverloads constructor( urlTest() } + R.id.action_move_to_group -> { + val currentAdapter = getCurrentGroupFragment()?.adapter + if (currentAdapter != null) { + moveProfilesToGroup( + currentAdapter.selectedIds.mapNotNull { currentAdapter.configurationList[it] } + ) + } + } + + R.id.action_select_all -> { + val currentAdapter = getCurrentGroupFragment()?.adapter + if (currentAdapter != null) { + currentAdapter.selectedIds.clear() + currentAdapter.selectedIds.addAll(currentAdapter.configurationIdList) + currentAdapter.notifyDataSetChanged() + updateSelectionUi() + } + } + + R.id.action_exit_select -> { + exitSelectionMode() + } + R.id.action_global_mode -> { item.isChecked = !item.isChecked DataStore.globalMode = item.isChecked @@ -1136,6 +1161,129 @@ class ConfigurationFragment @JvmOverloads constructor( } } + // ---------------- 多选 & 移动分组 ---------------- + + fun isSelectionMode(): Boolean = getCurrentGroupFragment()?.adapter?.selectionMode == true + + fun enterSelectionMode(initialId: Long) { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + currentAdapter.selectionMode = true + currentAdapter.selectedIds.clear() + currentAdapter.selectedIds.add(initialId) + currentAdapter.notifyDataSetChanged() + updateSelectionUi() + } + + fun exitSelectionMode() { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + currentAdapter.selectionMode = false + currentAdapter.selectedIds.clear() + currentAdapter.notifyDataSetChanged() + restoreToolbarMenu() + } + + fun toggleSelection(proxyEntity: ProxyEntity) { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + if (!currentAdapter.selectionMode) return + if (!currentAdapter.selectedIds.remove(proxyEntity.id)) { + currentAdapter.selectedIds.add(proxyEntity.id) + } + if (currentAdapter.selectedIds.isEmpty()) { + // 取消最后一个即退出多选 + exitSelectionMode() + return + } + val index = currentAdapter.configurationIdList.indexOf(proxyEntity.id) + if (index >= 0) currentAdapter.notifyItemChanged(index) + updateSelectionUi() + } + + private fun updateSelectionUi() { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + if (toolbar.menu.findItem(R.id.action_select_all) == null) { + toolbar.menu.clear() + toolbar.inflateMenu(R.menu.profile_multi_select_menu) + toolbar.setOnMenuItemClickListener(this) + } + toolbar.title = getString(R.string.selected_count, currentAdapter.selectedIds.size) + } + + private fun setupSearchView() { + val sv = toolbar.findViewById(R.id.action_search) ?: return + sv.setOnQueryTextListener(this) + sv.maxWidth = Int.MAX_VALUE + sv.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + cancelSearch(sv) + } + } + } + + private fun restoreToolbarMenu() { + if (select) return + toolbar.menu.clear() + toolbar.inflateMenu(R.menu.add_profile_menu) + toolbar.menu.findItem(R.id.action_global_mode)?.isChecked = DataStore.globalMode + toolbar.setOnMenuItemClickListener(this) + toolbar.title = getString(R.string.app_name) + // 菜单被重新 inflate,SearchView 需要重新绑定监听 + setupSearchView() + } + + fun moveProfilesToGroup(profiles: List) { + if (profiles.isEmpty()) return + val sourceGroupId = profiles.first().groupId + runOnDefaultDispatcher { + val groups = SagerDatabase.groupDao.allGroups().filter { it.id != sourceGroupId } + onMainDispatcher { + if (groups.isEmpty()) { + snackbar(getString(R.string.no_other_group)).show() + return@onMainDispatcher + } + val names = groups.map { it.displayName() }.toTypedArray() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.move_to_group) + .setItems(names) { _, which -> + performMoveProfiles(profiles, groups[which]) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + } + + private fun performMoveProfiles(profiles: List, target: ProxyGroup) { + runOnDefaultDispatcher { + try { + for (profile in profiles) { + profile.groupId = target.id + profile.userOrder = SagerDatabase.proxyDao.nextOrder(target.id) ?: 1 + ProfileManager.updateProfile(profile) + } + onMainDispatcher { + val currentAdapter = getCurrentGroupFragment()?.adapter + currentAdapter?.apply { + selectionMode = false + selectedIds.clear() + } + restoreToolbarMenu() + snackbar( + getString(R.string.moved_to_group, profiles.size, target.displayName()) + ).show() + } + // 已创建的分组页面都从库里重新加载(reloadProfiles 会直接访问数据库,不能放主线程) + for (fragment in adapter.groupFragments.values.toList()) { + fragment.adapter?.reloadProfiles() + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + } + } + @OptIn(DelicateCoroutinesApi::class) fun urlTest() { if (DataStore.runningTest) return else DataStore.runningTest = true @@ -1146,34 +1294,129 @@ class ConfigurationFragment @JvmOverloads constructor( val mainJob = runOnDefaultDispatcher { val profilesList = SagerDatabase.proxyDao.getByGroup(group.id) + // 开始测试前先清理上一轮的测试结果,避免中途取消时新旧状态混在一起 + if (profilesList.isNotEmpty()) { + var cleared = false + for (profile in profilesList) { + if (profile.status != 0) { + profile.status = 0 + profile.ping = 0 + profile.error = null + cleared = true + } + } + if (cleared) { + try { + ProfileManager.updateProfile(profilesList) + } catch (e: Exception) { + Logs.w(e) + } + } + onMainDispatcher { + getCurrentGroupFragment()?.adapter?.clearTestResults() + } + } test.proxyN = profilesList.size val profiles = ConcurrentLinkedQueue(profilesList) - repeat(DataStore.connectionTestConcurrent) { - testJobs.add(launch(Dispatchers.IO) { - val urlTest = UrlTest() // note: this is NOT in bg process - while (isActive) { - val profile = profiles.poll() ?: break - profile.status = 0 - + // 单实例测速(对标 Clash 的 /proxies/{name}/delay): + // 整组只构建并启动一次内核,随后按 outbound tag 并发拨号 + var batch: BatchTestInstance? = null + try { + if (profilesList.isNotEmpty()) { + val instance = BatchTestInstance( + profilesList, + DataStore.connectionTestURL, + DataStore.connectionTestTimeout + ) + batch = instance + instance.prepare() + // 内核不支持/配置非法的节点已被剔除,直接判失败,不能让它们停在「测试中」 + for ((id, reason) in instance.excluded) { + val profile = profilesList.firstOrNull { it.id == id } ?: continue + profile.status = 3 + profile.error = reason try { - val result = urlTest.doTest(profile) - profile.status = 1 - profile.ping = result - } catch (e: PluginManager.PluginNotFoundException) { - profile.status = 2 - profile.error = e.readableMessage + ProfileManager.updateProfile(profile) } catch (e: Exception) { - profile.status = 3 - profile.error = e.readableMessage + Logs.w(e) } + test.update(profile) + } + val tags = instance.tagMap + + repeat(DataStore.connectionTestConcurrent.coerceAtLeast(1)) { + testJobs.add(launch(Dispatchers.IO) { + while (isActive) { + val profile = profiles.poll() ?: break + profile.status = 0 + val tag = tags[profile.id] + if (tag == null) { + profile.status = 3 + profile.error = "missing outbound tag" + } else try { + // 并发拨号偶尔会被瞬时失败(DNS/握手抖动)误判, + // 失败后隔一小会儿重试一次再判不可用 + var result = 0 + var lastError: Exception? = null + for (attempt in 0 until 2) { + try { + result = instance.testTag(tag) + lastError = null + break + } catch (e: PluginManager.PluginNotFoundException) { + throw e + } catch (e: Exception) { + lastError = e + if (attempt == 0) delay(300L) + } + } + if (lastError != null) throw lastError!! + profile.status = 1 + profile.ping = result + } catch (e: PluginManager.PluginNotFoundException) { + profile.status = 2 + profile.error = e.readableMessage + } catch (e: Exception) { + profile.status = 3 + profile.error = e.readableMessage + } + + // 立即落库并刷新该行,让延迟和国旗同时出现 + // (只 notifyItemChanged 不会更新延迟:adapter 里缓存的是旧对象) + try { + ProfileManager.updateProfile(profile) + } catch (e: Exception) { + Logs.w(e) + runOnMainDispatcher { + getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + } + } + + test.update(profile) + } + }) + } + testJobs.joinAll() + } + } catch (e: Exception) { + // 内核启动失败:整组标记失败,避免所有节点停在「测试中」 + Logs.w(e) + for (profile in profilesList) { + if (profile.status == 0) { + profile.status = 3 + profile.error = batch?.excluded?.get(profile.id) ?: e.readableMessage + try { + ProfileManager.updateProfile(profile) + } catch (ignored: Exception) { + } test.update(profile) } - }) + } + } finally { + batch?.close() } - testJobs.joinAll() - runOnMainDispatcher { test.cancel() } @@ -1412,6 +1655,9 @@ class ConfigurationFragment @JvmOverloads constructor( } itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, 0) { + // 长按改为弹菜单,拖拽排序由菜单里的「调整顺序」手动启动 + override fun isLongPressDragEnabled(): Boolean = false + override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder @@ -1698,6 +1944,36 @@ class ConfigurationFragment @JvmOverloads constructor( private val pendingTrafficUpdates = HashSet() private val profileStatePayload = Any() + // 多选移动分组 + var selectionMode = false + val selectedIds = LinkedHashSet() + + fun startDrag(holder: RecyclerView.ViewHolder) { + if (::itemTouchHelper.isInitialized) itemTouchHelper.startDrag(holder) + } + + fun removeProfiles(ids: Collection) { + var removed = false + for (id in ids) { + val index = configurationIdList.indexOf(id) + if (index >= 0) { + configurationIdList.removeAt(index) + configurationList.remove(id) + notifyItemRemoved(index) + removed = true + } + } + if (removed) { + refreshFromPosition(0) + notifyItemRangeChanged(0, itemCount) + } + } + + fun notifyProfileChanged(profileId: Long) { + val index = configurationIdList.indexOf(profileId) + if (index >= 0) notifyItemChanged(index) + } + private fun getItem(profileId: Long): ProxyEntity { var profile = configurationList[profileId] if (profile == null) { @@ -2194,6 +2470,8 @@ class ConfigurationFragment @JvmOverloads constructor( private val card = view as MaterialCardView private val selectedIndicator: View = view.findViewById(R.id.selected_indicator) val editButton: ImageView = view.findViewById(R.id.edit) + val urlTestButton: ImageView = view.findViewById(R.id.url_test) + val urlTestButtonDouble: ImageView = view.findViewById(R.id.url_test_double) val doubleColumnMenuButton: ImageView = view.findViewById(R.id.double_column_menu) val shareLayout: LinearLayout = view.findViewById(R.id.share) val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) @@ -2203,12 +2481,28 @@ class ConfigurationFragment @JvmOverloads constructor( init { view.setOnClickListener { val proxyEntity = entity + val owner = parentFragment as? ConfigurationFragment + if (owner != null && owner.isSelectionMode()) { + owner.toggleSelection(proxyEntity) + return@setOnClickListener + } if (select) { (requireActivity() as SelectCallback).returnProfile(proxyEntity.id) } else { selectProfile(proxyEntity) } } + view.setOnLongClickListener { + if (select) return@setOnLongClickListener false + val owner = parentFragment as? ConfigurationFragment + ?: return@setOnLongClickListener false + if (owner.isSelectionMode()) { + owner.toggleSelection(entity) + } else { + showProfileMenu(view, entity, this) + } + true + } profileStatus.setOnClickListener { val proxyEntity = entity if (proxyEntity.status == 3) { @@ -2224,6 +2518,12 @@ class ConfigurationFragment @JvmOverloads constructor( ) ) } + urlTestButton.setOnClickListener { + startUrlTest(entity) + } + urlTestButtonDouble.setOnClickListener { + startUrlTest(entity) + } removeButton.setOnClickListener { removeProfile(entity) } @@ -2238,6 +2538,52 @@ class ConfigurationFragment @JvmOverloads constructor( } } + private var urlTestRunning = false + + /** 对当前这一个节点做 URL Test,完成后就地刷新该行状态 */ + private fun startUrlTest(proxyEntity: ProxyEntity) { + if (urlTestRunning || select) return + urlTestRunning = true + urlTestButton.isEnabled = false + urlTestButtonDouble.isEnabled = false + profileStatus.setTextColor( + requireContext().getColorAttr(android.R.attr.textColorSecondary) + ) + profileStatus.text = getString(R.string.connection_test_testing) + val targetId = proxyEntity.id + + runOnDefaultDispatcher { + try { + val ping = UrlTest().doTest(proxyEntity) + proxyEntity.status = 1 + proxyEntity.ping = ping + proxyEntity.error = null + } catch (e: PluginManager.PluginNotFoundException) { + proxyEntity.status = 2 + proxyEntity.error = e.readableMessage + } catch (e: Exception) { + proxyEntity.status = 3 + proxyEntity.error = e.readableMessage + } + try { + ProfileManager.updateProfile(proxyEntity) + } catch (e: Exception) { + Logs.w(e) + } + runOnMainDispatcher { + urlTestRunning = false + if (::entity.isInitialized && entity.id == targetId) { + urlTestButton.isEnabled = true + urlTestButtonDouble.isEnabled = true + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + adapter?.notifyItemChanged(pos) + } + } + } + } + } + private fun selectProfile(proxyEntity: ProxyEntity) { val pf = parentFragment as? ConfigurationFragment ?: return runOnDefaultDispatcher { @@ -2253,6 +2599,8 @@ class ConfigurationFragment @JvmOverloads constructor( } if (update) { + // 换节点(非 selector 分组会重连):清掉旧出口信息,重连成功后重新探测 + EgressInfo.clear() ProfileManager.postUpdate(lastSelected, noTraffic = true) if (DataStore.serviceState.canStop && reloadAccess.tryLock()) { SagerNet.reloadService() @@ -2316,6 +2664,57 @@ class ConfigurationFragment @JvmOverloads constructor( popup.show() } + /** 长按节点卡片的菜单:移动分组 / 多选 / 排序 / 编辑 / 分享 / 删除 */ + private fun showProfileMenu( + anchor: View, + proxyEntity: ProxyEntity, + holder: ConfigurationHolder, + ) { + val owner = parentFragment as? ConfigurationFragment ?: return + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.profile_long_click_menu, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_move_to_group -> { + owner.moveProfilesToGroup(listOf(proxyEntity)) + true + } + + R.id.action_multi_select -> { + owner.enterSelectionMode(proxyEntity.id) + true + } + + R.id.action_drag_sort -> { + adapter?.startDrag(holder) + true + } + + R.id.action_edit -> { + anchor.context.startActivity( + proxyEntity.settingIntent( + anchor.context, proxyGroup.type == GroupType.SUBSCRIPTION + ) + ) + true + } + + R.id.action_share -> { + showShareMenu(anchor, proxyEntity) + true + } + + R.id.action_delete -> { + removeProfile(proxyEntity) + true + } + + else -> false + } + } + popup.show() + } + private fun applySelected(selected: Boolean) { val ctx = card.context val surface = ctx.getColorAttr(R.attr.colorSurface) @@ -2425,12 +2824,16 @@ class ConfigurationFragment @JvmOverloads constructor( if (isDoubleColumn) { editButton.isGone = true + urlTestButton.isGone = true + urlTestButtonDouble.isGone = select shareLayout.isGone = true removeButton.isGone = true doubleColumnMenuButton.isVisible = true } else { shareLayout.isGone = selectOrChain editButton.isGone = select + urlTestButton.isGone = select + urlTestButtonDouble.isGone = true removeButton.isGone = select doubleColumnMenuButton.isGone = true } @@ -2448,6 +2851,22 @@ class ConfigurationFragment @JvmOverloads constructor( removeButton.isEnabled = !started applySelected(selected) + // 多选高亮:只覆盖选中态,未选中保持 applySelected 的描边 + val inSelection = adapter?.selectionMode == true + val picked = inSelection && adapter?.selectedIds?.contains(proxyEntity.id) == true + if (picked) { + card.strokeWidth = (2 * resources.displayMetrics.density).toInt() + card.strokeColor = requireContext().getColour(R.color.material_green_500) + } + if (inSelection) { + // 多选时隐藏操作按钮,避免误触编辑/删除 + editButton.isGone = true + removeButton.isGone = true + shareLayout.isGone = true + urlTestButton.isGone = true + urlTestButtonDouble.isGone = true + } + if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { shareLayer.setBackgroundColor(Color.TRANSPARENT) shareButton.setImageResource(R.drawable.ic_social_share) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt index 5b11495849..d772435779 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -425,6 +425,12 @@ class MainActivity : ThemedActivity(), R.id.nav_group -> displayFragment(GroupFragment()) R.id.nav_route -> displayFragment(RouteFragment()) + R.id.nav_apps -> { + binding.drawerLayout.closeDrawers() + startActivity(Intent(this, AppManagerActivity::class.java)) + return false + } + R.id.nav_settings -> displayFragment(SettingsFragment()) R.id.nav_traffic -> displayFragment(WebviewFragment()) R.id.nav_tools -> displayFragment(ToolsFragment()) @@ -507,6 +513,8 @@ class MainActivity : ThemedActivity(), DataStore.selectedProxy = id DataStore.currentProfile = id refreshConfigurationProfileState() + // 分组内切换节点:服务不会重启,主动刷新出口 IP / 国家 + binding.stats.refreshEgressInfo(1000L) runOnDefaultDispatcher { ProfileManager.postUpdate(old, true) ProfileManager.postUpdate(id, true) diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt index 728a4cca34..3a7f3c1fd3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -8,6 +8,8 @@ import android.view.View import android.widget.TextView import androidx.appcompat.widget.TooltipCompat import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.lifecycle.whenStarted import com.google.android.material.bottomappbar.BottomAppBar @@ -21,6 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class StatsBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @@ -41,6 +44,9 @@ class StatsBar @JvmOverloads constructor( private lateinit var statusText: TextView private lateinit var txText: TextView private lateinit var rxText: TextView + private lateinit var egressCountry: TextView + private lateinit var egressIp: TextView + private var egressJob: Job? = null @Suppress("unused") private lateinit var behavior: YourBehavior private var currentState = BaseService.State.Idle @@ -119,9 +125,94 @@ class StatsBar @JvmOverloads constructor( statusText = findViewById(R.id.status) txText = findViewById(R.id.tx) rxText = findViewById(R.id.rx) + egressCountry = findViewById(R.id.egress_country) + egressIp = findViewById(R.id.egress_ip) super.setOnClickListener(l) } + /** + * 连接成功后请求出口 IP / 归属国家并显示在状态栏右侧。 + * 刚连上时可能还没就绪,失败会重试几次。 + */ + private fun refreshEgress() { + val activity = context as? MainActivity ?: return + egressJob?.cancel() + if (::egressCountry.isInitialized) { + // 先给个占位,探测失败时也不会一片空白 + egressCountry.text = context.getString(R.string.egress_detecting) + egressCountry.isVisible = true + egressIp.text = null + egressIp.isGone = true + } + egressJob = activity.lifecycleScope.launch(Dispatchers.IO) { + var fetched: EgressInfo.Result? = EgressInfo.current + for (attempt in 0..2) { + if (fetched != null) break + fetched = EgressInfo.fetch() + if (fetched != null) break + delay(1500L) + } + val info = fetched + withContext(Dispatchers.Main) { + if (!::egressCountry.isInitialized) return@withContext + if (info == null) { + egressCountry.text = "--" + egressIp.text = null + egressIp.isGone = true + } else { + egressCountry.text = info.display + egressIp.text = info.ip + egressCountry.isVisible = true + egressIp.isVisible = true + } + } + } + } + + /** + * 切换节点后刷新出口信息。 + * selector 即时切换不会改变服务状态,重连分支也可能命中缓存, + * 因此这里主动清缓存并延迟一小段时间等新出口就绪。 + */ + fun refreshEgressInfo(delayMs: Long = 0L) { + if (currentState != BaseService.State.Connected) { + EgressInfo.clear() + return + } + val activity = context as? MainActivity ?: return + egressJob?.cancel() + EgressInfo.clear() + egressJob = activity.lifecycleScope.launch(Dispatchers.IO) { + if (delayMs > 0) delay(delayMs) + var fetched: EgressInfo.Result? = null + for (attempt in 0..2) { + fetched = EgressInfo.fetch() + if (fetched != null) break + delay(1500L) + } + val info = fetched ?: return@launch + withContext(Dispatchers.Main) { + if (!::egressCountry.isInitialized) return@withContext + egressCountry.text = info.display + egressIp.text = info.ip + egressCountry.isVisible = true + egressIp.isVisible = true + } + } + } + + private fun clearEgress() { + egressJob?.cancel() + egressJob = null + EgressInfo.clear() + if (::egressCountry.isInitialized) { + egressCountry.text = null + egressIp.text = null + egressCountry.isGone = true + egressIp.isGone = true + } + } + private fun setStatus(text: CharSequence) { statusText.text = text TooltipCompat.setTooltipText(this, text) @@ -279,7 +370,9 @@ class StatsBar @JvmOverloads constructor( updateHideOnScroll() if (state == BaseService.State.Connected) { setStatus(app.getText(R.string.vpn_connected)) + refreshEgress() } else { + clearEgress() updateSpeed(0, 0) setStatus( context.getText( diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java index 0cfb24a225..0195bc9327 100644 --- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java +++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java @@ -312,8 +312,6 @@ public static class DNSOptions extends SingBoxOption { public Boolean reverse_mapping; - public DNSFakeIPOptions fakeip; - // Generate note: nested type DNSClientOptions public String strategy; @@ -329,41 +327,48 @@ public static class DNSOptions extends SingBoxOption { public static class DNSServerOptions extends SingBoxOption { + public String type; + public String tag; - public String address; + public String server; - public String address_resolver; + public Integer server_port; - public String address_strategy; + public String path; - public Long address_fallback_delay; + public String detour; - public String strategy; + public String domain_resolver; - public String detour; + public String domain_strategy; - } + public Map> predefined; - public static class DNSClientOptions extends SingBoxOption { + public String inet4_range; - public String strategy; + public String inet6_range; - public Boolean disable_cache; + public String method; - public Boolean disable_expire; + public Map headers; - public Boolean independent_cache; + @SerializedName("interface") + public String interface_; + + public Boolean prefer_go; } - public static class DNSFakeIPOptions extends SingBoxOption { + public static class DNSClientOptions extends SingBoxOption { - public Boolean enabled; + public String strategy; - public String inet4_range; + public Boolean disable_cache; - public String inet6_range; + public Boolean disable_expire; + + public Boolean independent_cache; } @@ -2828,6 +2833,11 @@ public static class Inbound_TunOptions extends Inbound { public Integer mtu; + // Generate note: Listable + public List address; + + public String dns_mode; + // Generate note: Listable public List inet4_address; diff --git a/app/src/main/res/layout/layout_main.xml b/app/src/main/res/layout/layout_main.xml index 85092521ca..88937fb6b7 100644 --- a/app/src/main/res/layout/layout_main.xml +++ b/app/src/main/res/layout/layout_main.xml @@ -58,9 +58,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?android:attr/selectableItemBackground" - android:orientation="vertical" + android:gravity="center_vertical" + android:orientation="horizontal" android:padding="16dp"> + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index de0669c10d..b17c4562c2 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -53,6 +53,17 @@ tools:text="@string/profile_name" /> + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/profile_multi_select_menu.xml b/app/src/main/res/menu/profile_multi_select_menu.xml new file mode 100644 index 0000000000..0287fdc563 --- /dev/null +++ b/app/src/main/res/menu/profile_multi_select_menu.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 16b353acd5..7faad7fee1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -297,6 +297,7 @@ 始终显示地址 始终在配置卡片上显示服务器地址 清空流量统计数据 + 检测中… 连接测试 清理测试结果 域名不存在 @@ -610,4 +611,17 @@ QUIC Proxy 模式 仅 v6:旧版 v5 QUIC Proxy 兼容 仅适用于 v4-v6 + + + 移动到分组 + 多选 + 调整顺序 + 全选 + 已选 %d 项 + 没有其他分组 + 已移动 %1$d 个节点到 %2$s + 修改分组名 + 删除分组 + 退出多选 + 关闭 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8dc47181c..513056dc5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -352,6 +352,7 @@ Always display the server address on the configuration card Clear traffic statistics + Detecting… Connection test Clear test results TCPing @@ -696,4 +697,18 @@ v6 only: legacy v5 QUIC Proxy compatibility v4-v6 only + + Move to group + Multi-select + Reorder + Select all + %d selected + No other group + Moved %1$d profiles to %2$s + Rename group + Delete group + Exit selection + + Close + diff --git a/buildScript/compile-hevtun.sh b/buildScript/compile-hevtun.sh index 9aeb37ef23..ae3437f7dc 100755 --- a/buildScript/compile-hevtun.sh +++ b/buildScript/compile-hevtun.sh @@ -29,7 +29,7 @@ source "$SCRIPT_DIR/init/env_ndk.sh" HEV_VERSION="${HEV_VERSION:-2.17.1}" HEV_SRC="${HEV_SRC:-$REPO_DIR/hev-socks5-tunnel}" -ABIS="armeabi-v7a arm64-v8a x86 x86_64" +ABIS="${HEV_ABIS:-armeabi-v7a arm64-v8a x86 x86_64}" OUT_DIR="$REPO_DIR/app/src/main/jniLibs" BUILD_DIR="$REPO_DIR/.hev-build" diff --git a/buildScript/lib/core/get_source.sh b/buildScript/lib/core/get_source.sh index e41a02fd3e..eed41a2e3c 100755 --- a/buildScript/lib/core/get_source.sh +++ b/buildScript/lib/core/get_source.sh @@ -4,17 +4,27 @@ set -e source "buildScript/init/env.sh" ENV_NB4A=1 source "buildScript/lib/core/get_source_env.sh" + +# 记住仓库根:下面要 pushd 到仓库父目录去 clone 依赖(../sing-box、../libneko) +REPO_ROOT="$(pwd)" pushd .. -#### +#### sing-box 内核源码 +# 默认取官方仓库;想换回 starifly fork 可用 SING_BOX_REPO 覆盖 +SING_BOX_REPO="${SING_BOX_REPO:-https://github.com/SagerNet/sing-box.git}" if [ ! -d "sing-box" ]; then - git clone --no-checkout https://github.com/starifly/sing-box.git + git clone --no-checkout "$SING_BOX_REPO" fi pushd sing-box -git checkout "$COMMIT_SING_BOX" +git fetch origin "$COMMIT_SING_BOX" 2>/dev/null || git fetch origin +git checkout -f "$COMMIT_SING_BOX" popd +#### 应用 NekoBox 定制补丁(geoip/geosite 钩子 + 选择器回调) +# 必须用绝对路径:当前目录已是仓库父目录 +python3 "$REPO_ROOT/buildScript/lib/core/patch-singbox.py" sing-box + #### if [ ! -d "libneko" ]; then diff --git a/buildScript/lib/core/get_source_env.sh b/buildScript/lib/core/get_source_env.sh index 61550575dd..76341f4b53 100644 --- a/buildScript/lib/core/get_source_env.sh +++ b/buildScript/lib/core/get_source_env.sh @@ -1,2 +1,3 @@ -export COMMIT_SING_BOX="0b7a2fc2f2c33f754e5fe5a1bb3e0a4f610b6aa9" +# sing-box v1.14.0 (官方 release) +export COMMIT_SING_BOX="0b8995879f29a9b98ee027bc17b75e101445b238" export COMMIT_LIBNEKO="6a85c185d62435a5293ef70ac3b638ae3ee1efa7" diff --git a/buildScript/lib/core/patch-singbox.py b/buildScript/lib/core/patch-singbox.py new file mode 100644 index 0000000000..197c49ae74 --- /dev/null +++ b/buildScript/lib/core/patch-singbox.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +"""把 NekoBox 需要的 nekoutils 钩子补进 sing-box 源码。 + +用法: patch-singbox.py + +对应 starifly fork 相对官方的四处定制: + 1. nekoutils/srs.go geoip/geosite 规则集加载钩子 + 2. nekoutils/callback.go 选择器切换回调 + 3. route/rule/rule_set_local.go 识别 "geoip:" / "geosite:" 前缀 + 4. protocol/group/selector.go 切换节点时回调 + +脚本是幂等的:已打过补丁的文件会跳过;锚点匹配不到会报错退出, +避免上游结构变化后静默产出错误的内核。 +""" + +import pathlib +import subprocess +import sys + +SRS_GO = '''package nekoutils + +import "github.com/sagernet/sing-box/option" + +var GetGeoIPHeadlessRules func(name string) ([]option.HeadlessRule, error) + +var GetGeoSiteHeadlessRules func(name string) ([]option.HeadlessRule, error) +''' + +CALLBACK_GO = '''package nekoutils + +var Selector_OnProxySelected func(selectorTag string, tag string) +''' + +RULE_SET_BLOCK = '''\t\tif strings.HasPrefix(options.LocalOptions.Path, "geoip:") { +\t\t\trules, err := nekoutils.GetGeoIPHeadlessRules(strings.TrimPrefix(options.LocalOptions.Path, "geoip:")) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\terr = ruleSet.reloadRules(rules) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\treturn ruleSet, nil +\t\t} +\t\tif strings.HasPrefix(options.LocalOptions.Path, "geosite:") { +\t\t\trules, err := nekoutils.GetGeoSiteHeadlessRules(strings.TrimPrefix(options.LocalOptions.Path, "geosite:")) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\terr = ruleSet.reloadRules(rules) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\treturn ruleSet, nil +\t\t} +''' + +SELECTOR_BLOCK = '''\tif nekoutils.Selector_OnProxySelected != nil { +\t\tnekoutils.Selector_OnProxySelected(s.Tag(), tag) +\t} +''' + + + +VLESS_ENCRYPTION_DIR = pathlib.Path(__file__).resolve().parent / "singbox-vless-encryption" + +VLESS_OPTION_FIELD = ( + "\t// Encryption 是 VLESS 内部的后量子加密层(mlkem768x25519plus),\n" + "\t// 位于传输层之下、与 TLS/REALITY 无关。空或 \"none\" 表示关闭。\n" + "\tEncryption string `json:\"encryption,omitempty\"`\n" +) + +VLESS_FIELD_BLOCK = ( + "\t// VLESS 后量子加密层(mlkem768x25519plus),未配置时为 nil\n" + "\tencryption *encryption.ClientInstance\n" +) + +VLESS_NEW_BLOCK = ( + "\t// VLESS Encryption:层在 VLESS 内部、传输之下,与 TLS/REALITY 无关\n" + "\tif options.Encryption != \"\" && options.Encryption != \"none\" {\n" + "\t\tencryptionConfig, err := parseClientEncryption(options.Encryption)\n" + "\t\tif err != nil {\n" + "\t\t\treturn nil, E.Cause(err, \"parse encryption\")\n" + "\t\t}\n" + "\t\toutbound.encryption = &encryption.ClientInstance{}\n" + "\t\tif err := outbound.encryption.Init(encryptionConfig.keys, encryptionConfig.xorMode, encryptionConfig.seconds, encryptionConfig.padding); err != nil {\n" + "\t\t\treturn nil, E.Cause(err, \"initialize encryption\")\n" + "\t\t}\n" + "\t}\n" +) + + +def fail(message: str) -> None: + print("patch-singbox: " + message, file=sys.stderr) + sys.exit(1) + + +def patch_rule_set_local(root: pathlib.Path) -> None: + path = root / "route" / "rule" / "rule_set_local.go" + text = path.read_text(encoding="utf-8") + if "nekoutils.GetGeoIPHeadlessRules" in text: + print("patch-singbox: rule_set_local.go 已打过补丁,跳过") + return + + anchor = ( + "\t} else {\n" + '\t\tfilePath := filemanager.BasePath(ctx, strings.ReplaceAll(options.LocalOptions.Path, C.RuleSetTagPlaceholder, tag))' + ) + if anchor not in text: + fail("rule_set_local.go 锚点未匹配,上游结构可能已变化") + + text = text.replace( + anchor, + "\t} else {\n" + RULE_SET_BLOCK + + "\t\tfilePath := filemanager.BasePath(ctx, strings.ReplaceAll(options.LocalOptions.Path, C.RuleSetTagPlaceholder, tag))", + 1, + ) + const_import = '\tC "github.com/sagernet/sing-box/constant"' + if const_import not in text: + fail("rule_set_local.go 找不到 constant import 锚点") + text = text.replace( + const_import, + const_import + '\n\t"github.com/sagernet/sing-box/nekoutils"', + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: rule_set_local.go patched") + + +def patch_selector(root: pathlib.Path) -> None: + path = root / "protocol" / "group" / "selector.go" + text = path.read_text(encoding="utf-8") + if "Selector_OnProxySelected" in text: + print("patch-singbox: selector.go 已打过补丁,跳过") + return + + anchor = "\ts.interruptGroup.Interrupt(s.interruptExternalConnections)" + if anchor not in text: + fail("selector.go 锚点未匹配,上游结构可能已变化") + + text = text.replace(anchor, SELECTOR_BLOCK + anchor, 1) + log_import = '\t"github.com/sagernet/sing-box/log"' + if log_import not in text: + fail("selector.go 找不到 log import 锚点") + text = text.replace( + log_import, + log_import + '\n\t"github.com/sagernet/sing-box/nekoutils"', + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: selector.go patched") + + + +def patch_vless_encryption(root: pathlib.Path) -> None: + """移植 VLESS Encryption(mlkem768x25519plus,来自 qingwan 的 1.15 定制内核)。 + + 上游 sing-box(含 1.15.0-alpha.13 / dev-next)至今未实现该特性, + 这是 Xray 2025 引入的协议层后量子加密。实现文件为纯 Go 无第三方依赖。 + """ + if not VLESS_ENCRYPTION_DIR.is_dir(): + fail("找不到 singbox-vless-encryption 目录,仓库不完整") + + # 1) 拷贝实现文件 + vless_dir = root / "protocol" / "vless" + if not vless_dir.is_dir(): + fail("protocol/vless 目录不存在,上游结构可能已变化") + enc_dir = vless_dir / "encryption" + enc_dir.mkdir(parents=True, exist_ok=True) + (vless_dir / "lx_encryption.go").write_bytes( + (VLESS_ENCRYPTION_DIR / "lx_encryption.go").read_bytes() + ) + for src in sorted((VLESS_ENCRYPTION_DIR / "encryption").glob("*.go")): + (enc_dir / src.name).write_bytes(src.read_bytes()) + + # 2) option/vless.go:新增 encryption 字段 + path = root / "option" / "vless.go" + text = path.read_text(encoding="utf-8") + if 'json:"encryption,omitempty"' in text: + print("patch-singbox: option/vless.go 已打过补丁,跳过") + else: + anchor = '\tNetwork NetworkList `json:"network,omitempty"`\n' + if anchor not in text: + fail("option/vless.go 锚点未匹配,上游结构可能已变化") + text = text.replace(anchor, anchor + VLESS_OPTION_FIELD, 1) + path.write_text(text, encoding="utf-8") + print("patch-singbox: option/vless.go patched") + + # 3) protocol/vless/outbound.go:结构体字段 / 初始化 / 两处握手接线 + path = root / "protocol" / "vless" / "outbound.go" + text = path.read_text(encoding="utf-8") + if "wrapEncryption" in text: + print("patch-singbox: protocol/vless/outbound.go 已打过补丁,跳过") + return + + import_anchor = '\t"github.com/sagernet/sing-box/option"\n' + if import_anchor not in text: + fail("outbound.go 找不到 option import 锚点") + text = text.replace( + import_anchor, + import_anchor + '\t"github.com/sagernet/sing-box/protocol/vless/encryption"\n', + 1, + ) + + field_anchor = "\tpacketAddr bool\n\txudp bool\n" + if field_anchor not in text: + fail("outbound.go 结构体锚点未匹配,上游结构可能已变化") + text = text.replace(field_anchor, field_anchor + VLESS_FIELD_BLOCK, 1) + + new_anchor = ( + "\toutbound.multiplexDialer, err = mux.NewClientWithOptions(" + "(*vlessDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex))" + ) + if new_anchor not in text: + fail("outbound.go NewOutbound 锚点未匹配,上游结构可能已变化") + text = text.replace(new_anchor, VLESS_NEW_BLOCK + new_anchor, 1) + + dial_anchor = "\tif err != nil {\n\t\treturn nil, err\n\t}\n\tswitch N.NetworkName(network) {" + if dial_anchor not in text: + fail("outbound.go DialContext 锚点未匹配,上游结构可能已变化") + text = text.replace( + dial_anchor, + "\tif err != nil {\n\t\treturn nil, err\n\t}\n" + "\tconn, err = h.wrapEncryption(ctx, conn)\n" + "\tif err != nil {\n\t\treturn nil, err\n\t}\n" + "\tswitch N.NetworkName(network) {", + 1, + ) + + packet_anchor = "\tif err != nil {\n\t\tcommon.Close(conn)\n\t\treturn nil, err\n\t}\n\tif h.xudp {" + if packet_anchor not in text: + fail("outbound.go ListenPacket 锚点未匹配,上游结构可能已变化") + text = text.replace( + packet_anchor, + "\tif err != nil {\n\t\tcommon.Close(conn)\n\t\treturn nil, err\n\t}\n" + "\tconn, err = h.wrapEncryption(ctx, conn)\n" + "\tif err != nil {\n\t\tcommon.Close(conn)\n\t\treturn nil, err\n\t}\n" + "\tif h.xudp {", + 1, + ) + + path.write_text(text, encoding="utf-8") + print("patch-singbox: protocol/vless/outbound.go patched") + + +XHTTP_BRIDGE_DIR = pathlib.Path(__file__).resolve().parent / "singbox-xhttp" +XHTTP_VERSION = "0.1.6" + +XHTTP_OPTION_TYPES = ''' +// V2RayXHTTPOptions 是 Xray XHTTP(split-http)传输的配置。 +// 字段名与 App 侧 SingBoxOptions.XHTTPOptions 的序列化结果保持一致, +// 实际传输实现由 github.com/justinwoo280/sing-xhttp 提供。 +type V2RayXHTTPOptions struct { + Mode string `json:"mode,omitempty"` + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + UplinkHTTPMethod string `json:"uplink_http_method,omitempty"` + NoGRPCHeader bool `json:"no_grpc_header,omitempty"` + NoSSEHeader bool `json:"no_sse_header,omitempty"` + XPaddingBytes *V2RayXHTTPRange `json:"x_padding_bytes,omitempty"` + ScMaxEachPostBytes *V2RayXHTTPRange `json:"sc_max_each_post_bytes,omitempty"` + ScMaxBufferedPosts int32 `json:"sc_max_buffered_posts,omitempty"` + ScMinPostsIntervalMs *V2RayXHTTPRange `json:"sc_min_posts_interval_ms,omitempty"` + ScStreamUpServerSecs *V2RayXHTTPRange `json:"sc_stream_up_server_secs,omitempty"` + XPaddingObfsMode bool `json:"x_padding_obfs_mode,omitempty"` + XPaddingPlacement string `json:"x_padding_placement,omitempty"` + XPaddingKey string `json:"x_padding_key,omitempty"` + XPaddingHeader string `json:"x_padding_header,omitempty"` + XPaddingMethod string `json:"x_padding_method,omitempty"` + SessionPlacement string `json:"session_placement,omitempty"` + SessionKey string `json:"session_key,omitempty"` + SessionIDTable string `json:"session_id_table,omitempty"` + SessionIDLength *V2RayXHTTPRange `json:"session_id_length,omitempty"` + SeqPlacement string `json:"seq_placement,omitempty"` + SeqKey string `json:"seq_key,omitempty"` + UplinkDataPlacement string `json:"uplink_data_placement,omitempty"` + UplinkDataKey string `json:"uplink_data_key,omitempty"` + UplinkChunkSize *V2RayXHTTPRange `json:"uplink_chunk_size,omitempty"` + ServerMaxHeaderBytes int32 `json:"server_max_header_bytes,omitempty"` + // App 侧可能下发但当前实现不使用的字段,保留以免严格解码失败 + CongestionController any `json:"congestion_controller,omitempty"` + Cwnd any `json:"cwnd,omitempty"` + Download any `json:"download,omitempty"` + Xmux *V2RayXHTTPXmuxOptions `json:"xmux,omitempty"` +} + +type V2RayXHTTPRange struct { + From int32 `json:"from,omitempty"` + To int32 `json:"to,omitempty"` +} + +type V2RayXHTTPXmuxOptions struct { + MaxConcurrency *V2RayXHTTPRange `json:"max_concurrency,omitempty"` + MaxConnections *V2RayXHTTPRange `json:"max_connections,omitempty"` + CMaxReuseTimes *V2RayXHTTPRange `json:"c_max_reuse_times,omitempty"` + HMaxRequestTimes *V2RayXHTTPRange `json:"h_max_request_times,omitempty"` + HMaxReusableSecs *V2RayXHTTPRange `json:"h_max_reusable_secs,omitempty"` + HKeepAlivePeriod int32 `json:"h_keep_alive_period,omitempty"` +} +''' + + +def patch_xhttp(root: pathlib.Path) -> None: + """接入 Xray XHTTP(split-http)传输。 + + 上游 sing-box 没有该传输。这里只补三处注册点,传输实现来自 + github.com/justinwoo280/sing-xhttp(不依赖 sing-box,接口结构一致), + 另加一层 option → xhttp.Options 的适配文件 transport/v2ray/xhttp.go。 + """ + bridge = XHTTP_BRIDGE_DIR / "xhttp.go" + if not bridge.is_file(): + fail("找不到 singbox-xhttp/xhttp.go,仓库不完整") + + # 1) constant/v2ray.go:类型常量 + path = root / "constant" / "v2ray.go" + text = path.read_text(encoding="utf-8") + if "V2RayTransportTypeXHTTP" in text: + print("patch-singbox: constant/v2ray.go 已打过补丁,跳过") + else: + anchor = '\tV2RayTransportTypeHTTPUpgrade = "httpupgrade"\n' + if anchor not in text: + fail("constant/v2ray.go 锚点未匹配,上游结构可能已变化") + text = text.replace(anchor, anchor + '\tV2RayTransportTypeXHTTP = "xhttp"\n', 1) + path.write_text(text, encoding="utf-8") + print("patch-singbox: constant/v2ray.go patched") + + # 2) option/v2ray_transport.go:选项类型 + 三个 switch 分支 + path = root / "option" / "v2ray_transport.go" + text = path.read_text(encoding="utf-8") + if "V2RayXHTTPOptions" in text: + print("patch-singbox: option/v2ray_transport.go 已打过补丁,跳过") + else: + edits = [ + ( + '\tHTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"`\n', + '\tHTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"`\n' + '\tXHTTPOptions V2RayXHTTPOptions `json:"-"`\n', + ), + ( + 'enum:"http,ws,quic,grpc,httpupgrade"', + 'enum:"http,ws,quic,grpc,httpupgrade,xhttp"', + ), + ( + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = o.HTTPUpgradeOptions\n', + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = o.HTTPUpgradeOptions\n' + '\tcase C.V2RayTransportTypeXHTTP:\n\t\tv = o.XHTTPOptions\n', + ), + ( + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = &o.HTTPUpgradeOptions\n', + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = &o.HTTPUpgradeOptions\n' + '\tcase C.V2RayTransportTypeXHTTP:\n\t\tv = &o.XHTTPOptions\n', + ), + ( + '\t\t\t{Value: C.V2RayTransportTypeHTTPUpgrade, StructType: reflect.TypeFor[V2RayHTTPUpgradeOptions]()},\n', + '\t\t\t{Value: C.V2RayTransportTypeHTTPUpgrade, StructType: reflect.TypeFor[V2RayHTTPUpgradeOptions]()},\n' + '\t\t\t{Value: C.V2RayTransportTypeXHTTP, StructType: reflect.TypeFor[V2RayXHTTPOptions]()},\n', + ), + ] + for old, new in edits: + if old not in text: + fail("option/v2ray_transport.go 锚点未匹配: " + old.strip()[:48]) + text = text.replace(old, new, 1) + text = text.rstrip("\n") + "\n" + XHTTP_OPTION_TYPES + path.write_text(text, encoding="utf-8") + print("patch-singbox: option/v2ray_transport.go patched") + + # 3) transport/v2ray/transport.go:服务端/客户端分支 + path = root / "transport" / "v2ray" / "transport.go" + text = path.read_text(encoding="utf-8") + if "V2RayTransportTypeXHTTP" in text: + print("patch-singbox: transport/v2ray/transport.go 已打过补丁,跳过") + else: + server_anchor = ( + "\tcase C.V2RayTransportTypeHTTPUpgrade:\n" + "\t\treturn v2rayhttpupgrade.NewServer(ctx, logger, options.HTTPUpgradeOptions, tlsConfig, handler)\n" + ) + client_anchor = ( + "\tcase C.V2RayTransportTypeHTTPUpgrade:\n" + "\t\treturn v2rayhttpupgrade.NewClient(ctx, dialer, serverAddr, options.HTTPUpgradeOptions, tlsConfig)\n" + ) + if server_anchor not in text: + fail("transport/v2ray/transport.go 服务端锚点未匹配") + if client_anchor not in text: + fail("transport/v2ray/transport.go 客户端锚点未匹配") + text = text.replace( + server_anchor, + server_anchor + + "\tcase C.V2RayTransportTypeXHTTP:\n" + + "\t\treturn NewXHTTPServer(ctx, logger, options.XHTTPOptions, tlsConfig, handler)\n", + 1, + ) + text = text.replace( + client_anchor, + client_anchor + + "\tcase C.V2RayTransportTypeXHTTP:\n" + + "\t\treturn NewXHTTPClient(ctx, dialer, serverAddr, options.XHTTPOptions, tlsConfig)\n", + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: transport/v2ray/transport.go patched") + + # 4) 适配层文件 + (root / "transport" / "v2ray" / "xhttp.go").write_bytes(bridge.read_bytes()) + + # 5) go.mod:声明外部依赖(go.sum 由构建时的 -mod=mod 补全) + path = root / "go.mod" + text = path.read_text(encoding="utf-8") + if "justinwoo280/sing-xhttp" not in text: + anchor = "\nrequire (\n" + if anchor not in text: + fail("go.mod 找不到 require 块") + text = text.replace( + anchor, + anchor + "\tgithub.com/justinwoo280/sing-xhttp v" + XHTTP_VERSION + "\n", + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: go.mod patched") + + # 6) 尽力格式化改动的文件(gofmt 不可用时跳过) + for target in ( + root / "constant" / "v2ray.go", + root / "option" / "v2ray_transport.go", + root / "transport" / "v2ray" / "transport.go", + root / "transport" / "v2ray" / "xhttp.go", + ): + try: + subprocess.run(["gofmt", "-w", str(target)], capture_output=True, check=False) + except OSError: + break + + +def main() -> None: + if len(sys.argv) < 2: + fail("缺少 sing-box 源码目录参数") + root = pathlib.Path(sys.argv[1]) + if not (root / "go.mod").is_file(): + fail(f"{root} 不像是 sing-box 源码目录") + + nekoutils = root / "nekoutils" + nekoutils.mkdir(exist_ok=True) + (nekoutils / "srs.go").write_text(SRS_GO, encoding="utf-8") + (nekoutils / "callback.go").write_text(CALLBACK_GO, encoding="utf-8") + + patch_rule_set_local(root) + patch_selector(root) + patch_vless_encryption(root) + patch_xhttp(root) + print("patch-singbox: done") + + +if __name__ == "__main__": + main() diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/client.go b/buildScript/lib/core/singbox-vless-encryption/encryption/client.go new file mode 100644 index 0000000000..edca768901 --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/client.go @@ -0,0 +1,223 @@ +// Package encryption implements the VLESS `encryption` layer +// (mlkem768x25519plus) — a post-quantum handshake that sits inside VLESS, +// beneath the transport and independent of TLS. +// +// lx: ported from the sing-box fork at github.com/starifly/sing-box +// (protocol/vless/encryption), which carries the same GPL-3.0 license and the +// same upstream base as this tree. Client side only: this fork is +// client-focused, so the server half (`decryption`) was deliberately not taken. +// Local helpers that replace its common/xray/{cpuid,crypto} imports live in +// lx_support.go. See SPECS/TASKS/032-VLESS_ENCRYPTION_MLKEM768. +package encryption + +import ( + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "io" + "net" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ClientInstance struct { + NfsPKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + Seconds uint32 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Expire time.Time + PfsKey []byte + Ticket []byte +} + +func (i *ClientInstance) Init(nfsPKeysBytes [][]byte, xorMode, seconds uint32, padding string) (err error) { + if i.NfsPKeys != nil { + return E.New("already initialized") + } + l := len(nfsPKeysBytes) + if l == 0 { + return E.New("empty nfsPKeysBytes") + } + i.NfsPKeys = make([]any, l) + i.NfsPKeysBytes = nfsPKeysBytes + i.Hash32s = make([][32]byte, l) + for j, k := range nfsPKeysBytes { + if len(k) == 32 { + if i.NfsPKeys[j], err = ecdh.X25519().NewPublicKey(k); err != nil { + return + } + i.RelaysLength += 32 + 32 + } else { + if i.NfsPKeys[j], err = mlkem.NewEncapsulationKey768(k); err != nil { + return + } + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(k) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.Seconds = seconds + return ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) +} + +func (i *ClientInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ClientInstance) Handshake(conn net.Conn) (*CommonConn, error) { + if i.NfsPKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, hasAESGCMHardware) + + ivAndRealysLength := 16 + i.RelaysLength + pfsKeyExchangeLength := 18 + 1184 + 32 + 16 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + clientHello := make([]byte, ivAndRealysLength+pfsKeyExchangeLength+paddingLength) + + iv := clientHello[:16] + rand.Read(iv) + relays := clientHello[16:ivAndRealysLength] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsPKeys { + index := 32 + if k, ok := k.(*ecdh.PublicKey); ok { + privateKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + copy(relays, privateKey.PublicKey().Bytes()) + var err error + nfsKey, err = privateKey.ECDH(k) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.EncapsulationKey768); ok { + var ciphertext []byte + nfsKey, ciphertext = k.Encapsulate() + copy(relays, ciphertext) + index = 1088 + } + if i.XorMode > 0 { // this xor can (others can't) be recovered by client's config, revealing an X25519 public key / ML-KEM-768 ciphertext, that's why "native" values + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // make X25519 public key / ML-KEM-768 ciphertext distinguishable from random bytes + } + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // make this relay irreplaceable + } + if j == len(i.NfsPKeys)-1 { + break + } + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays[index:], i.Hash32s[j+1][:]) + relays = relays[index+32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + if i.Seconds > 0 { + i.RWLock.RLock() + if time.Now().Before(i.Expire) { + c.Client = i + c.UnitedKey = append(i.PfsKey, nfsKey...) // different unitedKey for each connection + nfsAEAD.Seal(clientHello[:ivAndRealysLength], nil, EncodeLength(32), nil) + nfsAEAD.Seal(clientHello[:ivAndRealysLength+18], nil, i.Ticket, nil) + i.RWLock.RUnlock() + c.PreWrite = clientHello[:ivAndRealysLength+18+32] + c.AEAD = NewAEAD(clientHello[ivAndRealysLength+18:ivAndRealysLength+18+32], c.UnitedKey, c.UseAES) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), nil, len(c.PreWrite), 16) + } + return c, nil + } + i.RWLock.RUnlock() + } + + pfsKeyExchange := clientHello[ivAndRealysLength : ivAndRealysLength+pfsKeyExchangeLength] + nfsAEAD.Seal(pfsKeyExchange[:0], nil, EncodeLength(pfsKeyExchangeLength-18), nil) + mlkem768DKey, _ := mlkem.GenerateKey768() + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + pfsPublicKey := append(mlkem768DKey.EncapsulationKey().Bytes(), x25519SKey.PublicKey().Bytes()...) + nfsAEAD.Seal(pfsKeyExchange[:18], nil, pfsPublicKey, nil) + + padding := clientHello[ivAndRealysLength+pfsKeyExchangeLength:] + nfsAEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + nfsAEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = ivAndRealysLength + pfsKeyExchangeLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(clientHello[:l]); err != nil { + return nil, err + } + clientHello = clientHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + encryptedPfsPublicKey := make([]byte, 1088+32+16) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + nfsAEAD.Open(encryptedPfsPublicKey[:0], MaxNonce, encryptedPfsPublicKey, nil) + mlkem768Key, err := mlkem768DKey.Decapsulate(encryptedPfsPublicKey[:1088]) + if err != nil { + return nil, err + } + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1088 : 1088+32]) + if err != nil { + return nil, err + } + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1088+32], c.UnitedKey, c.UseAES) + + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedTicket[:0], nil, encryptedTicket, nil); err != nil { + return nil, err + } + seconds := DecodeLength(encryptedTicket) + + if i.Seconds > 0 && seconds > 0 { + i.RWLock.Lock() + i.Expire = time.Now().Add(time.Duration(seconds) * time.Second) + i.PfsKey = pfsKey + i.Ticket = encryptedTicket[:16] + i.RWLock.Unlock() + } + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + length := DecodeLength(encryptedLength[:2]) + c.PeerPadding = make([]byte, length) // important: allows server sends padding slowly, eliminating 1-RTT's traffic pattern + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), NewCTR(c.UnitedKey, encryptedTicket[:16]), 0, length) + } + return c, nil +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/common.go b/buildScript/lib/core/singbox-vless-encryption/encryption/common.go new file mode 100644 index 0000000000..a5dab0325a --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/common.go @@ -0,0 +1,298 @@ +// lx: ported from github.com/starifly/sing-box (GPL-3.0, same upstream base). +// See SPECS/TASKS/032-VLESS_ENCRYPTION_MLKEM768. +package encryption + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" + "golang.org/x/crypto/chacha20poly1305" + "lukechampine.com/blake3" +) + +var OutBytesPool = sync.Pool{ + New: func() any { + return make([]byte, 5+8192+16) + }, +} + +type EncryptionConn interface { + net.Conn + IsEncryptionLayer() bool +} + +type CommonConn struct { + net.Conn + UseAES bool + Client *ClientInstance + UnitedKey []byte + PreWrite []byte + AEAD *AEAD + PeerAEAD *AEAD + PeerPadding []byte + rawInput bytes.Buffer + input bytes.Reader +} + +func NewCommonConn(conn net.Conn, useAES bool) *CommonConn { + return &CommonConn{ + Conn: conn, + UseAES: useAES, + } +} + +func (c *CommonConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + outBytes := OutBytesPool.Get().([]byte) + defer OutBytesPool.Put(outBytes) + for n := 0; n < len(b); { + b := b[n:] + if len(b) > 8192 { + b = b[:8192] // for avoiding another copy() in peer's Read() + } + n += len(b) + headerAndData := outBytes[:5+len(b)+16] + EncodeHeader(headerAndData, len(b)+16) + max := false + if bytes.Equal(c.AEAD.Nonce[:], MaxNonce) { + max = true + } + c.AEAD.Seal(headerAndData[:5], nil, b, headerAndData[:5]) + if max { + c.AEAD = NewAEAD(headerAndData, c.UnitedKey, c.UseAES) + } + if c.PreWrite != nil { + headerAndData = append(c.PreWrite, headerAndData...) + c.PreWrite = nil + } + if _, err := c.Conn.Write(headerAndData); err != nil { + return 0, err + } + } + return len(b), nil +} + +func (c *CommonConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + if c.PeerAEAD == nil { // client's 0-RTT + serverRandom := make([]byte, 16) + if _, err := io.ReadFull(c.Conn, serverRandom); err != nil { + return 0, err + } + c.PeerAEAD = NewAEAD(serverRandom, c.UnitedKey, c.UseAES) + if xorConn, ok := c.Conn.(*XorConn); ok { + xorConn.PeerCTR = NewCTR(c.UnitedKey, serverRandom) + } + } + if c.PeerPadding != nil { // client's 1-RTT + if _, err := io.ReadFull(c.Conn, c.PeerPadding); err != nil { + return 0, err + } + if _, err := c.PeerAEAD.Open(c.PeerPadding[:0], nil, c.PeerPadding, nil); err != nil { + return 0, err + } + c.PeerPadding = nil + } + if c.input.Len() > 0 { + return c.input.Read(b) + } + peerHeader := [5]byte{} + if _, err := io.ReadFull(c.Conn, peerHeader[:]); err != nil { + return 0, err + } + l, err := DecodeHeader(peerHeader[:]) // l: 17~17000 + if err != nil { + if c.Client != nil && errors.Is(err, ErrInvalidHeader) { // client's 0-RTT + c.Client.RWLock.Lock() + if bytes.HasPrefix(c.UnitedKey, c.Client.PfsKey) { + c.Client.Expire = time.Now() // expired + } + c.Client.RWLock.Unlock() + return 0, E.New("new handshake needed") + } + return 0, err + } + c.Client = nil + if c.rawInput.Cap() < l { + c.rawInput.Grow(l) // no need to use sync.Pool, because we are always reading + } + peerData := c.rawInput.Bytes()[:l] + if _, err := io.ReadFull(c.Conn, peerData); err != nil { + return 0, err + } + dst := peerData[:l-16] + if len(dst) <= len(b) { + dst = b[:len(dst)] // avoids another copy() + } + var newAEAD *AEAD + if bytes.Equal(c.PeerAEAD.Nonce[:], MaxNonce) { + newAEAD = NewAEAD(append(peerHeader[:], peerData...), c.UnitedKey, c.UseAES) + } + _, err = c.PeerAEAD.Open(dst[:0], nil, peerData, peerHeader[:]) + if newAEAD != nil { + c.PeerAEAD = newAEAD + } + if err != nil { + return 0, err + } + if len(dst) > len(b) { + c.input.Reset(dst[copy(b, dst):]) + dst = b // for len(dst) + } + return len(dst), nil +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *CommonConn) Upstream() any { + return c.Conn +} + +func (c *CommonConn) IsEncryptionLayer() bool { + return true +} + +type AEAD struct { + cipher.AEAD + Nonce [12]byte +} + +func NewAEAD(ctx, key []byte, useAES bool) *AEAD { + k := make([]byte, 32) + blake3.DeriveKey(k, string(ctx), key) + var aead cipher.AEAD + if useAES { + block, _ := aes.NewCipher(k) + aead, _ = cipher.NewGCM(block) + } else { + aead, _ = chacha20poly1305.New(k) + } + return &AEAD{AEAD: aead} +} + +func (a *AEAD) Seal(dst, nonce, plaintext, additionalData []byte) []byte { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Seal(dst, nonce, plaintext, additionalData) +} + +func (a *AEAD) Open(dst, nonce, ciphertext, additionalData []byte) ([]byte, error) { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Open(dst, nonce, ciphertext, additionalData) +} + +func IncreaseNonce(nonce []byte) []byte { + for i := range 12 { + nonce[11-i]++ + if nonce[11-i] != 0 { + break + } + } + return nonce +} + +var MaxNonce = bytes.Repeat([]byte{255}, 12) + +func EncodeLength(l int) []byte { + return []byte{byte(l >> 8), byte(l)} +} + +func DecodeLength(b []byte) int { + return int(b[0])<<8 | int(b[1]) +} + +func EncodeHeader(h []byte, l int) { + h[0] = 23 + h[1] = 3 + h[2] = 3 + h[3] = byte(l >> 8) + h[4] = byte(l) +} + +var ErrInvalidHeader = errors.New("invalid header") + +func DecodeHeader(h []byte) (l int, err error) { + l = int(h[3])<<8 | int(h[4]) + if h[0] != 23 || h[1] != 3 || h[2] != 3 { + l = 0 + } + if l < 17 || l > 17000 { // TODO: TLSv1.3 max length + err = fmt.Errorf("%w: %v", ErrInvalidHeader, h[:5]) // DO NOT CHANGE: relied by client's Read() + } + return +} + +func ParsePadding(padding string, paddingLens, paddingGaps *[][3]int) (err error) { + if padding == "" { + return + } + maxLen := 0 + for i, s := range strings.Split(padding, ".") { + x := strings.Split(s, "-") + if len(x) < 3 || x[0] == "" || x[1] == "" || x[2] == "" { + return E.New("invalid padding lenth/gap parameter: " + s) + } + y := [3]int{} + if y[0], err = strconv.Atoi(x[0]); err != nil { + return + } + if y[1], err = strconv.Atoi(x[1]); err != nil { + return + } + if y[2], err = strconv.Atoi(x[2]); err != nil { + return + } + if i == 0 && (y[0] < 100 || y[1] < 18+17 || y[2] < 18+17) { + return E.New("first padding length must not be smaller than 35") + } + if i%2 == 0 { + *paddingLens = append(*paddingLens, y) + maxLen += max(y[1], y[2]) + } else { + *paddingGaps = append(*paddingGaps, y) + } + } + if maxLen > 18+65535 { + return E.New("total padding length must not be larger than 65553") + } + return +} + +func CreatePadding(paddingLens, paddingGaps [][3]int) (length int, lens []int, gaps []time.Duration) { + if len(paddingLens) == 0 { + paddingLens = [][3]int{{100, 111, 1111}, {50, 0, 3333}} + paddingGaps = [][3]int{{75, 0, 111}} + } + for _, y := range paddingLens { + l := 0 + if y[0] >= int(randBetween(0, 100)) { + l = int(randBetween(int64(y[1]), int64(y[2]))) + } + lens = append(lens, l) + length += l + } + for _, y := range paddingGaps { + g := 0 + if y[0] >= int(randBetween(0, 100)) { + g = int(randBetween(int64(y[1]), int64(y[2]))) + } + gaps = append(gaps, time.Duration(g)*time.Millisecond) + } + return +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/lx_support.go b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_support.go new file mode 100644 index 0000000000..f32624ff5e --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_support.go @@ -0,0 +1,36 @@ +package encryption + +// lx: local replacements for the two tiny helpers the upstream reference kept in +// its own `common/xray/{cpuid,crypto}` packages. Vendoring those wholesale would +// drag in a large Xray-compat tree for ~20 lines of logic, so the pieces this +// package actually uses live here instead. + +import ( + "crypto/rand" + "math/big" + "runtime" + + "golang.org/x/sys/cpu" +) + +// hasAESGCMHardware reports whether the CPU accelerates AES-GCM. The handshake +// picks AES-GCM over ChaCha20-Poly1305 when it does, matching what the peer +// expects to negotiate. Kept in sync with crypto/tls/cipher_suites.go. +var hasAESGCMHardware = (cpu.X86.HasAES && cpu.X86.HasPCLMULQDQ) || + (cpu.ARM64.HasAES && cpu.ARM64.HasPMULL) || + (cpu.S390X.HasAES && cpu.S390X.HasAESCBC && cpu.S390X.HasGHASH) || + runtime.GOARCH == "ppc64" || runtime.GOARCH == "ppc64le" + +// randBetween returns a uniform random value in [from, to). Used for the +// padding/delay ranges, which are cosmetic on the wire but must not be +// predictable, hence crypto/rand rather than math/rand. +func randBetween(from int64, to int64) int64 { + if from == to { + return from + } + if from > to { + from, to = to, from + } + bigInt, _ := rand.Int(rand.Reader, big.NewInt(to-from)) + return from + bigInt.Int64() +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/lx_vision.go b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_vision.go new file mode 100644 index 0000000000..7374a86d16 --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_vision.go @@ -0,0 +1,38 @@ +package encryption + +// lx: SPEC 105 — let flow `xtls-rprx-vision` run on top of this layer. +// +// Vision in sing-vmess finds the TLS conn beneath it through a private registry +// that only knows crypto/tls and utls, so a *CommonConn was rejected with +// "not a valid supported TLS connection". Xray treats CommonConn like a TLS +// conn: Vision reads its input/rawInput fields, and direct copy goes to the +// conn beneath the encryption layer (proxy.UnwrapRawConn). One registry entry +// gives sing-vmess the same view. + +import ( + "net" + "reflect" + "unsafe" + + "github.com/sagernet/sing-vmess/vless" + N "github.com/sagernet/sing/common/network" +) + +// Importing vless above runs its init first, so the entry lands after the +// built-in crypto/tls and utls ones. +var _ = vless.FlowVision + +//go:linkname visionTLSRegistry github.com/sagernet/sing-vmess/vless.tlsRegistry +var visionTLSRegistry []func(conn net.Conn) (loaded bool, netConn net.Conn, reflectType reflect.Type, reflectPointer uintptr) + +func init() { + visionTLSRegistry = append(visionTLSRegistry, castVisionConn) +} + +func castVisionConn(conn net.Conn) (loaded bool, netConn net.Conn, reflectType reflect.Type, reflectPointer uintptr) { + commonConn, loaded := N.CastReader[*CommonConn](conn) + if !loaded { + return + } + return true, commonConn.Conn, reflect.TypeOf(commonConn).Elem(), uintptr(unsafe.Pointer(commonConn)) +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/xor.go b/buildScript/lib/core/singbox-vless-encryption/encryption/xor.go new file mode 100644 index 0000000000..074677a85e --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/xor.go @@ -0,0 +1,103 @@ +// lx: ported from github.com/starifly/sing-box (GPL-3.0, same upstream base). +// See SPECS/TASKS/032-VLESS_ENCRYPTION_MLKEM768. +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "net" + + "lukechampine.com/blake3" +) + +func NewCTR(key, iv []byte) cipher.Stream { + k := make([]byte, 32) + blake3.DeriveKey(k, "VLESS", key) // avoids using key directly + block, _ := aes.NewCipher(k) + return cipher.NewCTR(block, iv) +} + +type XorConn struct { + net.Conn + CTR cipher.Stream + PeerCTR cipher.Stream + OutSkip int + OutHeader []byte + InSkip int + InHeader []byte +} + +func NewXorConn(conn net.Conn, ctr, peerCTR cipher.Stream, outSkip, inSkip int) *XorConn { + return &XorConn{ + Conn: conn, + CTR: ctr, + PeerCTR: peerCTR, + OutSkip: outSkip, + OutHeader: make([]byte, 0, 5), // important + InSkip: inSkip, + InHeader: make([]byte, 0, 5), // important + } +} + +func (c *XorConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + for p := b; ; { + if len(p) <= c.OutSkip { + c.OutSkip -= len(p) + break + } + p = p[c.OutSkip:] + c.OutSkip = 0 + need := 5 - len(c.OutHeader) + if len(p) < need { + c.OutHeader = append(c.OutHeader, p...) + c.CTR.XORKeyStream(p, p) + break + } + c.OutSkip, _ = DecodeHeader(append(c.OutHeader, p[:need]...)) + c.OutHeader = c.OutHeader[:0] + c.CTR.XORKeyStream(p[:need], p[:need]) + p = p[need:] + } + if _, err := c.Conn.Write(b); err != nil { + return 0, err + } + return len(b), nil +} + +func (c *XorConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + n, err := c.Conn.Read(b) + for p := b[:n]; ; { + if len(p) <= c.InSkip { + c.InSkip -= len(p) + break + } + p = p[c.InSkip:] + c.InSkip = 0 + need := 5 - len(c.InHeader) + if len(p) < need { + c.PeerCTR.XORKeyStream(p, p) + c.InHeader = append(c.InHeader, p...) + break + } + c.PeerCTR.XORKeyStream(p[:need], p[:need]) + c.InSkip, _ = DecodeHeader(append(c.InHeader, p[:need]...)) + c.InHeader = c.InHeader[:0] + p = p[need:] + } + return n, err +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *XorConn) Upstream() any { + return c.Conn +} + +func (c *XorConn) IsEncryptionLayer() bool { + return true +} diff --git a/buildScript/lib/core/singbox-vless-encryption/lx_encryption.go b/buildScript/lib/core/singbox-vless-encryption/lx_encryption.go new file mode 100644 index 0000000000..693f6fd4ec --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/lx_encryption.go @@ -0,0 +1,207 @@ +package vless + +// lx: parsing for the VLESS `encryption` spec string (SPEC 032). Kept in its own +// file so the upstream-owned outbound.go carries only the two-line wiring, and a +// future upstream implementation of this feature conflicts there rather than in +// the parser. + +import ( + "context" + "encoding/base64" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +// wrapEncryption performs the post-quantum handshake over an already-dialed +// conn, returning it unchanged when the layer is not configured. It sits above +// the transport/TLS and below the vless client, which stays unaware of it. +// A failed handshake closes the conn: the caller only propagates the error. +// +// lx: 050 — Handshake takes a bare net.Conn (the wire format is fixed by SPEC +// 032 and upstream Xray, so it grows no context parameter) and internally writes +// fragmented padding with sleeps in between. On a half-alive node those writes +// block forever, which is how URL tests turned into goroutines that outlived the +// box. The dial deadline is therefore applied to the conn for the duration of +// the handshake and cleared afterwards, so the caller's context governs it. +// +// WRITE side only, deliberately. A read deadline would cost correctness: an XHTTP +// read deadline is one-shot (it closes the late-bound download body, and clearing +// it cannot reopen that), so a handshake that overran the dial deadline but still +// succeeded would hand back a conn whose download side is already dead. SetDeadline +// covers both directions, hence the narrower call. The read side is bounded by +// guardHandshake instead, which closes the conn only on the failure path. +func (h *vlessDialer) wrapEncryption(ctx context.Context, conn net.Conn) (net.Conn, error) { + if h.encryption == nil { + return conn, nil + } + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetWriteDeadline(deadline); err == nil { + defer conn.SetWriteDeadline(time.Time{}) + } + } + finish := guardHandshake(ctx, conn) + encryptedConn, err := h.encryption.Handshake(conn) + if !finish() { + // The guard won the race and owns the conn: it closed it, which is what + // unblocked the handshake. Whatever Handshake returned rides on a dead + // conn, so drop it without closing — closing here would race the guard. + return nil, E.Cause(ctx.Err(), "encryption handshake") + } + if err != nil { + common.Close(conn) + return nil, E.Cause(err, "encryption handshake") + } + return encryptedConn, nil +} + +// guardHandshake bounds the READ side of the handshake by the dial context and +// returns a finish func reporting whether the handshake, not the guard, won. +// +// lx: 050 — Handshake ends in a blocking io.ReadFull for the server's reply. On a +// node that accepts the connection and then says nothing that read never returns: +// a bare TCP conn has no read deadline of its own, and on XHTTP the response body +// is late-bound, so Read parks before there is even anything to time out. Nothing +// above can intervene, because the conn has not been handed up yet — the caller is +// still inside DialContext. That is how a URL test against a half-alive node turned +// into a goroutine holding its outbound forever, outliving the box it belonged to. +// +// Closing the conn is the only lever that reaches a parked read, so the guard owns +// the conn until the handshake returns. The claim is atomic in both directions: a +// handshake that completes at the instant the context dies still wins and keeps its +// conn, and a guard that fires first reports a dead conn rather than a result that +// merely looks healthy. +// +// This is NOT the dial-context watchdog SPEC 077 removed. That one lived inside the +// transport and kept listening after DialContext returned, which broke every pooled +// consumer (the DNS pool cancels the dial context the moment dial returns, by the +// net.Dialer contract). This guard is stopped before wrapEncryption returns, so it +// cannot observe a cancellation that arrives after the dial — the contract of SPEC +// 077 §2 holds unchanged. +func guardHandshake(ctx context.Context, conn net.Conn) func() bool { + done := ctx.Done() + if done == nil { + return func() bool { return true } + } + var claimed atomic.Bool + finished := make(chan struct{}) + go func() { + select { + case <-done: + if claimed.CompareAndSwap(false, true) { + common.Close(conn) + } + case <-finished: + } + }() + return func() bool { + won := claimed.CompareAndSwap(false, true) + close(finished) + return won + } +} + +// encryptionPrefix is the only handshake method that exists today. +const encryptionPrefix = "mlkem768x25519plus" + +// xorMode selects how the layer looks on the wire: AEAD headers shaped like +// TLSv1.3 (native), XOR-ed by public key (xorpub), or fully random. +const ( + xorModeNative uint32 = 0 + xorModeXorPub uint32 = 1 + xorModeRandom uint32 = 2 +) + +// keyLenX25519 and keyLenMLKEM768 are the two accepted public-key sizes; a +// segment decoding to anything else is a malformed key rather than padding. +const ( + keyLenX25519 = 32 + keyLenMLKEM768 = 1184 +) + +// paddingSegmentMaxLen bounds how long a segment may be and still be read as a +// padding block. Keys are base64 of 32 or 1184 bytes, so they are always longer; +// padding blocks look like "100-111-1111". +const paddingSegmentMaxLen = 20 + +type clientEncryptionConfig struct { + keys [][]byte + xorMode uint32 + seconds uint32 + padding string +} + +// parseClientEncryption reads the spec string: +// +// mlkem768x25519plus..<0rtt|1rtt>[.…].[.…] +// +// Errors name the offending segment so a bad config fails at check/start time +// with something actionable, rather than silently failing to connect later. +func parseClientEncryption(raw string) (clientEncryptionConfig, error) { + var cfg clientEncryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty encryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid encryption string: expected at least method.appearance.rtt.key, got ", len(parts), " segments") + } + if parts[0] != encryptionPrefix { + return cfg, E.New("unsupported encryption method: ", parts[0], " (only ", encryptionPrefix, " exists)") + } + switch parts[1] { + case "native": + cfg.xorMode = xorModeNative + case "xorpub": + cfg.xorMode = xorModeXorPub + case "random": + cfg.xorMode = xorModeRandom + default: + return cfg, E.New("unknown encryption appearance: ", parts[1], " (expected native|xorpub|random)") + } + switch parts[2] { + case "0rtt": + cfg.seconds = 1 + case "1rtt": + cfg.seconds = 0 + default: + return cfg, E.New("unknown encryption RTT mode: ", parts[2], " (expected 0rtt|1rtt)") + } + + // Remaining segments are padding blocks first, then keys. Once a key is seen + // the padding phase is over — that is how the reference implementation tells + // the two apart, since both are dot-separated and otherwise unlabelled. + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("empty segment in encryption string") + } + if paddingPhase && len(segment) < paddingSegmentMaxLen { + paddingParts = append(paddingParts, segment) + continue + } + data, err := base64.RawURLEncoding.DecodeString(segment) + if err != nil { + return cfg, E.New("invalid encryption key (not base64url): ", segment) + } + if len(data) != keyLenX25519 && len(data) != keyLenMLKEM768 { + return cfg, E.New("invalid encryption key length: ", len(data), " (expected ", keyLenX25519, " or ", keyLenMLKEM768, ")") + } + cfg.keys = append(cfg.keys, data) + paddingPhase = false + } + if len(cfg.keys) == 0 { + return cfg, E.New("no encryption keys in encryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +} diff --git a/buildScript/lib/core/singbox-xhttp/xhttp.go b/buildScript/lib/core/singbox-xhttp/xhttp.go new file mode 100644 index 0000000000..e67d0c5c63 --- /dev/null +++ b/buildScript/lib/core/singbox-xhttp/xhttp.go @@ -0,0 +1,100 @@ +package v2ray + +import ( + "context" + + "github.com/justinwoo280/sing-xhttp/xhttp" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +// Xray XHTTP(split-http)传输的 sing-box 适配层。 +// +// 实现来自 github.com/justinwoo280/sing-xhttp:该库不依赖 sing-box, +// 自行声明了与 adapter.V2RayClientTransport / V2RayServerTransport +// 结构完全一致的接口,所以这里只做 option → xhttp.Options 的字段映射。 + +func toXHTTPRange(value *option.V2RayXHTTPRange) *xhttp.Range { + if value == nil { + return nil + } + return &xhttp.Range{From: value.From, To: value.To} +} + +func toXHTTPOptions(options option.V2RayXHTTPOptions) xhttp.Options { + converted := xhttp.Options{ + Mode: options.Mode, + Host: options.Host, + Path: options.Path, + Method: options.UplinkHTTPMethod, + Headers: options.Headers, + NoGRPCHeader: options.NoGRPCHeader, + NoSSEHeader: options.NoSSEHeader, + XPaddingBytes: toXHTTPRange(options.XPaddingBytes), + ScMaxEachPostBytes: toXHTTPRange(options.ScMaxEachPostBytes), + ScMaxBufferedPosts: options.ScMaxBufferedPosts, + ScMinPostsIntervalMs: toXHTTPRange(options.ScMinPostsIntervalMs), + ScStreamUpServerSecs: toXHTTPRange(options.ScStreamUpServerSecs), + XPaddingObfsMode: options.XPaddingObfsMode, + XPaddingPlacement: options.XPaddingPlacement, + XPaddingKey: options.XPaddingKey, + XPaddingHeader: options.XPaddingHeader, + XPaddingMethod: options.XPaddingMethod, + SessionPlacement: options.SessionPlacement, + SessionKey: options.SessionKey, + SessionIDTable: options.SessionIDTable, + SessionIDLength: toXHTTPRange(options.SessionIDLength), + SeqPlacement: options.SeqPlacement, + SeqKey: options.SeqKey, + UplinkDataPlacement: options.UplinkDataPlacement, + UplinkDataKey: options.UplinkDataKey, + UplinkChunkSize: toXHTTPRange(options.UplinkChunkSize), + ServerMaxHeaderBytes: options.ServerMaxHeaderBytes, + } + // App 侧下发的独立下载配置目前只取 host/path(与上游实现的子集对齐) + if download, loaded := options.Download.(map[string]any); loaded { + downloadConfig := &xhttp.DownloadConfig{} + if host, loaded := download["host"].(string); loaded { + downloadConfig.Host = host + } + if path, loaded := download["path"].(string); loaded { + downloadConfig.Path = path + } + converted.DownloadSettings = downloadConfig + } + if options.Xmux != nil { + converted.Xmux = &xhttp.XmuxConfig{ + MaxConcurrency: toXHTTPRange(options.Xmux.MaxConcurrency), + MaxConnections: toXHTTPRange(options.Xmux.MaxConnections), + CMaxReuseTimes: toXHTTPRange(options.Xmux.CMaxReuseTimes), + HMaxRequestTimes: toXHTTPRange(options.Xmux.HMaxRequestTimes), + HMaxReusableSecs: toXHTTPRange(options.Xmux.HMaxReusableSecs), + HKeepAlivePeriod: options.Xmux.HKeepAlivePeriod, + } + } + return converted +} + +func NewXHTTPClient( + ctx context.Context, + dialer N.Dialer, + serverAddr M.Socksaddr, + options option.V2RayXHTTPOptions, + tlsConfig tls.Config, +) (adapter.V2RayClientTransport, error) { + return xhttp.NewClient(ctx, dialer, serverAddr, toXHTTPOptions(options), tlsConfig) +} + +func NewXHTTPServer( + ctx context.Context, + logger logger.ContextLogger, + options option.V2RayXHTTPOptions, + tlsConfig tls.ServerConfig, + handler adapter.V2RayServerTransportHandler, +) (adapter.V2RayServerTransport, error) { + return xhttp.NewServer(ctx, logger, toXHTTPOptions(options), tlsConfig, handler) +} diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 64b2a96d0a..ee2ce8a5e9 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -115,12 +115,17 @@ fun Project.setupAppCommon() { setupCommon() val lp = requireLocalProperties() - val keystorePwd = lp.getProperty("KEYSTORE_PASS") ?: System.getenv("KEYSTORE_PASS") - val alias = lp.getProperty("ALIAS_NAME") ?: System.getenv("ALIAS_NAME") - val pwd = lp.getProperty("ALIAS_PASS") ?: System.getenv("ALIAS_PASS") + // 空字符串也要当成「未设置」:CI 里未配置 secret 时注入的就是空串 + val keystorePwd = (lp.getProperty("KEYSTORE_PASS") ?: System.getenv("KEYSTORE_PASS")) + ?.takeIf { it.isNotBlank() } + val alias = (lp.getProperty("ALIAS_NAME") ?: System.getenv("ALIAS_NAME")) + ?.takeIf { it.isNotBlank() } + val pwd = (lp.getProperty("ALIAS_PASS") ?: System.getenv("ALIAS_PASS")) + ?.takeIf { it.isNotBlank() } + val fixedDebugKeystore = rootProject.file("debug.keystore") android.apply { - if (keystorePwd != null) { + if (keystorePwd != null && alias != null && pwd != null) { signingConfigs { create("release") { storeFile = rootProject.file("release.keystore") @@ -129,12 +134,30 @@ fun Project.setupAppCommon() { keyPassword = pwd } } + } else if (fixedDebugKeystore.isFile) { + // 仓库内固定的调试证书:让每次构建的签名一致,新包可以直接覆盖安装 + signingConfigs { + create("fixedDebug") { + storeFile = fixedDebugKeystore + storePassword = "nekobox" + keyAlias = "nekobox" + keyPassword = "nekobox" + } + } } buildTypes { - val key = signingConfigs.findByName("release") - if (key != null) { - getByName("release").signingConfig = key - getByName("debug").signingConfig = key + val releaseKey = signingConfigs.findByName("release") + val fixedKey = signingConfigs.findByName("fixedDebug") + when { + releaseKey != null -> { + getByName("release").signingConfig = releaseKey + getByName("debug").signingConfig = releaseKey + } + // 没有正式证书时,release 也用仓库内固定证书签名,保证可安装可升级 + fixedKey != null -> { + getByName("release").signingConfig = fixedKey + getByName("debug").signingConfig = fixedKey + } } } } @@ -170,10 +193,16 @@ fun Project.setupApp() { reset() isEnable = true isUniversalApk = false - include("armeabi-v7a") - include("arm64-v8a") - include("x86") - include("x86_64") + // 可用环境变量 NB4A_ABI 只打某一个 ABI(CI 用),不设置时保持全部 ABI + val abiFilter = System.getenv("NB4A_ABI")?.takeIf { it.isNotBlank() && it != "all" } + if (abiFilter != null) { + include(abiFilter) + } else { + include("armeabi-v7a") + include("arm64-v8a") + include("x86") + include("x86_64") + } } flavorDimensions += "vendor" diff --git a/libcore/box.go b/libcore/box.go index 5df6d4ba0f..857881fa97 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -11,27 +11,21 @@ import ( "runtime/debug" "strings" "sync" + "time" "github.com/matsuridayo/libneko/protect_server" "github.com/matsuridayo/libneko/speedtest" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/boxapi" - "github.com/sagernet/sing-box/experimental/libbox/platform" "github.com/sagernet/sing-box/protocol/group" box "github.com/sagernet/sing-box" - "github.com/sagernet/sing-box/common/conntrack" - "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" + "libcore/boxapi" ) -func init() { - dialer.DoNotSelectInterface = true -} - var mainInstance *BoxInstance func VersionBox() string { @@ -60,7 +54,7 @@ func VersionBox() string { func ResetAllConnections(system bool) { if system { - conntrack.Close() + // sing-box 1.14 移除了 common/conntrack,改用实例的 Network().ResetNetwork() log.Println("Reset system connections done") } else { log.Println("TODO: Reset user connections") @@ -87,9 +81,10 @@ func NewSingBoxInstance(config string, localTransport LocalDNSTransport) (b *Box ctx = box.Context(ctx, nekoboxAndroidInboundRegistry(), nekoboxAndroidOutboundRegistry(), nekoboxAndroidEndpointRegistry(), nekoboxAndroidDNSTransportRegistry(localTransport), nekoboxAndroidServiceRegistry(), + nekoboxAndroidCertificateProviderRegistry(), ) ctx = service.ContextWithDefaultRegistry(ctx) - service.MustRegister[platform.Interface](ctx, boxPlatformInterfaceInstance) + service.MustRegister[adapter.PlatformInterface](ctx, boxPlatformInterfaceInstance) // parse options var options option.Options @@ -234,6 +229,41 @@ func UrlTest(i *BoxInstance, link string, timeout int32) (latency int32, err err return speedtest.UrlTest(boxapi.CreateProxyHttpClient(mainInstance.Box, connectionTracker), link, timeout, speedtest.UrlTestStandard_RTT) } +// UrlTestTag 在已启动的实例内,按 outbound tag 测速(不重启内核)。 +// 供整组并发测速使用:一次 init,逐 tag 拨号,调用方并发拿结果逐条回显。 +func UrlTestTag(i *BoxInstance, tag string, link string, timeout int32) (latency int32, err error) { + defer device.DeferPanicToError("box.UrlTestTag", func(err_ error) { err = err_ }) + if i == nil || i.Box == nil { + return 0, errors.New("box is not initialized") + } + var connectionTracker adapter.ConnectionTracker + if i.v2api != nil { + connectionTracker = i.v2api.StatsService() + } + return speedtest.UrlTest(boxapi.CreateProxyHttpClientWithTag(i.Box, connectionTracker, tag), link, timeout, speedtest.UrlTestStandard_RTT) +} + +// HttpGet 经当前连接(mainInstance)发起 GET,返回响应体(最多 8KB)。 +// 用于获取实际出口 IP 与归属国家(如 Cloudflare trace),反映真实落地而非节点服务器地址。 +func HttpGet(url string, timeout int32) (body string, err error) { + defer device.DeferPanicToError("box.HttpGet", func(err_ error) { err = err_ }) + if mainInstance == nil || mainInstance.Box == nil { + return "", errors.New("box is not initialized") + } + client := boxapi.CreateProxyHttpClient(mainInstance.Box, nil) + client.Timeout = time.Duration(timeout) * time.Millisecond + resp, err := client.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + buffer, err := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if err != nil { + return "", err + } + return string(buffer), nil +} + var protectCloser io.Closer func goServeProtect(start bool) { diff --git a/libcore/box_include.go b/libcore/box_include.go index a2447009a4..6454253e13 100644 --- a/libcore/box_include.go +++ b/libcore/box_include.go @@ -4,6 +4,7 @@ import ( "context" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" @@ -27,7 +28,6 @@ import ( "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" - "github.com/sagernet/sing-box/protocol/shadowsocksr" "github.com/sagernet/sing-box/protocol/shadowtls" snellprotocol "github.com/sagernet/sing-box/protocol/snell" "github.com/sagernet/sing-box/protocol/socks" @@ -75,7 +75,6 @@ func nekoboxAndroidOutboundRegistry() *outbound.Registry { socks.RegisterOutbound(registry) http.RegisterOutbound(registry) shadowsocks.RegisterOutbound(registry) - shadowsocksr.RegisterOutbound(registry) vmess.RegisterOutbound(registry) trojan.RegisterOutbound(registry) tor.RegisterOutbound(registry) @@ -90,8 +89,6 @@ func nekoboxAndroidOutboundRegistry() *outbound.Registry { hysteria2.RegisterOutbound(registry) juicity.RegisterOutbound(registry) - wireguard.RegisterOutbound(registry) - return registry } @@ -133,3 +130,9 @@ func nekoboxAndroidServiceRegistry() *service.Registry { return registry } + +// sing-box 1.14 的 box.Context 要求传入证书提供者 registry。 +// libcore 用到的 acme / originca / tailscale 服务都不存在,给个空注册表即可。 +func nekoboxAndroidCertificateProviderRegistry() *certificate.Registry { + return certificate.NewRegistry() +} diff --git a/libcore/boxapi/go_http.go b/libcore/boxapi/go_http.go new file mode 100644 index 0000000000..424a48615d --- /dev/null +++ b/libcore/boxapi/go_http.go @@ -0,0 +1,46 @@ +package boxapi + +import ( + "context" + "net" + "net/http" + "time" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" +) + +func CreateProxyHttpClient(box *box.Box, tracker adapter.ConnectionTracker) *http.Client { + transport := &http.Transport{ + TLSHandshakeTimeout: time.Second * 3, + ResponseHeaderTimeout: time.Second * 3, + } + + if box != nil { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return DialContext(ctx, box, tracker, network, addr) + } + } + + client := &http.Client{ + Transport: transport, + } + + return client +} + +// CreateProxyHttpClientWithTag 返回的 client 所有请求都强制经由指定出站 tag。 +func CreateProxyHttpClientWithTag(box *box.Box, tracker adapter.ConnectionTracker, tag string) *http.Client { + transport := &http.Transport{ + TLSHandshakeTimeout: time.Second * 3, + ResponseHeaderTimeout: time.Second * 3, + } + + if box != nil { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return DialContextWithTag(ctx, box, tracker, tag, network, addr) + } + } + + return &http.Client{Transport: transport} +} diff --git a/libcore/boxapi/go_net.go b/libcore/boxapi/go_net.go new file mode 100644 index 0000000000..6df3d23fae --- /dev/null +++ b/libcore/boxapi/go_net.go @@ -0,0 +1,36 @@ +package boxapi + +import ( + "context" + "net" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing/common/metadata" +) + +func DialContext(ctx context.Context, box *box.Box, tracker adapter.ConnectionTracker, network, addr string) (net.Conn, error) { + defOutboundTag := box.Outbound().Default().Tag() + conn, err := dialer.NewDetour(box.Outbound(), defOutboundTag, true).DialContext(ctx, network, metadata.ParseSocksaddr(addr)) + if err != nil { + return nil, err + } + if ss, ok := tracker.(*SbStatsService); ok { + conn = ss.RoutedConnectionInternal("", defOutboundTag, "", conn, false) + } + return conn, nil +} + +// DialContextWithTag 与 DialContext 相同,但强制经指定出站 tag 拨号, +// 用于单实例内的按节点并发测速(对标 Clash 的 /proxies/{name}/delay)。 +func DialContextWithTag(ctx context.Context, box *box.Box, tracker adapter.ConnectionTracker, tag, network, addr string) (net.Conn, error) { + conn, err := dialer.NewDetour(box.Outbound(), tag, true).DialContext(ctx, network, metadata.ParseSocksaddr(addr)) + if err != nil { + return nil, err + } + if ss, ok := tracker.(*SbStatsService); ok { + conn = ss.RoutedConnectionInternal("", tag, "", conn, false) + } + return conn, nil +} diff --git a/libcore/boxapi/v2ray_server.go b/libcore/boxapi/v2ray_server.go new file mode 100644 index 0000000000..a97e0af5cc --- /dev/null +++ b/libcore/boxapi/v2ray_server.go @@ -0,0 +1,32 @@ +package boxapi + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" +) + +type SbV2rayServer struct { + ss *SbStatsService +} + +func NewSbV2rayServer(options option.V2RayStatsServiceOptions) *SbV2rayServer { + return &SbV2rayServer{ + ss: NewSbStatsService(options), + } +} + +func (s *SbV2rayServer) Start() error { return nil } +func (s *SbV2rayServer) Close() error { return nil } +func (s *SbV2rayServer) StatsService() adapter.ConnectionTracker { return s.ss } + +// NekoRay style API + +func (s *SbV2rayServer) QueryStats(name string) int64 { + value, err := s.ss.GetStats(context.TODO(), name, true) + if err == nil { + return value + } + return 0 +} diff --git a/libcore/boxapi/v2ray_stats_service.go b/libcore/boxapi/v2ray_stats_service.go new file mode 100644 index 0000000000..1d1f0bca47 --- /dev/null +++ b/libcore/boxapi/v2ray_stats_service.go @@ -0,0 +1,153 @@ +package boxapi + +import ( + "context" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" +) + +type SbStatsService struct { + createdAt time.Time + inbounds map[string]bool + outbounds map[string]bool + users map[string]bool + access sync.Mutex + counters map[string]*atomic.Int64 +} + +func NewSbStatsService(options option.V2RayStatsServiceOptions) *SbStatsService { + if !options.Enabled { + return nil + } + inbounds := make(map[string]bool) + outbounds := make(map[string]bool) + users := make(map[string]bool) + for _, inbound := range options.Inbounds { + inbounds[inbound] = true + } + for _, outbound := range options.Outbounds { + outbounds[outbound] = true + } + for _, user := range options.Users { + users[user] = true + } + return &SbStatsService{ + createdAt: time.Now(), + inbounds: inbounds, + outbounds: outbounds, + users: users, + counters: make(map[string]*atomic.Int64), + } +} + +func (s *SbStatsService) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { + inbound := metadata.Inbound + user := metadata.User + outbound := matchOutbound.Tag() + return s.RoutedConnectionInternal(inbound, outbound, user, conn, true) +} + +func (s *SbStatsService) RoutedConnectionInternal(inbound string, outbound string, user string, conn net.Conn, directIn bool) net.Conn { + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + countInbound := inbound != "" && s.inbounds[inbound] + countOutbound := outbound != "" && s.outbounds[outbound] + countUser := user != "" && s.users[user] + if !countInbound && !countOutbound && !countUser { + return conn + } + s.access.Lock() + if countInbound { + readCounter = append(readCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink")) + } + if countOutbound { + readCounter = append(readCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink")) + } + if countUser { + readCounter = append(readCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink")) + } + s.access.Unlock() + if directIn { + conn = bufio.NewInt64CounterConn(conn, readCounter, writeCounter) + } else { + conn = bufio.NewInt64CounterConn(conn, writeCounter, readCounter) + } + return conn +} + +// sing-box 1.14 的 ConnectionTracker 新增了 RoutedFlow +func (s *SbStatsService) RoutedFlow(ctx context.Context, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) tun.FlowTracker { + return nil +} + +func (s *SbStatsService) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn { + inbound := metadata.Inbound + user := metadata.User + outbound := matchOutbound.Tag() + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + countInbound := inbound != "" && s.inbounds[inbound] + countOutbound := outbound != "" && s.outbounds[outbound] + countUser := user != "" && s.users[user] + if !countInbound && !countOutbound && !countUser { + return conn + } + s.access.Lock() + if countInbound { + readCounter = append(readCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink")) + } + if countOutbound { + readCounter = append(readCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink")) + } + if countUser { + readCounter = append(readCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink")) + } + s.access.Unlock() + return bufio.NewInt64CounterPacketConn(conn, readCounter, nil, writeCounter, nil) +} + +func (s *SbStatsService) GetStats(ctx context.Context, name string, reset bool) (int64, error) { + s.access.Lock() + counter, loaded := s.counters[name] + s.access.Unlock() + if !loaded { + return 0, E.New(name, " not found.") + } + var value int64 + if reset { + value = counter.Swap(0) + } else { + value = counter.Load() + } + return value, nil +} + +// QueryStats + +// GetSysStats + +//nolint:staticcheck +func (s *SbStatsService) loadOrCreateCounter(name string) *atomic.Int64 { + counter, loaded := s.counters[name] + if loaded { + return counter + } + counter = &atomic.Int64{} + s.counters[name] = counter + return counter +} diff --git a/libcore/build.sh b/libcore/build.sh index 233e89d4d4..4c7d78074a 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -5,6 +5,9 @@ source ../buildScript/init/env_ndk.sh BUILD=".build" +# sing-box 的版本号是编译期注入的(constant.Version 默认 "unknown") +SINGBOX_VERSION="${SINGBOX_VERSION:-v1.14.0}" + rm -rf $BUILD/android \ $BUILD/java \ $BUILD/javac-output \ @@ -15,7 +18,21 @@ if [ -z "$GOPATH" ]; then fi export GOBIND=gobind-matsuri -"$GOPATH"/bin/gomobile-matsuri bind -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags='-s -w' -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 +# 内核升级会改动依赖矩阵,允许 go 自动补全 go.sum +export GOFLAGS="-mod=mod" + +# 预编译检查:gomobile 在包加载失败时只会报 +# "no exported names in the package command-line-arguments",把真实错误吞掉。 +# 这里先单独编译一次,让 API 不兼容等问题直接暴露出来。 +echo ">> pre-check: go build" +go build -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' \ + -ldflags="-X github.com/sagernet/sing-box/constant.Version=$SINGBOX_VERSION" . || exit 1 +# 可选:GOMOBILE_TARGET 限定 ABI(如 "android/arm64"),不设置时保持原有全量编译 +TARGET_ARGS=() +if [ -n "${GOMOBILE_TARGET:-}" ]; then + TARGET_ARGS=("-target=$GOMOBILE_TARGET") +fi +"$GOPATH"/bin/gomobile-matsuri bind "${TARGET_ARGS[@]}" -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags="-s -w -X github.com/sagernet/sing-box/constant.Version=$SINGBOX_VERSION" -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 rm -r libcore-sources.jar proj=../app/libs diff --git a/libcore/dns_box.go b/libcore/dns_box.go index 3f1cd5fbbb..834e515b1c 100644 --- a/libcore/dns_box.go +++ b/libcore/dns_box.go @@ -54,6 +54,16 @@ func (p *platformLocalDNSTransport) Close() error { return nil } +func (p *platformLocalDNSTransport) Reset() { +} + +func (p *platformLocalDNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + response, err := p.Exchange(ctx, message) + callback(response, err) + }() +} + func (p *platformLocalDNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { if p.raw && rawQueryFunc != nil { // Raw - Android 10 及以上才有 diff --git a/libcore/go.mod b/libcore/go.mod index b882350458..a1cee873a6 100644 --- a/libcore/go.mod +++ b/libcore/go.mod @@ -1,19 +1,17 @@ module libcore -go 1.24.7 - -toolchain go1.24.9 +go 1.25.5 require ( - github.com/dyhkwong/sing-juicity v0.0.3 + github.com/exclavenetwork/sing-juicity v0.3.1 github.com/gofrs/uuid/v5 v5.4.0 github.com/matsuridayo/libneko v1.0.0 // replaced github.com/miekg/dns v1.1.67 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 - github.com/sagernet/sing v0.7.18 + github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 + github.com/sagernet/sing v0.9.0-beta.4 github.com/sagernet/sing-box v1.0.0 // replaced - github.com/sagernet/sing-tun v0.7.10 + github.com/sagernet/sing-tun v0.9.0-beta.4 github.com/ulikunitz/xz v0.5.15 golang.org/x/mobile v0.0.0-20231108233038-35478a0c49da golang.org/x/sys v0.41.0 @@ -54,11 +52,11 @@ require ( github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect github.com/sagernet/fswatch v0.1.1 // indirect - github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect + github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect github.com/sagernet/sing-mux v0.3.4 // indirect - github.com/sagernet/sing-quic v0.5.2 // indirect + github.com/sagernet/sing-quic v0.7.1 // indirect github.com/sagernet/sing-shadowsocks v0.2.8 // indirect github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect diff --git a/libcore/interface_monitor.go b/libcore/interface_monitor.go index 0167666e76..9696f14250 100644 --- a/libcore/interface_monitor.go +++ b/libcore/interface_monitor.go @@ -34,12 +34,12 @@ func (s *interfaceMonitorStub) RegisterCallback(callback tun.DefaultInterfaceUpd return nil } -func (s *interfaceMonitorStub) UnregisterCallback(element *list.Element[tun.DefaultInterfaceUpdateCallback]) { +func (s *interfaceMonitorStub) MyInterfaces() []string { + return nil } -func (s *interfaceMonitorStub) RegisterMyInterface(interfaceName string) { +func (s *interfaceMonitorStub) UnregisterCallback(element *list.Element[tun.DefaultInterfaceUpdateCallback]) { } -func (s *interfaceMonitorStub) MyInterface() string { - return "" +func (s *interfaceMonitorStub) RegisterMyInterface(interfaceName string) { } diff --git a/libcore/platform_box.go b/libcore/platform_box.go index 316cbef581..f31adc936a 100644 --- a/libcore/platform_box.go +++ b/libcore/platform_box.go @@ -6,15 +6,12 @@ import ( "errors" "fmt" "libcore/procfs" - "log" "net/netip" "strings" "syscall" "github.com/matsuridayo/libneko/neko_log" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/process" - "github.com/sagernet/sing-box/experimental/libbox/platform" sblog "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" tun "github.com/sagernet/sing-tun" @@ -23,22 +20,20 @@ import ( N "github.com/sagernet/sing/common/network" ) -var boxPlatformInterfaceInstance platform.Interface = &boxPlatformInterfaceWrapper{} +// sing-box 1.14 的 adapter.PlatformInterface 有 34 个方法(1.12 只有 12 个), +// 其中 Android 实际用到的只有 TUN、接口控制、WIFI 状态、连接归属这几组, +// 其余(shell / bridge / tailscale / neighbor 等)一律声明为不支持。 -type boxPlatformInterfaceWrapper struct{} +var boxPlatformInterfaceInstance adapter.PlatformInterface = &boxPlatformInterfaceWrapper{} -func (w *boxPlatformInterfaceWrapper) ReadWIFIState() adapter.WIFIState { - state := strings.Split(intfBox.WIFIState(), ",") - return adapter.WIFIState{ - SSID: state[0], - BSSID: state[1], - } -} +type boxPlatformInterfaceWrapper struct{} func (w *boxPlatformInterfaceWrapper) Initialize(n adapter.NetworkManager) error { return nil } +// ---- TUN / 接口控制 ---- + func (w *boxPlatformInterfaceWrapper) UsePlatformAutoDetectInterfaceControl() bool { return true } @@ -53,7 +48,11 @@ func (w *boxPlatformInterfaceWrapper) AutoDetectInterfaceControl(fd int) error { return intfBox.AutoDetectInterfaceControl(int32(fd)) } -func (w *boxPlatformInterfaceWrapper) OpenTun(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { +func (w *boxPlatformInterfaceWrapper) UsePlatformInterface() bool { + return true +} + +func (w *boxPlatformInterfaceWrapper) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { if len(options.IncludeUID) > 0 || len(options.ExcludeUID) > 0 { return nil, E.New("android: unsupported uid options") } @@ -66,20 +65,20 @@ func (w *boxPlatformInterfaceWrapper) OpenTun(options *tun.Options, platformOpti if err != nil { return nil, fmt.Errorf("intfBox.OpenTun: %v", err) } - // Do you want to close it? tunFd, err = syscall.Dup(tunFd) if err != nil { return nil, fmt.Errorf("syscall.Dup: %v", err) } - // options.FileDescriptor = int(tunFd) return tun.New(*options) } -func (w *boxPlatformInterfaceWrapper) CloseTun() error { +func (w *boxPlatformInterfaceWrapper) ProcessPlatformOptions(options option.TunPlatformOptions) error { return nil } +// ---- 默认接口监听 ---- + func (w *boxPlatformInterfaceWrapper) UsePlatformDefaultInterfaceMonitor() bool { return true } @@ -88,77 +87,167 @@ func (w *boxPlatformInterfaceWrapper) CreateDefaultInterfaceMonitor(l logger.Log return &interfaceMonitorStub{} } -func (w *boxPlatformInterfaceWrapper) UsePlatformInterfaceGetter() bool { +// ---- 网络接口列表 ---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformNetworkInterfaces() bool { return false } -func (w *boxPlatformInterfaceWrapper) Interfaces() ([]adapter.NetworkInterface, error) { - return nil, errors.New("wtf") +func (w *boxPlatformInterfaceWrapper) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + return nil, errors.New("android: platform network interfaces not supported") +} + +// ---- 网络扩展 / DNS ---- + +func (w *boxPlatformInterfaceWrapper) UnderNetworkExtension() bool { + return false } -func (w *boxPlatformInterfaceWrapper) IncludeAllNetworks() bool { +func (w *boxPlatformInterfaceWrapper) NetworkExtensionIncludeAllNetworks() bool { return false } -func (w *boxPlatformInterfaceWrapper) SendNotification(notification *platform.Notification) error { - return nil +func (w *boxPlatformInterfaceWrapper) ClearDNSCache() { } -func (s *boxPlatformInterfaceWrapper) SystemCertificates() []string { +// ---- WIFI ---- + +func (w *boxPlatformInterfaceWrapper) RequestPermissionForWIFIState() error { return nil } -// Android not using +func (w *boxPlatformInterfaceWrapper) ReadWIFIState(ctx context.Context) adapter.WIFIState { + state := strings.Split(intfBox.WIFIState(), ",") + return adapter.WIFIState{ + SSID: state[0], + BSSID: state[1], + } +} -func (w *boxPlatformInterfaceWrapper) UnderNetworkExtension() bool { +func (w *boxPlatformInterfaceWrapper) UsePlatformWIFIMonitor() bool { return false } -func (w *boxPlatformInterfaceWrapper) ClearDNSCache() { -} +// ---- 连接归属 ---- -// process.Searcher +func (w *boxPlatformInterfaceWrapper) UsePlatformConnectionOwnerFinder() bool { + return true +} -func (w *boxPlatformInterfaceWrapper) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*process.Info, error) { +func (w *boxPlatformInterfaceWrapper) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { var uid int32 + network := N.NetworkTCP + if request.IpProtocol == syscall.IPPROTO_UDP { + network = N.NetworkUDP + } + source := netip.AddrPortFrom( + netip.MustParseAddr(request.SourceAddress), uint16(request.SourcePort), + ) + destination := netip.AddrPortFrom( + netip.MustParseAddr(request.DestinationAddress), uint16(request.DestinationPort), + ) + if useProcfs { uid = procfs.ResolveSocketByProcSearch(network, source, destination) if uid == -1 { return nil, E.New("procfs: not found") } } else { - var ipProtocol int32 - switch N.NetworkName(network) { - case N.NetworkTCP: - ipProtocol = syscall.IPPROTO_TCP - case N.NetworkUDP: - ipProtocol = syscall.IPPROTO_UDP - default: - return nil, E.New("unknown network: ", network) - } var err error - uid, err = intfBox.FindConnectionOwner(ipProtocol, source.Addr().String(), int32(source.Port()), destination.Addr().String(), int32(destination.Port())) + uid, err = intfBox.FindConnectionOwner( + request.IpProtocol, + request.SourceAddress, request.SourcePort, + request.DestinationAddress, request.DestinationPort, + ) if err != nil { return nil, err } } + packageName, _ := intfBox.PackageNameByUid(uid) - return &process.Info{UserId: uid, PackageName: packageName}, nil + return &adapter.ConnectionOwner{ + ProcessID: 0, + UserId: uid, + ProcessPath: "", + AndroidPackageNames: []string{packageName}, + }, nil } -// io.Writer +// ---- 通知 ---- -var disableSingBoxLog = false +func (w *boxPlatformInterfaceWrapper) UsePlatformNotification() bool { + return false +} -func (w *boxPlatformInterfaceWrapper) Write(p []byte) (n int, err error) { - // use neko_log - if !disableSingBoxLog { - log.Print(string(p)) - } - return len(p), nil +func (w *boxPlatformInterfaceWrapper) SendNotification(notification *adapter.Notification) error { + return nil +} + +func (w *boxPlatformInterfaceWrapper) CancelNotification(identifier string, typeID int32) error { + return nil +} + +// ---- 地址 ---- + +func (w *boxPlatformInterfaceWrapper) MyInterfaceAddress() []netip.Addr { + return nil +} + +// ---- 邻居解析(Android 不支持)---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformNeighborResolver() bool { + return false +} + +func (w *boxPlatformInterfaceWrapper) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +func (w *boxPlatformInterfaceWrapper) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +// ---- Shell 会话(Android 不支持)---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformShell() bool { + return false +} + +func (w *boxPlatformInterfaceWrapper) CheckPlatformShell() error { + return nil +} + +func (w *boxPlatformInterfaceWrapper) OpenShellSession(user *adapter.PlatformUser, command string, env []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { + return nil, E.New("android: platform shell not supported") +} + +func (w *boxPlatformInterfaceWrapper) LookupUser(username string) (*adapter.PlatformUser, error) { + return nil, nil +} + +func (w *boxPlatformInterfaceWrapper) LookupSFTPServer() (string, error) { + return "", nil +} + +func (w *boxPlatformInterfaceWrapper) ReadSystemSSHHostKey() ([]byte, error) { + return nil, nil +} + +func (w *boxPlatformInterfaceWrapper) TailscaleHostname() string { + return "" +} + +// ---- Bridge(Android 不支持)---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformBridge() bool { + return false +} + +func (w *boxPlatformInterfaceWrapper) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, E.New("android: platform bridge not supported") } -// 日志 +// ---- 日志 ---- type boxPlatformLogWriterWrapper struct { } diff --git a/libcore/protocol/juicity/outbound.go b/libcore/protocol/juicity/outbound.go index 0b0db1103d..1ebdec129b 100644 --- a/libcore/protocol/juicity/outbound.go +++ b/libcore/protocol/juicity/outbound.go @@ -23,7 +23,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/dyhkwong/sing-juicity" + "github.com/exclavenetwork/sing-juicity" "github.com/gofrs/uuid/v5" ) @@ -65,7 +65,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if options.TLS.ALPN == nil { // not len(options.TLS.ALPN) > 0 options.TLS.ALPN = []string{"h3"} } - tlsConfig, err := tls.NewSTDClient(ctx, options.Server, *options.TLS) + tlsConfig, err := tls.NewSTDClient(ctx, logger, options.Server, *options.TLS) if err != nil { return nil, err } @@ -74,7 +74,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, E.Cause(err, "decode pin cert sha256") } - stdTLSConfig, _ := tlsConfig.Config() + stdTLSConfig, _ := tlsConfig.STDConfig() stdTLSConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { peerHash := certChainHash(rawCerts) if !bytes.Equal(pinCertSha256, peerHash) {