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..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 @@ -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,18 +687,17 @@ 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) // 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) { @@ -695,9 +709,17 @@ 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 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. @@ -714,7 +736,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 +799,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 +828,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 +838,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) + } + } + + @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)) + } + } +}