Skip to content

Commit 79f7287

Browse files
committed
feat: Implement WireGuard endpoint support, add tests, and update CI workflow
1 parent 4b74924 commit 79f7287

6 files changed

Lines changed: 289 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ jobs:
3636
go-version: ^1.24
3737
- name: Native Build
3838
if: steps.cache.outputs.cache-hit != 'true'
39-
run: ./run lib core
39+
run: |
40+
./run lib core
41+
(cd libcore && go test . -run '^TestWireGuard(SingleEndpointConfig|LegacyOutboundRejected)$' -count=1 -v)
4042
- name: Verify LibCore AAR
4143
run: |
4244
test -s app/libs/libcore.aar

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

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,88 @@ const val TAG_DNS_HOSTS = "dns-hosts"
5959

6060
const val LOCALHOST = "127.0.0.1"
6161

62+
// Only types backed by the target sing-box endpoint registry belong here. Keeping this
63+
// whitelist explicit prevents a user-supplied legacy/custom outbound from being silently
64+
// reinterpreted as an endpoint.
65+
private val ENDPOINT_TYPES = setOf("wireguard")
66+
67+
private fun SingBoxOption.isGeneratedEndpoint(): Boolean {
68+
return this is Endpoint && type in ENDPOINT_TYPES
69+
}
70+
71+
private fun endpointTag(value: Any?): String? {
72+
return (value as? Map<*, *>)?.get("tag")?.toString()?.takeIf { it.isNotBlank() }
73+
}
74+
75+
private fun mergeEndpointList(
76+
existing: List<*>, incoming: List<*>, prependNew: Boolean = false
77+
): MutableList<Any?> {
78+
val result = existing.toMutableList()
79+
val additions = mutableListOf<Any?>()
80+
81+
incoming.forEach { endpoint ->
82+
val tag = endpointTag(endpoint)
83+
val existingIndex = tag?.let { candidate ->
84+
result.indexOfFirst { endpointTag(it) == candidate }
85+
} ?: -1
86+
val additionIndex = tag?.let { candidate ->
87+
additions.indexOfFirst { endpointTag(it) == candidate }
88+
} ?: -1
89+
90+
when {
91+
existingIndex >= 0 -> result[existingIndex] = endpoint
92+
additionIndex >= 0 -> additions[additionIndex] = endpoint
93+
else -> additions.add(endpoint)
94+
}
95+
}
96+
97+
if (prependNew) result.addAll(0, additions) else result.addAll(additions)
98+
return result
99+
}
100+
101+
@Suppress("UNCHECKED_CAST")
102+
private fun mergeRootConfig(dst: MutableMap<String, Any?>, json: String) {
103+
if (json.isBlank()) return
104+
val source = gson.fromJson(json, dst.javaClass) as? Map<String, Any?> ?: return
105+
val remaining = source.toMutableMap()
106+
107+
// Root custom config precedence is automatic < global < selected profile. For endpoints,
108+
// a later non-empty tag replaces the earlier object in place; distinct/untagged objects
109+
// coexist. The existing +key/key+ list extension syntax remains prepend/append respectively.
110+
val replacement = remaining.remove("endpoints")
111+
val prepended = remaining.remove("+endpoints")
112+
val appended = remaining.remove("endpoints+")
113+
Util.mergeMap(dst, remaining)
114+
115+
fun merge(value: Any?, prependNew: Boolean = false) {
116+
if (value !is List<*>) {
117+
if (value != null) dst["endpoints"] = value
118+
return
119+
}
120+
val current = dst["endpoints"] as? List<*> ?: emptyList<Any?>()
121+
dst["endpoints"] = mergeEndpointList(current, value, prependNew)
122+
}
123+
124+
merge(replacement)
125+
merge(prepended, prependNew = true)
126+
merge(appended)
127+
}
128+
129+
internal fun finalizeRootConfig(
130+
options: MyOptions,
131+
globalCustomConfig: String = "",
132+
profileCustomConfig: String = "",
133+
): MutableMap<String, Any?> {
134+
val generatedEndpoints = options.outbounds.orEmpty().filter { it.isGeneratedEndpoint() }
135+
options.endpoints = options.endpoints.orEmpty() + generatedEndpoints.map { it as Endpoint }
136+
options.outbounds = options.outbounds.orEmpty().filterNot { it.isGeneratedEndpoint() }
137+
138+
val configMap = options.asMap()
139+
mergeRootConfig(configMap, globalCustomConfig)
140+
mergeRootConfig(configMap, profileCustomConfig)
141+
return configMap
142+
}
143+
62144
class ConfigBuildResult(
63145
var config: String,
64146
var externalIndex: List<IndexEntity>,
@@ -330,6 +412,7 @@ fun buildConfig(
330412
})
331413
}
332414

415+
endpoints = mutableListOf()
333416
outbounds = mutableListOf()
334417

335418
// init routing object
@@ -1096,10 +1179,12 @@ fun buildConfig(
10961179
}
10971180
}
10981181

1099-
if (!forTest) _hack_custom_config = DataStore.globalCustomConfig
1100-
}.let {
1101-
val configMap = it.asMap()
1102-
Util.mergeJSON(configMap, proxy.requireBean().customConfigJson)
1182+
}.let { options ->
1183+
val configMap = finalizeRootConfig(
1184+
options,
1185+
globalCustomConfig = if (forTest) "" else DataStore.globalCustomConfig,
1186+
profileCustomConfig = proxy.requireBean().customConfigJson,
1187+
)
11031188
ConfigBuildResult(
11041189
gson.toJson(configMap),
11051190
externalIndexMap,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package io.nekohasekai.sagernet.fmt
2+
3+
import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean
4+
import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxEndpointWireGuardBean
5+
import moe.matsuri.nb4a.SingBoxOptions.MyOptions
6+
import moe.matsuri.nb4a.SingBoxOptions.Outbound
7+
import moe.matsuri.nb4a.SingBoxOptions.RouteOptions
8+
import moe.matsuri.nb4a.utils.JavaUtil.gson
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertFalse
11+
import org.junit.Assert.assertNotNull
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
15+
class ConfigBuilderWireGuardTest {
16+
17+
@Test
18+
fun singleWireGuardMainUsesEndpointTagAndCoexistsWithCustomEndpoint() {
19+
val options = baseOptions()
20+
val config = gson.toJsonTree(
21+
finalizeRootConfig(options, globalCustomConfig = CUSTOM_ENDPOINT_JSON)
22+
).asJsonObject
23+
24+
val endpoints = config.getAsJsonArray("endpoints")
25+
assertEquals(2, endpoints.size())
26+
27+
val generated = endpoints
28+
.map { it.asJsonObject }
29+
.single { it.get("tag").asString == MAIN_TAG }
30+
assertEquals("wireguard", generated.get("type").asString)
31+
assertEquals(listOf("10.0.0.2/32", "fd00::2/128"), generated
32+
.getAsJsonArray("address").map { it.asString })
33+
assertTrue(TEST_PRIVATE_KEY == generated.get("private_key").asString)
34+
assertEquals(TEST_PUBLIC_KEY, generated.getAsJsonArray("peers")
35+
.single().asJsonObject.get("public_key").asString)
36+
37+
val custom = endpoints
38+
.map { it.asJsonObject }
39+
.single { it.get("tag").asString == CUSTOM_TAG }
40+
assertEquals("wireguard", custom.get("type").asString)
41+
42+
val outbounds = config.getAsJsonArray("outbounds")
43+
assertTrue(outbounds.any { it.asJsonObject.get("tag").asString == TAG_DIRECT })
44+
assertFalse(outbounds.any { it.asJsonObject.get("type").asString == "wireguard" })
45+
assertEquals(MAIN_TAG, config.getAsJsonObject("route").get("final").asString)
46+
}
47+
48+
@Test
49+
fun profileCustomEndpointWinsSameTagAfterGlobalConfig() {
50+
val config = gson.toJsonTree(
51+
finalizeRootConfig(
52+
baseOptions(),
53+
globalCustomConfig = endpointOverrideJson("global"),
54+
profileCustomConfig = endpointOverrideJson("profile"),
55+
)
56+
).asJsonObject
57+
58+
val matching = config.getAsJsonArray("endpoints")
59+
.map { it.asJsonObject }
60+
.filter { it.get("tag").asString == MAIN_TAG }
61+
assertEquals(1, matching.size)
62+
assertEquals("profile", matching.single().get("name").asString)
63+
assertNotNull(config.getAsJsonObject("route"))
64+
assertEquals(MAIN_TAG, config.getAsJsonObject("route").get("final").asString)
65+
}
66+
67+
private fun baseOptions() = MyOptions().apply {
68+
endpoints = mutableListOf()
69+
route = RouteOptions().apply { final_ = MAIN_TAG }
70+
outbounds = mutableListOf(
71+
buildSingBoxEndpointWireGuardBean(WireGuardBean().apply {
72+
serverAddress = "198.51.100.10"
73+
serverPort = 51820
74+
localAddress = "10.0.0.2/32, fd00::2/128"
75+
privateKey = TEST_PRIVATE_KEY
76+
peerPublicKey = TEST_PUBLIC_KEY
77+
mtu = 1380
78+
}).apply { tag = MAIN_TAG },
79+
Outbound().apply {
80+
type = "direct"
81+
tag = TAG_DIRECT
82+
},
83+
)
84+
}
85+
86+
private fun endpointOverrideJson(name: String): String {
87+
return """{"endpoints":[{"type":"wireguard","tag":"$MAIN_TAG","name":"$name"}]}"""
88+
}
89+
90+
private companion object {
91+
const val MAIN_TAG = "wireguard-main"
92+
const val CUSTOM_TAG = "custom-wireguard"
93+
const val TEST_PRIVATE_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
94+
const val TEST_PUBLIC_KEY = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
95+
const val CUSTOM_ENDPOINT_JSON = """
96+
{
97+
"endpoints": [
98+
{
99+
"type": "wireguard",
100+
"tag": "$CUSTOM_TAG",
101+
"address": ["10.0.1.2/32"],
102+
"private_key": "$TEST_PRIVATE_KEY",
103+
"peers": [
104+
{
105+
"address": "203.0.113.10",
106+
"port": 51820,
107+
"public_key": "$TEST_PUBLIC_KEY",
108+
"allowed_ips": ["0.0.0.0/0", "::/0"]
109+
}
110+
]
111+
}
112+
]
113+
}
114+
"""
115+
}
116+
}

libcore/box_include.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ func nekoboxAndroidOutboundRegistry() *outbound.Registry {
9999
// 官方 sing-box 1.11 起废弃、1.13.0 移除 wireguard outbound(仅保留 endpoint,
100100
// 见下方 nekoboxAndroidEndpointRegistry);镜像官方 include/registry.go 的 stub,
101101
// 让旧式 wireguard outbound 配置得到明确报错而非 "unknown outbound type"。
102-
// TODO: Kotlin 侧 WireGuardFmt 仍生成 outbound 配置,需迁移为 endpoint(见 ROO_KERNEL_TODO 已知降级项)。
103102
outbound.Register[option.StubOptions](registry, C.TypeWireGuard, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.StubOptions) (adapter.Outbound, error) {
104103
return nil, E.New("WireGuard outbound is deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, use WireGuard endpoint instead")
105104
})

libcore/wireguard_config_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package libcore
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/sagernet/sing-box/adapter/service"
9+
"github.com/sagernet/sing-box/box"
10+
"github.com/sagernet/sing-box/option"
11+
)
12+
13+
const wireGuardEndpointConfig = `{
14+
"log": { "disabled": true },
15+
"endpoints": [
16+
{
17+
"type": "wireguard",
18+
"tag": "wireguard-main",
19+
"address": ["10.0.0.2/32", "fd00::2/128"],
20+
"private_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
21+
"peers": [
22+
{
23+
"address": "198.51.100.10",
24+
"port": 51820,
25+
"public_key": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
26+
"allowed_ips": ["0.0.0.0/0", "::/0"]
27+
}
28+
]
29+
}
30+
],
31+
"outbounds": [
32+
{ "type": "direct", "tag": "direct" }
33+
],
34+
"route": { "final": "wireguard-main" }
35+
}`
36+
37+
func checkConfig(config string) (*box.Box, error) {
38+
ctx := box.Context(
39+
context.Background(),
40+
nekoboxAndroidInboundRegistry(),
41+
nekoboxAndroidOutboundRegistry(),
42+
nekoboxAndroidEndpointRegistry(),
43+
nekoboxAndroidDNSTransportRegistry(nil),
44+
nekoboxAndroidServiceRegistry(),
45+
)
46+
ctx = service.ContextWithDefaultRegistry(ctx)
47+
var options option.Options
48+
if err := options.UnmarshalJSONContext(ctx, []byte(config)); err != nil {
49+
return nil, err
50+
}
51+
return box.New(box.Options{Options: options, Context: ctx})
52+
}
53+
54+
func TestWireGuardSingleEndpointConfig(t *testing.T) {
55+
instance, err := checkConfig(wireGuardEndpointConfig)
56+
if err != nil {
57+
t.Fatalf("WireGuard endpoint config check failed: %v", err)
58+
}
59+
if err := instance.Close(); err != nil {
60+
t.Fatalf("close checked instance: %v", err)
61+
}
62+
}
63+
64+
func TestWireGuardLegacyOutboundRejected(t *testing.T) {
65+
const legacyConfig = `{
66+
"outbounds": [
67+
{ "type": "wireguard", "tag": "wireguard-main" }
68+
],
69+
"route": { "final": "wireguard-main" }
70+
}`
71+
instance, err := checkConfig(legacyConfig)
72+
if instance != nil {
73+
_ = instance.Close()
74+
}
75+
if err == nil || !strings.Contains(err.Error(), "WireGuard outbound is deprecated") {
76+
t.Fatalf("expected the legacy WireGuard outbound removal diagnostic, got: %v", err)
77+
}
78+
}

openspec/changes/migrate-wireguard-endpoint/tasks.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
- [x] 1.1 对照 `nb4a.properties` 的 sing-box v1.13.16 官方源码/JSON schema,记录 WireGuard endpoint、peer、根 `endpoints` 和 endpoint registry 的准确字段与类型;同时核对 T4A WireGuardBean 的 Kryo 版本,只有缺失字段时才采用追加字段的兼容升级。
44
- [x] 1.2 在 `SingBoxOptions.java` 增加根 endpoints、Endpoint 基类及 v1.13.16 WireGuard endpoint/peer options,并在 WireGuard 格式模块实现 endpoint builder、可选字段省略和 reserved 三字节列表/base64 兼容转换;保留 legacy outbound options 但移除产品 builder 对它的调用。
55
- [x] 1.3 增加 JVM 单元测试,覆盖完整字段、双栈 allowed_ips、零值/空值省略、reserved 两种表示以及 Bean 旧版本反序列化(如本批修改 Bean);fixture 必须使用无生产价值的测试密钥且失败输出不得泄露完整私钥/PSK。
6-
- [ ] 1.4 提交本批至 GitHub Actions `CI / Build OSS APK`(其依赖 `CI / Native Build (LibCore)`);预期两个 job 均成功且目标 WireGuard 单测通过,回传 Actions run URL、失败/成功测试摘要和 APK 编译成功记录。收到该证据前不得开始第 2 批。
6+
- [x] 1.4 提交本批至 GitHub Actions `CI / Build OSS APK`(其依赖 `CI / Native Build (LibCore)`);预期两个 job 均成功且目标 WireGuard 单测通过,回传 Actions run URL、失败/成功测试摘要和 APK 编译成功记录。收到该证据前不得开始第 2 批。
77

88
## 2. 单节点 endpoint 配置拓扑
99

10-
- [ ] 2.1 在根配置和序列化模型中接入 `endpoints`,实现 endpoint 类型白名单与构建后分区,使一个 WireGuard 主节点进入 endpoints、保留原 tag,并从 outbounds 消失;明确自动配置与用户自定义 endpoints 的 merge 顺序和同 tag 行为。
11-
- [ ] 2.2 增加配置构建测试,断言单 WireGuard 主节点的最终 JSON、主路由 tag、自定义 endpoint 共存结果,以及 legacy WireGuard outbound/stub 不会被产品生成配置触发;加入目标版本可执行的 libcore 配置检查入口或测试。
10+
- [x] 2.1 在根配置和序列化模型中接入 `endpoints`,实现 endpoint 类型白名单与构建后分区,使一个 WireGuard 主节点进入 endpoints、保留原 tag,并从 outbounds 消失;明确自动配置与用户自定义 endpoints 的 merge 顺序和同 tag 行为。
11+
- [x] 2.2 增加配置构建测试,断言单 WireGuard 主节点的最终 JSON、主路由 tag、自定义 endpoint 共存结果,以及 legacy WireGuard outbound/stub 不会被产品生成配置触发;加入目标版本可执行的 libcore 配置检查入口或测试。
1212
- [ ] 2.3 提交本批至 GitHub Actions `CI / Native Build (LibCore)``CI / Build OSS APK`;预期生成的单节点 JSON通过 libcore 配置检查、两个 job 成功且不出现 WireGuard outbound removed 错误,回传 run URL、脱敏生成结构与配置检查日志。收到证据前不得开始第 3 批。
1313

1414
## 3. Selector、URLTest 与链式引用

0 commit comments

Comments
 (0)