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
+42 -11
View File
@@ -510,21 +510,52 @@ func ParseTuic(line string, index int) model.ProxyNode {
func ParseWireGuard(line string, index int) model.ProxyNode {
normalized := strings.Replace(line, "wg://", "wireguard://", 1)
u := parseURL(normalized)
// WireGuard keys are base64-encoded and may contain '+' characters.
// Go's url.Query() treats '+' as space (form-encoding behavior), which corrupts base64 keys.
// Use rawQueryUnescapeForKeys to preserve '+' in key values.
return StripUndefined(map[string]any{
"name": fragmentName(u, fmt.Sprintf("wireguard-%d", index+1)),
"type": "wireguard",
"server": u.Hostname(),
"port": portFromURL(u, 51820),
"ip": firstNonEmpty(paramGet(u, "ip"), paramGet(u, "address")),
"ipv6": paramGet(u, "ipv6"),
"private-key": firstNonEmpty(userInfo(u), paramGet(u, "private-key"), paramGet(u, "privatekey")),
"public-key": firstNonEmpty(paramGet(u, "public-key"), paramGet(u, "publickey"), paramGet(u, "peer-public-key")),
"pre-shared-key": firstNonEmpty(paramGet(u, "pre-shared-key"), paramGet(u, "presharedkey"), paramGet(u, "psk")),
"reserved": paramGet(u, "reserved"),
"udp": true,
"name": fragmentName(u, fmt.Sprintf("wireguard-%d", index+1)),
"type": "wireguard",
"server": u.Hostname(),
"port": portFromURL(u, 51820),
"ip": firstNonEmpty(rawParamGet(u, "ip"), rawParamGet(u, "address")),
"ipv6": rawParamGet(u, "ipv6"),
"private-key": firstNonEmpty(userInfo(u), rawParamGet(u, "private-key"), rawParamGet(u, "privatekey")),
"public-key": firstNonEmpty(rawParamGet(u, "public-key"), rawParamGet(u, "publickey"), rawParamGet(u, "peer-public-key")),
"pre-shared-key": firstNonEmpty(rawParamGet(u, "pre-shared-key"), rawParamGet(u, "presharedkey"), rawParamGet(u, "psk")),
"reserved": rawParamGet(u, "reserved"),
"udp": true,
})
}
// rawParamGet retrieves a query parameter value while preserving '+' characters.
// This is necessary for base64-encoded values (e.g., WireGuard keys) where
// Go's standard url.Query() would incorrectly convert '+' to spaces.
func rawParamGet(u *url.URL, key string) string {
raw := u.RawQuery
if raw == "" {
return ""
}
for _, pair := range strings.Split(raw, "&") {
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
continue
}
k, _ := url.QueryUnescape(kv[0])
if k != key {
continue
}
// Unescape manually, but preserve '+' as literal '+'
val := strings.ReplaceAll(kv[1], "+", "%2B")
v, err := url.QueryUnescape(val)
if err != nil {
return kv[1]
}
return v
}
return ""
}
// BoolParam returns true if value is "1" or "true".
func BoolParam(value string) bool {
return value == "1" || value == "true"