Skip to content

Commit f063924

Browse files
committed
feat: Implement WireGuard endpoint detour logic and enhance configuration tests
1 parent 0776674 commit f063924

4 files changed

Lines changed: 336 additions & 10 deletions

File tree

app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,43 @@ private fun SingBoxOption.isGeneratedEndpoint(): Boolean {
6868
return this is Endpoint && type in ENDPOINT_TYPES
6969
}
7070

71+
internal fun SingBoxOption.detourTo(nextTag: String) {
72+
if (this is Endpoint_WireGuardOptions) {
73+
val effectiveOptions = asMap()
74+
val listenPort = when (val value = effectiveOptions["listen_port"]) {
75+
is Number -> value.toInt()
76+
else -> value?.toString()?.toIntOrNull() ?: 0
77+
}
78+
if (listenPort > 0) {
79+
val endpointTag = effectiveOptions["tag"]?.toString()?.takeIf { it.isNotBlank() }
80+
?: "<untagged>"
81+
throw IllegalArgumentException(
82+
"WireGuard endpoint '$endpointTag' cannot detour to '$nextTag' while " +
83+
"listen_port is enabled; set listen_port to 0 or use WireGuard only " +
84+
"in a chain position that does not require another hop."
85+
)
86+
}
87+
88+
// sing-box exposes endpoints through OutboundManager, so selectors, URL tests and
89+
// other dialers can reference this tag directly. WireGuard also embeds DialerOptions,
90+
// allowing its own outbound connection to follow the existing T4A chain direction.
91+
detour = nextTag
92+
return
93+
}
94+
95+
_hack_config_map["detour"] = nextTag
96+
}
97+
98+
internal fun buildSelectorOutbound(defaultTag: String?, memberTags: List<String>) =
99+
Outbound_SelectorOptions().apply {
100+
type = "selector"
101+
tag = TAG_PROXY
102+
default_ = defaultTag
103+
// Endpoint tags are valid outbound references in sing-box 1.13; keep them as direct
104+
// group members instead of wrapping WireGuard in a removed outbound.
105+
outbounds = memberTags
106+
}
107+
71108
private fun endpointTag(value: Any?): String? {
72109
return (value as? Map<*, *>)?.get("tag")?.toString()?.takeIf { it.isNotBlank() }
73110
}
@@ -532,7 +569,7 @@ fun buildConfig(
532569
outbound = tagOut
533570
})
534571
} else {
535-
pastOutbound._hack_config_map["detour"] = tagOut
572+
pastOutbound.detourTo(tagOut)
536573
}
537574
} else {
538575
// index == 0 means last profile in chain / not chain
@@ -715,12 +752,7 @@ fun buildConfig(
715752
list.forEach {
716753
tagMap[it.id] = buildChain(it.id, it)
717754
}
718-
outbounds.add(0, Outbound_SelectorOptions().apply {
719-
type = "selector"
720-
tag = TAG_PROXY
721-
default_ = tagMap[proxy.id]
722-
outbounds = tagMap.values.toList()
723-
})
755+
outbounds.add(0, buildSelectorOutbound(tagMap[proxy.id], tagMap.values.toList()))
724756
} else {
725757
val mainTag = buildChain(0, proxy)
726758
tagMap[proxy.id] = mainTag

app/src/test/java/io/nekohasekai/sagernet/fmt/ConfigBuilderWireGuardTest.kt

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import org.junit.Assert.assertEquals
1010
import org.junit.Assert.assertFalse
1111
import org.junit.Assert.assertNotNull
1212
import org.junit.Assert.assertTrue
13+
import org.junit.Assert.fail
1314
import org.junit.Test
1415

1516
class ConfigBuilderWireGuardTest {
@@ -64,6 +65,68 @@ class ConfigBuilderWireGuardTest {
6465
assertEquals(MAIN_TAG, config.getAsJsonObject("route").get("final").asString)
6566
}
6667

68+
@Test
69+
fun selectorReferencesWireGuardEndpointMemberWithoutLegacyOutbound() {
70+
val options = topologyOptions()
71+
options.outbounds.add(0, buildSelectorOutbound(MAIN_TAG, listOf(MAIN_TAG, NEXT_TAG)))
72+
options.route.final_ = TAG_PROXY
73+
74+
val config = finalizedTopology(options)
75+
val selector = outbound(config, TAG_PROXY)
76+
77+
assertEquals(MAIN_TAG, selector.get("default").asString)
78+
assertEquals(listOf(MAIN_TAG, NEXT_TAG), selector
79+
.getAsJsonArray("outbounds").map { it.asString })
80+
assertTopologyReferencesResolve(config)
81+
}
82+
83+
@Test
84+
fun urlTestTopologyUsesWireGuardEndpointAsMainTarget() {
85+
val config = finalizedTopology(topologyOptions())
86+
87+
assertEquals(MAIN_TAG, config.getAsJsonObject("route").get("final").asString)
88+
assertTopologyReferencesResolve(config)
89+
}
90+
91+
@Test
92+
fun applicationFacingWireGuardDetoursToNextOutboundInExistingChainOrder() {
93+
val options = topologyOptions()
94+
options.outbounds.single { it.tag == MAIN_TAG }.detourTo(NEXT_TAG)
95+
96+
val config = finalizedTopology(options)
97+
assertEquals(NEXT_TAG, endpoint(config, MAIN_TAG).get("detour").asString)
98+
assertEquals(listOf(MAIN_TAG, NEXT_TAG), chainPath(config))
99+
assertTopologyReferencesResolve(config)
100+
}
101+
102+
@Test
103+
fun egressFacingWireGuardIsReferencedByPreviousOutbound() {
104+
val options = topologyOptions()
105+
options.route.final_ = NEXT_TAG
106+
options.outbounds.single { it.tag == NEXT_TAG }.detourTo(MAIN_TAG)
107+
108+
val config = finalizedTopology(options)
109+
assertEquals(MAIN_TAG, outbound(config, NEXT_TAG).get("detour").asString)
110+
assertFalse(endpoint(config, MAIN_TAG).has("detour"))
111+
assertEquals(listOf(NEXT_TAG, MAIN_TAG), chainPath(config))
112+
assertTopologyReferencesResolve(config)
113+
}
114+
115+
@Test
116+
fun wireGuardWithListenPortRejectsPositionRequiringDetour() {
117+
val endpoint = wireGuardEndpoint(listenPort = 51820)
118+
119+
try {
120+
endpoint.detourTo(NEXT_TAG)
121+
fail("Expected WireGuard listen_port and detour conflict")
122+
} catch (error: IllegalArgumentException) {
123+
assertTrue(error.message.orEmpty().contains(MAIN_TAG))
124+
assertTrue(error.message.orEmpty().contains(NEXT_TAG))
125+
assertTrue(error.message.orEmpty().contains("listen_port"))
126+
assertFalse(endpoint.asMap().containsKey("detour"))
127+
}
128+
}
129+
67130
private fun baseOptions() = MyOptions().apply {
68131
endpoints = mutableListOf()
69132
route = RouteOptions().apply { final_ = MAIN_TAG }
@@ -84,12 +147,94 @@ class ConfigBuilderWireGuardTest {
84147
)
85148
}
86149

150+
private fun topologyOptions() = MyOptions().apply {
151+
endpoints = mutableListOf()
152+
route = RouteOptions().apply { final_ = MAIN_TAG }
153+
outbounds = mutableListOf(
154+
wireGuardEndpoint(),
155+
Outbound().apply {
156+
type = "socks"
157+
tag = NEXT_TAG
158+
_hack_config_map["server"] = "192.0.2.20"
159+
_hack_config_map["server_port"] = 1080
160+
},
161+
Outbound().apply {
162+
type = "direct"
163+
tag = TAG_DIRECT
164+
},
165+
)
166+
}
167+
168+
private fun wireGuardEndpoint(listenPort: Int = 0) =
169+
buildSingBoxEndpointWireGuardBean(WireGuardBean().apply {
170+
initializeDefaultValues()
171+
serverAddress = "198.51.100.10"
172+
serverPort = 51820
173+
localAddress = "10.0.0.2/32, fd00::2/128"
174+
privateKey = TEST_PRIVATE_KEY
175+
peerPublicKey = TEST_PUBLIC_KEY
176+
this.listenPort = listenPort
177+
}).apply { tag = MAIN_TAG }
178+
179+
private fun finalizedTopology(options: MyOptions) = gson.toJsonTree(
180+
finalizeRootConfig(options)
181+
).asJsonObject.also { config ->
182+
val endpoints = config.getAsJsonArray("endpoints").map { it.asJsonObject }
183+
val outbounds = config.getAsJsonArray("outbounds").map { it.asJsonObject }
184+
assertEquals(1, endpoints.count { it.get("tag").asString == MAIN_TAG })
185+
assertFalse(outbounds.any { it.get("type").asString == "wireguard" })
186+
}
187+
188+
private fun endpoint(config: com.google.gson.JsonObject, tag: String) =
189+
config.getAsJsonArray("endpoints")
190+
.map { it.asJsonObject }
191+
.single { it.get("tag").asString == tag }
192+
193+
private fun outbound(config: com.google.gson.JsonObject, tag: String) =
194+
config.getAsJsonArray("outbounds")
195+
.map { it.asJsonObject }
196+
.single { it.get("tag").asString == tag }
197+
198+
private fun assertTopologyReferencesResolve(config: com.google.gson.JsonObject) {
199+
val endpoints = config.getAsJsonArray("endpoints").map { it.asJsonObject }
200+
val outbounds = config.getAsJsonArray("outbounds").map { it.asJsonObject }
201+
val availableTags = (endpoints + outbounds).map { it.get("tag").asString }.toSet()
202+
val referencedTags = buildList {
203+
add(config.getAsJsonObject("route").get("final").asString)
204+
endpoints.mapNotNullTo(this) { it.get("detour")?.asString }
205+
outbounds.mapNotNullTo(this) { it.get("detour")?.asString }
206+
outbounds.filter { it.get("type").asString == "selector" }.forEach { selector ->
207+
add(selector.get("default").asString)
208+
selector.getAsJsonArray("outbounds").mapTo(this) { it.asString }
209+
}
210+
}
211+
212+
assertTrue("Unresolved topology tags: ${referencedTags - availableTags}",
213+
availableTags.containsAll(referencedTags))
214+
}
215+
216+
private fun chainPath(config: com.google.gson.JsonObject): List<String> {
217+
val nodes = (
218+
config.getAsJsonArray("endpoints").map { it.asJsonObject } +
219+
config.getAsJsonArray("outbounds").map { it.asJsonObject }
220+
).associateBy { it.get("tag").asString }
221+
val path = mutableListOf<String>()
222+
var currentTag: String? = config.getAsJsonObject("route").get("final").asString
223+
while (currentTag != null) {
224+
check(path.size <= nodes.size) { "Cycle in topology path: $path" }
225+
path += currentTag
226+
currentTag = nodes.getValue(currentTag).get("detour")?.asString
227+
}
228+
return path
229+
}
230+
87231
private fun endpointOverrideJson(name: String): String {
88232
return """{"endpoints":[{"type":"wireguard","tag":"$MAIN_TAG","name":"$name"}]}"""
89233
}
90234

91235
private companion object {
92236
const val MAIN_TAG = "wireguard-main"
237+
const val NEXT_TAG = "next-hop"
93238
const val CUSTOM_TAG = "custom-wireguard"
94239
const val TEST_PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
95240
const val TEST_PUBLIC_KEY = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="

0 commit comments

Comments
 (0)