feat: sing-box Clash template support + Docker CI + frontend scaffold

- Clash→sing-box template conversion engine (singbox_template.go)
- Auto-map 40+ Clash rule-providers to sing-box .srs binary rule-sets
- Fix sing-box 1.11+/1.12+ migrations (sniff/block/WireGuard/DNS/rule_set)
- Fix WireGuard URI '+' parsing bug (rawParamGet)
- Add REJECT block outbound for selector group references
- Skip rules referencing rule-sets with no sing-box equivalent
- Verified: sing-box run succeeds with ACL4SSR template (16 rule-sets loaded)
- Add Dockerfile (multi-stage Go build)
- Add GitHub Actions workflow (multi-arch: amd64+arm64, push to ghcr.io)
- Add frontend scaffold (Vue3 + Vite + Tailwind)
This commit is contained in:
2026-07-27 21:56:19 +08:00
parent cec95ff4e8
commit 9ec8c956c1
40 changed files with 5887 additions and 74 deletions
+59 -17
View File
@@ -878,8 +878,8 @@ func TestExtraSingBoxStructure(t *testing.T) {
t.Errorf("expected listen_port=7890, got: %v", inbound["listen_port"])
}
outbounds, _ := parsed["outbounds"].([]any)
if len(outbounds) < 4 {
t.Fatalf("expected at least 4 outbounds (PROXY, AUTO, nodes, DIRECT, REJECT), got %d", len(outbounds))
if len(outbounds) < 3 {
t.Fatalf("expected at least 3 outbounds (PROXY, AUTO, nodes, DIRECT), got %d", len(outbounds))
}
proxyOut, _ := outbounds[0].(map[string]any)
if proxyOut["tag"] != "PROXY" || proxyOut["type"] != "selector" {
@@ -889,16 +889,29 @@ func TestExtraSingBoxStructure(t *testing.T) {
if autoOut["tag"] != "AUTO" || autoOut["type"] != "urltest" {
t.Errorf("expected second outbound AUTO urltest, got: %v", autoOut)
}
// DIRECT and REJECT should be at the end
// DIRECT should be at the end (REJECT is now a route rule action, not a special outbound)
last, _ := outbounds[len(outbounds)-1].(map[string]any)
if last["tag"] != "REJECT" || last["type"] != "block" {
t.Errorf("expected last outbound REJECT block, got: %v", last)
if last["tag"] != "DIRECT" || last["type"] != "direct" {
t.Errorf("expected last outbound DIRECT direct, got: %v", last)
}
secondLast, _ := outbounds[len(outbounds)-2].(map[string]any)
if secondLast["tag"] != "DIRECT" || secondLast["type"] != "direct" {
t.Errorf("expected second-last outbound DIRECT direct, got: %v", secondLast)
// Inbound should NOT have sniff field (migrated to route rule action in sing-box 1.11+)
if _, hasSniff := inbound["sniff"]; hasSniff {
t.Errorf("inbound should not have sniff field (migrated to route rule action)")
}
// Route should have rules with sniff and reject actions
routeMap, _ := parsed["route"].(map[string]any)
rules, _ := routeMap["rules"].([]any)
if len(rules) < 2 {
t.Fatalf("expected at least 2 route rules (sniff, reject), got %d", len(rules))
}
rule0, _ := rules[0].(map[string]any)
if rule0["action"] != "sniff" {
t.Errorf("expected first route rule action=sniff, got: %v", rule0["action"])
}
rule1, _ := rules[1].(map[string]any)
if rule1["action"] != "reject" {
t.Errorf("expected second route rule action=reject, got: %v", rule1["action"])
}
if routeMap == nil || routeMap["final"] != "PROXY" {
t.Errorf("expected route.final=PROXY, got: %v", parsed["route"])
}
@@ -923,7 +936,6 @@ func TestExtraSingBoxOutboundAllTypes(t *testing.T) {
{"tuic", nodeByName("TUIC"), "tuic"},
{"socks5", nodeByName("SOCKS5"), "socks"},
{"http", nodeByName("HTTP"), "http"},
{"wireguard", nodeByName("WG"), "wireguard"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -941,6 +953,31 @@ func TestExtraSingBoxOutboundAllTypes(t *testing.T) {
}
}
func TestExtraSingBoxWireGuardEndpoint(t *testing.T) {
node := nodeByName("WG")
ep := ToSingBoxWireGuardEndpoint(node)
if ep == nil {
t.Fatalf("ToSingBoxWireGuardEndpoint(WG) nil")
}
if ep["type"] != "wireguard" {
t.Errorf("expected type=wireguard, got %v", ep["type"])
}
if ep["tag"] != "WG-Node" {
t.Errorf("expected tag=WG-Node, got %v", ep["tag"])
}
peers, ok := ep["peers"].([]any)
if !ok || len(peers) == 0 {
t.Fatalf("expected peers array, got: %v", ep["peers"])
}
peer, ok := peers[0].(map[string]any)
if !ok {
t.Fatalf("expected peer map, got: %v", peers[0])
}
if peer["public_key"] == nil {
t.Errorf("expected peer.public_key to be set")
}
}
func TestExtraSingBoxUnsupportedType(t *testing.T) {
node := model.ProxyNode{"type": "snell", "name": "x"}
if out := ToSingBoxOutbound(node); out != nil {
@@ -1029,10 +1066,15 @@ func TestExtraSingBoxHTTPTls(t *testing.T) {
func TestExtraSingBoxWireGuardReserved(t *testing.T) {
node := nodeByName("WG")
out := ToSingBoxOutbound(node)
reserved, ok := out["reserved"].([]int)
ep := ToSingBoxWireGuardEndpoint(node)
peers, ok := ep["peers"].([]any)
if !ok || len(peers) == 0 {
t.Fatalf("expected peers array, got: %v", ep["peers"])
}
peer := peers[0].(map[string]any)
reserved, ok := peer["reserved"].([]int)
if !ok {
t.Fatalf("expected reserved []int, got: %v", out["reserved"])
t.Fatalf("expected peer reserved []int, got: %v", peer["reserved"])
}
if !reflect.DeepEqual(reserved, []int{1, 2, 3}) {
t.Errorf("expected reserved=[1,2,3], got: %v", reserved)
@@ -1041,13 +1083,13 @@ func TestExtraSingBoxWireGuardReserved(t *testing.T) {
func TestExtraSingBoxWireGuardLocalAddress(t *testing.T) {
node := nodeByName("WG")
out := ToSingBoxOutbound(node)
la, ok := out["local_address"].([]string)
ep := ToSingBoxWireGuardEndpoint(node)
addr, ok := ep["address"].([]string)
if !ok {
t.Fatalf("expected local_address, got: %v", out["local_address"])
t.Fatalf("expected address, got: %v", ep["address"])
}
if len(la) != 2 {
t.Errorf("expected 2 local addresses (ip+ipv6), got %d", len(la))
if len(addr) != 2 {
t.Errorf("expected 2 addresses (ip+ipv6), got %d", len(addr))
}
}