From 522b3e2f18d2697dd8a15bcce6e5e40f7e5e0d7c Mon Sep 17 00:00:00 2001 From: theremila Date: Mon, 26 Oct 2026 09:42:37 +0200 Subject: [PATCH 1/2] Add WARP entry and exit modes with AmneziaWG support Support WARP before, after, or on both sides of the selected connection, with independent entry and exit identities and WARP bypass routing. Co-authored-by: Codex --- README.md | 2 +- .../sagernet/database/DataStore.kt | 2 + .../sagernet/database/SettingsMapper.kt | 2 + .../sagernet/database/SettingsRegistry.kt | 4 +- .../sagernet/outbound/config/ChainBuilder.kt | 21 +- .../outbound/config/ConfigGenerator.kt | 43 ++-- .../outbound/config/GeneratedConfig.kt | 2 +- .../outbound/config/GeneratorSettings.kt | 4 +- .../outbound/config/WarpCamouflage.kt | 34 +++ .../sagernet/outbound/config/WarpHop.kt | 56 ++++- .../ui/settings/WarpSettingsScreen.kt | 17 +- .../sagernet/ui/warp/WarpClient.kt | 28 ++- .../sagernet/ui/warp/WarpGenerate.kt | 29 ++- app/src/main/res/raw/warp_awg_gen_license.txt | 24 +++ app/src/main/res/values-ru/strings.xml | 15 +- app/src/main/res/values/arrays.xml | 12 ++ app/src/main/res/values/strings.xml | 15 +- app/src/main/res/xml/settings_warp.xml | 9 + .../sagernet/outbound/config/WarpModesTest.kt | 197 ++++++++++++++++++ 19 files changed, 466 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpCamouflage.kt create mode 100644 app/src/main/res/raw/warp_awg_gen_license.txt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/outbound/config/WarpModesTest.kt diff --git a/README.md b/README.md index 944873de..d6446800 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Releases from v2.0.0 on are signed with a new key, so Android refuses to install - Backups in the desktop's `.thrbackup` format, restorable on either side: save, share or sync over WebDAV - Route profiles shared with desktop, preloaded from [routeprofiles](https://github.com/throneproj/routeprofiles), plus Android-only rule conditions: apps, Wi-Fi network (SSID / BSSID), network type and metered networks - Auto Selector profiles that keep the best server of one or more groups ranked and switch automatically -- Cloudflare WARP: registration (WireGuard or MASQUE identity) and a built-in WARP mode for routing +- Cloudflare WARP: WireGuard, AmneziaWG or MASQUE at entry, exit, or both ends of the selected connection - URL, IP / country and speed tests, with a bulk-test panel (live progress, latency histogram, fastest servers, sort / remove unavailable / connect to fastest) - The sing-box dashboard built in (live traffic, connections, logs) - JSON editor with sing-box schema checking, completion and formatting diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index dd5a664b..41f12f4e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -277,6 +277,8 @@ object DataStore : OnPreferenceDataStoreChangeListener { // warp var enableWarp by SettingsRegistry.ENABLE_WARP var warpMode by SettingsRegistry.WARP_MODE + var warpChainOrder by SettingsRegistry.WARP_CHAIN_ORDER + var warpEntryConfig by SettingsRegistry.WARP_ENTRY_CONFIG var warpEp by SettingsRegistry.WARP_EP var warpPrivateKey by SettingsRegistry.WARP_PRIVATE_KEY var warpPublicKey by SettingsRegistry.WARP_PUBLIC_KEY diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt index a97cf9c7..08d7bc4d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt @@ -120,6 +120,8 @@ object SettingsMapper { directTestUrl = DataStore.directTestUrl, enableWarp = DataStore.enableWarp, warpMode = DataStore.warpMode, + warpChainOrder = DataStore.warpChainOrder, + warpEntryConfig = DataStore.warpEntryConfig, warpEp = DataStore.warpEp, warpPrivateKey = DataStore.warpPrivateKey, warpPublicKey = DataStore.warpPublicKey, diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt index 1923dbb3..ac249ca1 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt @@ -125,7 +125,7 @@ object SettingsRegistry { val NTP_OUTBOUNDS = listOf("direct", "proxy") @JvmField - val WARP_MODES = listOf("wireguard", "masque") + val WARP_MODES = listOf("wireguard", "amnezia", "masque") /** defaultTunPrivateRanges (SettingsRepo.h:18-21). */ @JvmField @@ -303,6 +303,8 @@ object SettingsRegistry { // ------------------------------------------------------------------------------------------------ warp @JvmField val ENABLE_WARP = bool("enable_warp", false) + @JvmField val WARP_CHAIN_ORDER = oneOf("warp_chain_order", "proxy-first", listOf("proxy-first", "warp-first", "warp-both")) + @JvmField val WARP_ENTRY_CONFIG = string("warp_entry_config", "") @JvmField val WARP_MODE = oneOf("warp_mode", "wireguard", WARP_MODES) @JvmField val WARP_EP = string("warp_ep", "") @JvmField val WARP_PRIVATE_KEY = string("warp_private_key", "") diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ChainBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ChainBuilder.kt index ef9cecc9..facebac6 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ChainBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ChainBuilder.kt @@ -100,6 +100,7 @@ internal class ChainBuilder( private val ctx: BuildContext, private val state: BuildState, private val warp: Outbound? = null, + private val entryWarp: Outbound? = null, ) { /** usesXrayCore (generate.cpp:428-431). */ fun usesXrayCore(outbound: Outbound): Boolean = outbound.isXray() || outbound.isXrayFullConfig() @@ -128,7 +129,11 @@ internal class ChainBuilder( var xrayFullConfig = false var inXray = false for (id in hopIds) { - val hop = profiles.get(id) ?: continue + val hop = when (id) { + WarpHop.PROFILE_ID -> warp + WarpHop.ENTRY_PROFILE_ID -> entryWarp + else -> profiles.get(id) + } ?: continue if (hop.isXrayFullConfig()) xrayFullConfig = true val xray = hop.isXray() if (xray && !inXray) singToXray = true @@ -189,8 +194,9 @@ internal class ChainBuilder( val scan = ChainScan() var inXray = false for (id in hopIds) { - if (id == WarpHop.PROFILE_ID) { - val warpHop = warp ?: return missingHopError(id, origin) + if (id == WarpHop.PROFILE_ID || id == WarpHop.ENTRY_PROFILE_ID) { + val warpHop = (if (id == WarpHop.ENTRY_PROFILE_ID) entryWarp else warp) + ?: return missingHopError(id, origin) if (inXray) { state.xrayToSingTransitioned = true scan.coreTransitions++ @@ -395,6 +401,15 @@ internal class ChainBuilder( } val obj = result.json obj["tag"] = tag + if (hop.id == WarpHop.ENTRY_PROFILE_ID && nextTag.isEmpty()) { + // A separate WireGuard device per selector member would reuse one identity and race handshakes. + if (state.endpoints.none { (it as? JsonObject)?.string("tag") == "warp-entry" }) { + obj["tag"] = "warp-entry" + state.endpoints.add(obj) + } + state.outbounds.add(jsonObjectOf("type" to "selector", "tag" to tag, "outbounds" to JsonArray.of("warp-entry"))) + continue + } state.collectEchQueryName(hop.outbound) // Realm reads its STUN resolver off this key only; without it the hosts go through DNS rules (:1356-1358). if (TypeAccess.realmActive(hop.outbound)) obj["domain_resolver"] = jsonObjectOf("server" to Tags.DNS_DIRECT) diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt index d283e1a1..6fe99d1a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt @@ -89,7 +89,11 @@ class ConfigGenerator @JvmOverloads constructor( val front = if (plan != null) -1L else frontProxyId val state = BuildState(forTest = false, ports) - val chains = ChainBuilder(reads, buildContext, state, if (settings.enableWarp) WarpHop.outbound(settings) else null) + val chains = ChainBuilder( + reads, buildContext, state, + if (settings.enableWarp) WarpHop.outbound(settings) else null, + if (WarpHop.before(settings)) WarpHop.outbound(settings, entry = true) else null, + ) calculatePrerequisites(state, chains, reads, profile, route, landing, front, plan) if (state.failed) return GeneratedConfig.failure(state.error) @@ -612,12 +616,17 @@ class ConfigGenerator @JvmOverloads constructor( state: BuildState, chains: ChainBuilder, reads: ProfileProvider, profile: Outbound, profileId: Long, landingProxyId: Long, frontProxyId: Long, plan: AutoSelectorPlan?, origin: HopOrigin, ) { - val warpWrap = settings.enableWarp + val warpBefore = WarpHop.before(settings) + val warpAfter = WarpHop.after(settings) + val warpWrap = warpAfter && !warpBefore if (profile is AutoSelector && plan != null) { - buildAutoSelectorGroup(state, chains, reads, profile, profileId, plan, warpWrap) + val groupTag = if (warpAfter) { if (warpBefore) "warp-main" else Tags.WARP_BYPASS } else Tags.PROXY + buildAutoSelectorGroup(state, chains, reads, profile, profileId, plan, groupTag, warpBefore) + if (state.failed) return + if (warpBefore) buildAutoSelectorGroup(state, chains, reads, profile, profileId, plan, Tags.WARP_BYPASS, false, track = false) if (state.failed) return - if (warpWrap) { - buildWarpInFrontOfSelector(state) + if (warpAfter) { + buildWarpInFrontOfSelector(state, groupTag) if (state.failed) return } } else { @@ -630,7 +639,13 @@ class ConfigGenerator @JvmOverloads constructor( state.error = "The chain has no hops" return } - if (warpWrap) hopIds.add(0, WarpHop.PROFILE_ID) + if (warpBefore) { + val bypassTag = chains.buildOutboundChain(ChainRequest(hopIds.toList(), "warp-bypass-chain", origin = origin)) + if (state.failed) return + state.outbounds.add(jsonObjectOf("type" to "selector", "tag" to Tags.WARP_BYPASS, "outbounds" to JsonArray.of(bypassTag))) + hopIds.add(WarpHop.ENTRY_PROFILE_ID) + } + if (warpAfter) hopIds.add(0, WarpHop.PROFILE_ID) chains.buildOutboundChain( ChainRequest(hopIds, Tags.MAIN_CHAIN_PREFIX, includeProxy = true, warpWrap = warpWrap, origin = origin), ) @@ -672,13 +687,12 @@ class ConfigGenerator @JvmOverloads constructor( */ private fun buildAutoSelectorGroup( state: BuildState, chains: ChainBuilder, reads: ProfileProvider, selector: AutoSelector, selectorId: Long, - plan: AutoSelectorPlan, warpWrap: Boolean, + plan: AutoSelectorPlan, groupTag: String, warpBefore: Boolean, track: Boolean = true, ) { if (!plan.ok) { state.error = plan.error return } - val groupTag = if (warpWrap) Tags.WARP_BYPASS else Tags.PROXY class PlannedMember(val member: SelectorMember, val hopIds: List, val bridges: MemberBridges, val origin: HopOrigin) @@ -695,6 +709,7 @@ class ConfigGenerator @JvmOverloads constructor( if (memberGroup.landingProxyId > 0) hopIds.add(memberGroup.landingProxyId) hopIds.add(id) if (memberGroup.frontProxyId > 0) hopIds.add(memberGroup.frontProxyId) + if (warpBefore) hopIds.add(WarpHop.ENTRY_PROFILE_ID) val bridges = chains.bridgesFor(hopIds) bridgeCount += bridges.count val origin = HopOrigin(selector, listOf(ChainGroup(memberGroup.name, memberGroup.landingProxyId, memberGroup.frontProxyId))) @@ -714,7 +729,7 @@ class ConfigGenerator @JvmOverloads constructor( val tag = chains.buildOutboundChain( ChainRequest( planned.hopIds, - hopTag(Tags.POOL_CHAIN_PREFIX, idx), + hopTag(if (track) Tags.POOL_CHAIN_PREFIX else "pool-warp-bypass", idx), singToXrayPort = if (bridges.singToXray) bridgePorts[portIdx++] else -1, xrayToSingPort = if (bridges.xrayToSing) bridgePorts[portIdx++] else -1, xrayFullConfigPort = if (bridges.xrayFullConfig) bridgePorts[portIdx++] else -1, @@ -777,13 +792,13 @@ class ConfigGenerator @JvmOverloads constructor( group["balance_interval"] = "${selector.balanceIntervalSec}s" } state.outbounds.add(group) - state.autoSelector = AutoSelectorBuild( + if (track) state.autoSelector = AutoSelectorBuild( groupTag = groupTag, selectorId = selectorId, members = members, rejected = rejected, intervalSec = selector.intervalSec, - warp = warpWrap, + warp = WarpHop.after(settings), ) } @@ -806,8 +821,8 @@ class ConfigGenerator @JvmOverloads constructor( return rejected } - /** buildWarpInFrontOfSelector (:1779-1792): the group holds warp-bypass, so WARP is `proxy` dialing through it. */ - private fun buildWarpInFrontOfSelector(state: BuildState) { + /** Exit WARP dials through the selected group, which may itself use entry WARP. */ + private fun buildWarpInFrontOfSelector(state: BuildState, detour: String) { val warp = WarpHop.outbound(settings) val result = warp.buildChecked(buildContext) if (!result.ok) { @@ -816,7 +831,7 @@ class ConfigGenerator @JvmOverloads constructor( } val obj = result.json obj["tag"] = Tags.PROXY - obj["detour"] = Tags.WARP_BYPASS + obj["detour"] = detour if (warp.isEndpoint()) state.endpoints.add(obj) else state.outbounds.add(obj) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratedConfig.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratedConfig.kt index 5431949d..e2369cca 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratedConfig.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratedConfig.kt @@ -62,7 +62,7 @@ class GeneratedConfig( * core status names members by [members] tags, and pinning maps a profile id back to its tag. */ class AutoSelectorBuild( - /** The core `auto-selector` outbound: `proxy`, or `warp-bypass` under WARP. */ + /** The core auto-selector group, including its WARP placement. */ @JvmField val groupTag: String, @JvmField val selectorId: Long, /** Member ingress tag (`pool--0`) -> member profile id, in pool order: the plan's build minus [rejected]. */ diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt index d099549f..6554e94c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt @@ -145,8 +145,10 @@ data class GeneratorSettings( // ---- warp (SettingsRepo.h:252-267), the built-in WARP hop of WarpHop /** enable_warp. */ val enableWarp: Boolean = false, - /** warp_mode: "wireguard" or "masque". */ + /** warp_mode: "wireguard", "amnezia" or "masque". */ val warpMode: String = "wireguard", + val warpChainOrder: String = "proxy-first", + val warpEntryConfig: String = "", /** warp_ep, warp_private_key, warp_public_key, warp_ifc_addrs, warp_reserved (decimal strings). */ val warpEp: String = "", val warpPrivateKey: String = "", diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpCamouflage.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpCamouflage.kt new file mode 100644 index 00000000..6ac7969c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpCamouflage.kt @@ -0,0 +1,34 @@ +package io.nekohasekai.sagernet.outbound.config + +// Public QUIC CPS captures from Skiro1/warp-awg-gen (MIT). +// Source: https://github.com/Skiro1/warp-awg-gen/blob/382001b704a1a6f87429f4601c237937ad420a0e/internal/awg/cps.go +/* +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +internal object WarpCamouflage { + val packets = listOf( + "", + "", + "", + ) +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpHop.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpHop.kt index 3eae598a..06941bec 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpHop.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/WarpHop.kt @@ -1,25 +1,31 @@ package io.nekohasekai.sagernet.outbound.config +import io.nekohasekai.sagernet.outbound.json.JsonInput +import io.nekohasekai.sagernet.outbound.json.JsonObject +import io.nekohasekai.sagernet.outbound.json.JsonValues import io.nekohasekai.sagernet.outbound.Outbound import io.nekohasekai.sagernet.outbound.QtStrings import io.nekohasekai.sagernet.outbound.types.Masque import io.nekohasekai.sagernet.outbound.types.WireGuard import io.nekohasekai.sagernet.outbound.types.WireGuardPeer -/** - * The built-in WARP hop of generate.cpp: the synthetic profile id (generate.h:98), the WireGuard or MASQUE endpoint - * built from the warp_* settings (getWarpProfile, :475-524) and the missing-identity check of calculatePrerequisites - * (:559-571). With enable_warp the main chain becomes [WARP as `proxy`, exit hop as `warp-bypass`, ...]. - */ +/** Built-in WARP identities, shared by the main chain and selector members. */ object WarpHop { const val PROFILE_ID = -2408L + const val ENTRY_PROFILE_ID = -2409L + + fun before(settings: GeneratorSettings) = settings.enableWarp && settings.warpChainOrder in listOf("warp-first", "warp-both") + fun after(settings: GeneratorSettings) = settings.enableWarp && settings.warpChainOrder != "warp-first" const val MISSING_ERROR = - "Warp is enabled but its config has not been generated. Please generate the Warp config first in Routing Settings." + "WARP needs a generated config. Entry and exit mode requires two independent identities. Generate the config in Routing Settings." /** Whether the identity of the active warp_mode is incomplete (:559-567). */ @JvmStatic fun missing(settings: GeneratorSettings): Boolean = + (settings.warpChainOrder == "warp-both" && !validEntry(settings)) || primaryMissing(settings) + + private fun primaryMissing(settings: GeneratorSettings): Boolean = if (settings.warpMode == "masque") { settings.warpMasquePrivateKey.isEmpty() || settings.warpMasquePeerPublicKey.isEmpty() || settings.warpMasqueEp.isEmpty() || settings.warpMasqueIfcAddrs.isEmpty() @@ -30,7 +36,9 @@ object WarpHop { /** getWarpProfile (:475-524): named "warp", MTU 1280, an endpoint either way. */ @JvmStatic - fun outbound(settings: GeneratorSettings): Outbound { + fun outbound(settings: GeneratorSettings, entry: Boolean = false): Outbound { + val identity = if (entry && settings.warpChainOrder == "warp-both") + JsonInput.parseObject(settings.warpEntryConfig) else null if (settings.warpMode == "masque") { val masque = Masque() masque.name = "warp" @@ -40,6 +48,11 @@ object WarpHop { masque.private_key = settings.warpMasquePrivateKey masque.peer_public_key = settings.warpMasquePeerPublicKey masque.address = settings.warpMasqueIfcAddrs.toMutableList() + if (identity != null) { + masque.private_key = identity.string("private_key") + masque.peer_public_key = identity.string("peer_public_key") + masque.address = identity.array("address").filterIsInstance().toMutableList() + } masque.mtu = 1280 if (settings.warpMasqueSni.isNotEmpty()) masque.tls.server_name = settings.warpMasqueSni when (settings.warpMasqueHttpMode) { @@ -67,10 +80,39 @@ object WarpHop { reserved = settings.warpReserved.mapTo(ArrayList()) { QtStrings.toInt(it) } persistent_keepalive = "10" } + if (identity != null) { + wireGuard.private_key = identity.string("private_key") + wireGuard.address = identity.array("address").filterIsInstance().toMutableList() + val peer = identity.array("peers").firstOrNull() as? JsonObject ?: JsonObject() + wireGuard.peer.public_key = peer.string("public_key") + wireGuard.peer.reserved = peer.array("reserved").map { JsonValues.toInt(it) }.toMutableList() + } + if (settings.warpMode == "amnezia") { + wireGuard.enable_amnezia = true + wireGuard.jc = 4 + wireGuard.jmin = 40 + wireGuard.jmax = 70 + wireGuard.h1 = "1" + wireGuard.h2 = "2" + wireGuard.h3 = "3" + wireGuard.h4 = "4" + wireGuard.i1 = WarpCamouflage.packets.random() + } wireGuard.mtu = 1280 return wireGuard } + fun validEntry(settings: GeneratorSettings): Boolean { + val config = JsonInput.parseObject(settings.warpEntryConfig) + val masque = settings.warpMode == "masque" + val key = config.string("private_key").trim() + val primaryKey = if (masque) settings.warpMasquePrivateKey else settings.warpPrivateKey + if (config.string("type") != if (masque) "masque" else "wireguard") return false + if (key.isBlank() || key == primaryKey.trim() || config.array("address").isEmpty()) return false + return if (masque) config.string("peer_public_key").isNotBlank() + else (config.array("peers").firstOrNull() as? JsonObject)?.string("public_key")?.isNotBlank() == true + } + /** splitWarpEndpoint (:457-473): host, host:port, [v6] or [v6]:port; a bare IPv6 address has no port. */ @JvmStatic fun splitEndpoint(endpoint: String, defaultPort: Int): Pair { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/WarpSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/WarpSettingsScreen.kt index 19e1794a..9486cb21 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/WarpSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/WarpSettingsScreen.kt @@ -41,9 +41,16 @@ class WarpSettingsFragment : SettingsScreenFragment(R.xml.settings_warp) { getString(R.string.warp_mode_sum, it.entry ?: "") } fun showMode(value: String?) { + wireGuard.title = WarpClient.modeName(value.orEmpty()) masque.isVisible = value == WarpClient.TUNNEL_MASQUE wireGuard.isVisible = !masque.isVisible } + val order = pref(SettingsRegistry.WARP_CHAIN_ORDER.key) + order.summaryProvider = Preference.SummaryProvider { it.entry } + order.setOnPreferenceChangeListener { _, _ -> + reloadIfEnabled() + true + } showMode(mode.value) mode.setOnPreferenceChangeListener { _, newValue -> showMode(newValue as String) @@ -75,8 +82,14 @@ class WarpSettingsFragment : SettingsScreenFragment(R.xml.settings_warp) { pref(KEY_GENERATE).setOnPreferenceClickListener { button -> val masqueMode = mode.value == WarpClient.TUNNEL_MASQUE val tunnelType = if (masqueMode) WarpClient.TUNNEL_MASQUE else WarpClient.TUNNEL_WIREGUARD - WarpGenerate.generate(this, requireContext(), button, tunnelType, R.string.warp_generate_failed) { - fill(it, masqueMode) + val selectedMode = mode.value + val selectedOrder = order.value + val count = if (selectedOrder == "warp-both") 2 else 1 + WarpGenerate.generateIdentities(this, requireContext(), button, tunnelType, R.string.warp_generate_failed, count) { + check(mode.value == selectedMode && order.value == selectedOrder) { getString(R.string.warp_settings_changed) } + // Save only after every registration succeeds; a failed second registration preserves the old pair. + if (count == 2) DataStore.warpEntryConfig = it[1].toBeanJson(tunnelType) + fill(it[0], masqueMode) } true } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpClient.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpClient.kt index ba55adf1..fafa4e88 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpClient.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpClient.kt @@ -8,6 +8,8 @@ import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.appRequestsNeedProxy import io.nekohasekai.sagernet.ktx.appRequestsViaProxy +import io.nekohasekai.sagernet.outbound.config.GeneratorSettings +import io.nekohasekai.sagernet.outbound.config.WarpHop import io.nekohasekai.sagernet.outbound.json.JsonInput import io.nekohasekai.sagernet.outbound.link.LinkCodec import kotlinx.coroutines.CancellationException @@ -41,6 +43,19 @@ object WarpClient { ipv6.takeIf { it.isNotEmpty() }?.let { "$it/128" }, ) + init { + require(privateKey.isNotBlank() && peerPublicKey.isNotBlank() && endpoint.isNotBlank() && + (ipv4.isNotBlank() || ipv6.isNotBlank())) { "WARP registration returned an incomplete identity" } + } + + fun toBeanJson(tunnelType: String): String = WarpHop.outbound(GeneratorSettings( + warpMode = tunnelType, + warpPrivateKey = privateKey, warpPublicKey = peerPublicKey, + warpEp = endpoint, warpIfcAddrs = addresses, warpReserved = reserved, + warpMasquePrivateKey = privateKey, warpMasquePeerPublicKey = peerPublicKey, + warpMasqueEp = endpoint, warpMasqueIfcAddrs = addresses, + )).exportToJson().toCompact() + /** The endpoint split into host (IPv6 unbracketed) and port (edit_wireguard.cpp:44-49); null without a port. */ fun endpointHostPort(): Pair? { val sep = endpoint.lastIndexOf(':') @@ -52,10 +67,19 @@ object WarpClient { } fun modeName(mode: String): String = - app.getString(if (mode == TUNNEL_MASQUE) R.string.warp_mode_masque else R.string.warp_mode_wireguard) + app.getString(when (mode) { + TUNNEL_MASQUE -> R.string.warp_mode_masque + "amnezia" -> R.string.warp_mode_amnezia + else -> R.string.warp_mode_wireguard + }) /** generate.cpp:559-571: the key, endpoint or addresses of the active mode are missing. */ - fun isGenerated(): Boolean = if (DataStore.warpMode == TUNNEL_MASQUE) { + fun isGenerated(): Boolean = (DataStore.warpChainOrder != "warp-both" || WarpHop.validEntry(GeneratorSettings( + warpMode = DataStore.warpMode, warpEntryConfig = DataStore.warpEntryConfig, + warpPrivateKey = DataStore.warpPrivateKey, warpMasquePrivateKey = DataStore.warpMasquePrivateKey, + ))) && primaryGenerated() + + private fun primaryGenerated(): Boolean = if (DataStore.warpMode == TUNNEL_MASQUE) { DataStore.warpMasquePrivateKey.isNotEmpty() && DataStore.warpMasquePeerPublicKey.isNotEmpty() && DataStore.warpMasqueEp.isNotEmpty() && DataStore.warpMasqueIfcAddrs.isNotEmpty() } else { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpGenerate.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpGenerate.kt index a9d9b4fb..1174a6ba 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpGenerate.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/warp/WarpGenerate.kt @@ -51,30 +51,39 @@ object WarpGenerate { tunnelType: String, @StringRes failedTitle: Int, onIdentity: (WarpClient.Identity) -> Unit, + ) = generateIdentities(owner, context, button, tunnelType, failedTitle, 1) { onIdentity(it.single()) } + + fun generateIdentities( + owner: LifecycleOwner, + context: Context, + button: Preference, + tunnelType: String, + @StringRes failedTitle: Int, + count: Int, + onIdentities: (List) -> Unit, ) = confirmTerms(context) { val idle = button.summary button.isEnabled = false button.summary = context.getString(R.string.generating) owner.lifecycleScope.launch { - val identity = try { - WarpClient.register(tunnelType) + try { + val identities = List(count) { WarpClient.register(tunnelType) } + check(identities.map { it.privateKey }.distinct().size == count) { "WARP registrations returned duplicate identities" } + onIdentities(identities) + button.summary = context.getString(R.string.warp_generate_success) + delay(2000) } catch (e: CancellationException) { throw e } catch (e: Exception) { - button.summary = idle - button.isEnabled = true MaterialAlertDialogBuilder(context) .setTitle(failedTitle) .setMessage(e.readableMessage) .setPositiveButton(android.R.string.ok, null) .show() - return@launch + } finally { + button.summary = idle + button.isEnabled = true } - onIdentity(identity) - button.summary = context.getString(R.string.warp_generate_success) - delay(2000) - button.summary = idle - button.isEnabled = true } } diff --git a/app/src/main/res/raw/warp_awg_gen_license.txt b/app/src/main/res/raw/warp_awg_gen_license.txt new file mode 100644 index 00000000..b0548393 --- /dev/null +++ b/app/src/main/res/raw/warp_awg_gen_license.txt @@ -0,0 +1,24 @@ +Public QUIC CPS captures from Skiro1/warp-awg-gen +https://github.com/Skiro1/warp-awg-gen/blob/382001b704a1a6f87429f4601c237937ad420a0e/internal/awg/cps.go + +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ba531c38..ff6f975a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1401,7 +1401,7 @@ Отключить WARP WARP отключён Включить WARP - Позволяет использовать конфигурацию ниже как выход, полезно для обхода сервисов, ограничивающих не-WARP VPN + Использовать WARP на входе, выходе или с обеих сторон выбранного подключения. WARP включён Конечная точка Не удалось создать конфигурацию WARP @@ -1416,9 +1416,9 @@ %s\nHTTP/3 работает поверх UDP; HTTP/2 — поверх TLS на TCP и работает там, где UDP заблокирован. Не удалось создать идентификатор WARP Адреса интерфейса - WARP едет поверх вашего прокси: MASQUE через HTTP/3 требует прокси, пересылающего UDP, через HTTP/2 достаточно только TCP. + На выходе MASQUE через HTTP/3 требует поддержки UDP, через HTTP/2 — TCP. На входе адрес WARP должен быть доступен напрямую. Режим - %s\nWireGuard работает поверх UDP. MASQUE туннелирует через HTTP/3 или HTTP/2 и проходит сети, блокирующие WireGuard. + %s\nWireGuard и AmneziaWG используют UDP. AmneziaWG добавляет маскировку пакетов. MASQUE использует HTTP/3 или HTTP/2. WARP включён, но его конфигурация не создана. Создайте её в Настройки › Маршрутизация › WARP. Открытый ключ пары Base64 DER открытого ключа сервера. При задании закрепляет сертификат сервера. @@ -1431,7 +1431,7 @@ Выкл. Вкл. · %s
Принимаете ли вы условия использования Cloudflare WARP?]]>
- WARP едет поверх вашего прокси: WireGuard требует прокси, пересылающего UDP. + На выходе WireGuard и AmneziaWG требуют поддержки UDP в выбранном подключении. На входе адрес WARP должен быть доступен напрямую. Выбрать резервную копию Загрузка из WebDAV… Чтение папки WebDAV… @@ -2496,4 +2496,11 @@ Собственные настройки DNS профиля отброшены, поэтому он использует глобальные: %1$s Удаленный %1$s, прямой %2$s Недавние и предлагаемые серверы + AmneziaWG + Расположение WARP + На выходе: подключение → WARP + На входе: WARP → подключение + На входе и выходе: WARP → подключение → WARP + Подключение — выбранный профиль или цепочка. Входной WARP доставляет подключение до сервера, выходной — трафик от сервера до сайта. Для двойного режима кнопка «Сгенерировать» создаёт две независимые регистрации WARP. Правила обхода WARP обходят оба туннеля. + Настройки WARP изменились во время регистрации. Повторите генерацию для выбранного режима. diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index cb5005d5..78092e79 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -696,10 +696,12 @@ @string/warp_mode_wireguard + @string/warp_mode_amnezia @string/warp_mode_masque wireguard + amnezia masque @@ -707,4 +709,14 @@ @string/warp_http3_only HTTP/2 + + @string/warp_order_exit + @string/warp_order_entry + @string/warp_order_both + + + proxy-first + warp-first + warp-both + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f957dbb6..dc703281 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1558,13 +1558,13 @@ On · %s Off Enable WARP - Enables the config below to be used as egress, useful for bypassing services restricting non-WARP VPNs + Use WARP at the entry, exit, or both ends of the selected connection. Mode - %s\nWireGuard runs over UDP. MASQUE tunnels over HTTP/3 or HTTP/2 and passes networks that block WireGuard. + %s\nWireGuard and AmneziaWG use UDP. AmneziaWG adds packet camouflage. MASQUE uses HTTP/3 or HTTP/2. WireGuard MASQUE - WARP rides on your proxy: WireGuard needs a proxy that forwards UDP. - WARP rides on your proxy: MASQUE over HTTP/3 needs a proxy that forwards UDP, over HTTP/2 it needs only TCP. + At exit, WireGuard and AmneziaWG require a connection that forwards UDP. At entry, the WARP endpoint must be reachable directly. + At exit, MASQUE over HTTP/3 requires UDP forwarding; HTTP/2 requires TCP. At entry, the WARP endpoint must be reachable directly. Endpoint Private key Public key @@ -2641,4 +2641,11 @@ The profile\'s own DNS settings were dropped, so it uses the global ones: %1$s Remote %1$s, direct %2$s Recent and suggested servers + AmneziaWG + WARP placement + At exit: Connection → WARP + At entry: WARP → Connection + At entry and exit: WARP → Connection → WARP + Connection means the selected profile or chain. Entry WARP carries the connection to its server; exit WARP carries traffic from the server to the destination. For both, Generate registers two independent WARP identities. WARP bypass rules skip both tunnels. + WARP settings changed during registration. Generate again for the selected mode. diff --git a/app/src/main/res/xml/settings_warp.xml b/app/src/main/res/xml/settings_warp.xml index cfecf8ec..efce39be 100644 --- a/app/src/main/res/xml/settings_warp.xml +++ b/app/src/main/res/xml/settings_warp.xml @@ -12,6 +12,15 @@ app:key="warp_mode" app:title="@string/warp_mode" /> + + + = + (core.array("outbounds") + core.array("endpoints")).filterIsInstance() + + private fun find(core: JsonObject, tag: String) = objects(core).single { it.string("tag") == tag } + + @Test fun allTransportsPlaceEntryAndExitWithoutWrappingBypass() { + for (mode in listOf("wireguard", "amnezia", "masque")) { + for (order in listOf("proxy-first", "warp-first", "warp-both")) { + val config = JsonInput.parseObject(build(settings(mode, order)).coreConfig) + val endpoints = config.array("endpoints").filterIsInstance() + assertEquals(if (order == "warp-both") 2 else 1, endpoints.size) + val entry = endpoints.singleOrNull { it.string("tag") == "warp-entry" } + assertEquals(order != "proxy-first", entry != null) + if (entry != null) assertFalse(entry.contains("detour")) + if (entry != null) { + val head = if (order == "warp-first") find(config, "proxy") + else find(config, find(config, "proxy").string("detour")) + assertEquals("socks", head.string("type")) + val alias = find(config, head.string("detour")) + assertEquals("warp-entry", alias.array("outbounds").single()) + } + if (order == "warp-first") assertEquals("socks", find(config, "proxy").string("type")) + else assertEquals(if (mode == "masque") "masque" else "wireguard", find(config, "proxy").string("type")) + val bypass = find(config, "warp-bypass") + if (order == "proxy-first") assertFalse(bypass.contains("detour")) + else { + val raw = find(config, bypass.array("outbounds").single() as String) + assertEquals("socks", raw.string("type")) + assertFalse(raw.contains("detour")) + } + if (order == "warp-both") assertEquals(2, endpoints.map { it.string("private_key") }.distinct().size) + } + } + } + + @Test fun amneziaAddsCamouflageWithoutChangingWireguardIdentity() { + val settings = settings("amnezia", "warp-both") + for (entry in listOf(false, true)) { + val result = WarpHop.outbound(settings, entry).buildChecked(BuildContext.DEFAULT) + assertTrue(result.error, result.ok) + val config = result.json.toCompact() + assertTrue(config.contains("().count { it.string("tag") == "warp-entry" }) + for (doc in listOf(core, xray)) { + val outbounds = doc.array("outbounds").filterIsInstance() + assertEquals(outbounds.size, outbounds.map { it.string("tag") }.distinct().size) + } + assertTrue(xray.toCompact().contains("dialerProxy")) + } + } + + @Test fun selectorMembersShareOneEntryEndpointAndKeepBypassSeparate() { + val members = mapOf(1L to socks(), 2L to xray()) + val selector = AutoSelector().apply { gids.add(9L) } + val group = SelectorGroup(9, "Test", -1, -1, null, null, + members.map { (id, outbound) -> SelectorMember(id, outbound, 0, 0, "") }) + val routing = RoutingInput(RoutingInput.DEFAULT.profile, RoutingInput.DEFAULT.catalog, SelectorStore { group }) + for (order in listOf("warp-first", "warp-both")) { + val generated = ConfigGenerator(ProfileProvider { if (it == 3L) selector else members[it] }, settings(order = order), routing = routing).build(3) + assertTrue(generated.error, generated.ok) + val core = JsonInput.parseObject(generated.coreConfig) + val all = objects(core) + assertEquals(all.size, all.map { it.string("tag") }.distinct().size) + assertEquals(1, all.count { it.string("tag") == "warp-entry" }) + assertEquals(2, find(core, "warp-bypass").array("outbounds").size) + assertEquals(if (order == "warp-both") "warp-main" else "proxy", generated.autoSelector!!.groupTag) + } + } + + @Test fun entryWrapsTheWholeChainWhileRouteOutboundsStayIndependent() { + val profiles = (1L..5L).associateWith { id -> socks().apply { server = "192.0.2.$id" } } + + (6L to Chain().apply { list.addAll(listOf(1L, 2L)) }) + val route = RouteProfile().apply { + rules.add(RouteRule().apply { domain.add("separate.example"); outbound_id = 5L }) + rules.add(RouteRule().apply { domain.add("bypass.example"); outbound_id = OutboundIds.WARP_BYPASS }) + } + val routing = RoutingInput(route, RoutingInput.DEFAULT.catalog) + for (order in listOf("proxy-first", "warp-first", "warp-both")) { + val generated = ConfigGenerator(ProfileProvider { profiles[it] }, settings(order = order), routing = routing) + .build(6, landingProxyId = 4, frontProxyId = 3) + assertTrue(generated.error, generated.ok) + val core = JsonInput.parseObject(generated.coreConfig) + fun path(start: String): List { + val servers = ArrayList() + var hop = find(core, start) + repeat(objects(core).size) { + if (hop.string("tag") == "warp-entry") { servers.add("entry"); return servers } + if (hop.contains("server")) servers.add(hop.string("server")) + val next = hop.string("detour").ifEmpty { hop.array("outbounds").firstOrNull() as? String ?: "" } + if (next.isEmpty()) return servers + hop = find(core, next) + } + error("Outbound cycle") + } + val chain = listOf("192.0.2.4", "192.0.2.2", "192.0.2.1", "192.0.2.3") + // WireGuard endpoints carry the server in peers, so only the profile hops and entry marker appear here. + assertEquals(chain + if (order == "proxy-first") emptyList() else listOf("entry"), path("proxy")) + assertEquals(chain, path("warp-bypass")) + val separate = objects(core).single { it.string("server") == "192.0.2.5" } + assertFalse(separate.contains("detour")) + val rules = core.obj("route").array("rules").filterIsInstance() + assertTrue(rules.any { it.array("domain").contains("bypass.example") && it.string("outbound") == "warp-bypass" }) + } + } + + @Test fun disabledWarpDoesNotRequireOrEmitAnIdentity() { + val config = JsonInput.parseObject(build(settings(order = "warp-both").copy( + enableWarp = false, warpEntryConfig = "", warpPrivateKey = "", + )).coreConfig) + assertTrue(config.array("endpoints").isEmpty()) + assertFalse(find(config, "proxy").contains("detour")) + } + + @Test fun incompleteRegistrationsCannotReplaceSavedIdentities() { + fun identity(key: String = "key", peer: String = "peer", endpoint: String = "192.0.2.1:2408", ipv4: String = "172.16.0.2") = + WarpClient.Identity("", "", "", ipv4, "", key, peer, endpoint, emptyList()) + identity() + for (invalid in listOf<() -> Unit>( + { identity(key = " ") }, { identity(peer = "") }, { identity(endpoint = "") }, { identity(ipv4 = "") }, + )) { + assertThrows(IllegalArgumentException::class.java, invalid) + } + } +} From 91b2592533ee81067651444e37777a94895b946b Mon Sep 17 00:00:00 2001 From: theremila Date: Mon, 26 Oct 2026 09:45:39 +0200 Subject: [PATCH 2/2] Validate selector member topology with entry WARP Co-authored-by: Codex --- .../outbound/config/ConfigGenerator.kt | 11 +++++++++-- .../sagernet/outbound/config/WarpModesTest.kt | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt index 6fe99d1a..f5a7ff44 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt @@ -697,7 +697,7 @@ class ConfigGenerator @JvmOverloads constructor( class PlannedMember(val member: SelectorMember, val hopIds: List, val bridges: MemberBridges, val origin: HopOrigin) // One member the core rejects fails the whole group's start, so drop it instead. - val rejected = rejectedMembers(chains, reads, plan.build) + val rejected = rejectedMembers(chains, reads, plan.build).toMutableMap() val plannedMembers = ArrayList() var bridgeCount = 0 for (id in plan.build) { @@ -710,9 +710,16 @@ class ConfigGenerator @JvmOverloads constructor( hopIds.add(id) if (memberGroup.frontProxyId > 0) hopIds.add(memberGroup.frontProxyId) if (warpBefore) hopIds.add(WarpHop.ENTRY_PROFILE_ID) + val origin = HopOrigin(selector, listOf(ChainGroup(memberGroup.name, memberGroup.landingProxyId, memberGroup.frontProxyId))) + if (warpBefore) { + val error = chains.candidateError(hopIds, origin) + if (error.isNotEmpty() && error != OutboundChecks.fragmentSettingsError(buildContext)) { + rejected[id] = error + continue + } + } val bridges = chains.bridgesFor(hopIds) bridgeCount += bridges.count - val origin = HopOrigin(selector, listOf(ChainGroup(memberGroup.name, memberGroup.landingProxyId, memberGroup.frontProxyId))) plannedMembers.add(PlannedMember(member, hopIds, bridges, origin)) } // One batch for every member: reserving per member can deal the same port twice. diff --git a/app/src/test/java/io/nekohasekai/sagernet/outbound/config/WarpModesTest.kt b/app/src/test/java/io/nekohasekai/sagernet/outbound/config/WarpModesTest.kt index 7384e27a..8202a028 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/outbound/config/WarpModesTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/outbound/config/WarpModesTest.kt @@ -6,6 +6,7 @@ import io.nekohasekai.sagernet.outbound.json.JsonInput import io.nekohasekai.sagernet.outbound.json.JsonObject import io.nekohasekai.sagernet.outbound.types.AutoSelector import io.nekohasekai.sagernet.outbound.types.Chain +import io.nekohasekai.sagernet.outbound.types.Custom import io.nekohasekai.sagernet.outbound.types.Socks import io.nekohasekai.sagernet.outbound.types.XrayVless import io.nekohasekai.sagernet.route.OutboundIds @@ -194,4 +195,21 @@ class WarpModesTest { assertThrows(IllegalArgumentException::class.java, invalid) } } + + @Test fun selectorSkipsMembersThatCannotDialThroughEntryWarp() { + val members = mapOf(1L to socks(), 2L to Custom().apply { + subtype = Custom.CUSTOM_XRAY_FULL_CONFIG + config = "{\"outbounds\":[{\"protocol\":\"freedom\",\"tag\":\"direct\"}]}" + }) + val selector = AutoSelector().apply { gids.add(9L) } + val group = SelectorGroup(9, "Test", -1, -1, null, null, + members.map { (id, outbound) -> SelectorMember(id, outbound, 0, 0, "") }) + val routing = RoutingInput(RoutingInput.DEFAULT.profile, RoutingInput.DEFAULT.catalog, SelectorStore { group }) + for (order in listOf("warp-first", "warp-both")) { + val generated = ConfigGenerator(ProfileProvider { if (it == 3L) selector else members[it] }, settings(order = order), routing = routing).build(3) + assertTrue(generated.error, generated.ok) + assertEquals(listOf(1L), generated.autoSelector!!.members.values.toList()) + assertTrue(generated.autoSelector.rejected.containsKey(2L)) + } + } }