From d2efe9b022a4f9cbd91c78f5d243078199597982 Mon Sep 17 00:00:00 2001 From: Grigoriy Date: Thu, 9 Jul 2026 01:52:46 +0300 Subject: [PATCH] fix(sub): include native WireGuard clients in Clash and JSON subscriptions (#5676) The Clash (buildProxy) and JSON (getConfig) subscription generators had no WireGuard branch, so a native WireGuard inbound's clients were silently dropped: buildProxy hit its default nil case, and getConfig emitted a config with no proxy outbound. Only the raw subscription (genWireguardLink) and external-link Clash path handled WireGuard. Add a WireGuard case to both generators, mirroring genWireguardLink: the peer public key is derived from the inbound secretKey, while the private key, tunnel address (mihomo ip/ipv6, Xray settings.address), pre-shared key and keep-alive come from the client. The peer routes the full tunnel (0.0.0.0/0, ::/0), which both mihomo and Xray also default to. Field names verified against the mihomo WireGuardOption source (private-key, public-key, pre-shared-key, persistent-keepalive, ip, ipv6, mtu, dns) and the Xray wireguard outbound schema (secretKey, address, peers[].publicKey/endpoint/ preSharedKey/keepAlive/allowedIPs, mtu). --- internal/sub/clash_service.go | 65 ++++++++++++++++++++++++ internal/sub/clash_service_test.go | 79 ++++++++++++++++++++++++++++++ internal/sub/json_service.go | 56 +++++++++++++++++++++ internal/sub/json_service_test.go | 75 ++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+) diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index d57bc8210..c77efe420 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -9,6 +9,7 @@ import ( yaml "github.com/goccy/go-yaml" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) type SubClashService struct { @@ -214,6 +215,9 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, if inbound.Protocol == model.Hysteria { return s.buildHysteriaProxy(subReq, inbound, client, ep) } + if inbound.Protocol == model.WireGuard { + return s.buildWireguardProxy(subReq, inbound, client, ep) + } network, _ := stream["network"].(string) @@ -361,6 +365,67 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model. return proxy } +// buildWireguardProxy produces a mihomo-compatible Clash entry for a native +// WireGuard inbound, mirroring genWireguardLink: the peer public key is derived +// from the inbound secretKey, while the private key, tunnel address, and +// pre-shared key come from the client. Returns nil when the client has no key. +func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any { + if client.PrivateKey == "" { + return nil + } + + var inboundSettings map[string]any + _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + secretKey, _ := inboundSettings["secretKey"].(string) + + proxy := map[string]any{ + "name": subReq.endpointRemark(inbound, client.Email, ep, ""), + "type": "wireguard", + "server": inbound.Listen, + "port": inbound.Port, + "udp": true, + "private-key": client.PrivateKey, + } + if secretKey != "" { + if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil { + proxy["public-key"] = pub + } + } + if client.PreSharedKey != "" { + proxy["pre-shared-key"] = client.PreSharedKey + } + if client.KeepAlive > 0 { + proxy["persistent-keepalive"] = client.KeepAlive + } + for _, addr := range client.AllowedIPs { + ip := stripCIDR(addr) + if ip == "" { + continue + } + if strings.Contains(ip, ":") { + proxy["ipv6"] = ip + } else { + proxy["ip"] = ip + } + } + if mtu, ok := inboundSettings["mtu"].(float64); ok && mtu > 0 { + proxy["mtu"] = int(mtu) + } + if dns, _ := inboundSettings["dns"].(string); dns != "" { + servers := make([]string, 0) + for _, server := range strings.Split(dns, ",") { + if server = strings.TrimSpace(server); server != "" { + servers = append(servers, server) + } + } + if len(servers) > 0 { + proxy["dns"] = servers + } + } + + return proxy +} + // buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON // storage into the kebab-case map that Mihomo expects under xhttp-opts. // diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 7301124b4..fb6f626cf 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) func TestEnsureUniqueProxyNames(t *testing.T) { @@ -779,3 +780,81 @@ func TestBuildXhttpClashOpts_NoGRPCHeaderFalsey(t *testing.T) { } }) } + +func TestBuildWireguardProxyForClash(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, _, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.9", + Port: 51820, + Protocol: model.WireGuard, + Remark: "wg", + Settings: `{"secretKey":"` + serverPriv + `","mtu":1420,"dns":"1.1.1.1, 8.8.8.8"}`, + } + client := model.Client{ + Email: "user", + PrivateKey: clientPriv, + PreSharedKey: "psk-value", + KeepAlive: 25, + AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, + } + + proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil) + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid wireguard client") + } + if proxy["type"] != "wireguard" { + t.Fatalf("type = %v, want wireguard", proxy["type"]) + } + if proxy["server"] != "203.0.113.9" { + t.Fatalf("server = %v, want 203.0.113.9", proxy["server"]) + } + if proxy["port"] != 51820 { + t.Fatalf("port = %v, want 51820", proxy["port"]) + } + if proxy["private-key"] != clientPriv { + t.Fatalf("private-key = %v, want %v", proxy["private-key"], clientPriv) + } + if proxy["public-key"] != serverPub { + t.Fatalf("public-key = %v, want %v (derived from inbound secretKey)", proxy["public-key"], serverPub) + } + if proxy["pre-shared-key"] != "psk-value" { + t.Fatalf("pre-shared-key = %v, want psk-value", proxy["pre-shared-key"]) + } + if proxy["persistent-keepalive"] != 25 { + t.Fatalf("persistent-keepalive = %v, want 25", proxy["persistent-keepalive"]) + } + if proxy["ip"] != "10.0.0.2" { + t.Fatalf("ip = %v, want 10.0.0.2", proxy["ip"]) + } + if proxy["ipv6"] != "fd00::2" { + t.Fatalf("ipv6 = %v, want fd00::2", proxy["ipv6"]) + } + if proxy["mtu"] != 1420 { + t.Fatalf("mtu = %v, want 1420", proxy["mtu"]) + } + if proxy["udp"] != true { + t.Fatalf("udp = %v, want true", proxy["udp"]) + } + if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"1.1.1.1", "8.8.8.8"}) { + t.Fatalf("dns = %v, want [1.1.1.1 8.8.8.8]", proxy["dns"]) + } +} + +func TestBuildWireguardProxyForClashNoKey(t *testing.T) { + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`} + client := model.Client{Email: "user"} + + if proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil); proxy != nil { + t.Fatalf("buildProxy = %v, want nil for a keyless wireguard client", proxy) + } +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 7e77c38f6..8cd0425f3 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -10,6 +10,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/util/random" + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) //go:embed default.json @@ -217,6 +218,12 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux))) case "hysteria": newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux))) + case "wireguard": + wgOutbound := s.genWireguard(inbound, client) + if wgOutbound == nil { + continue + } + newOutbounds = append(newOutbounds, wgOutbound) } newOutbounds = append(newOutbounds, s.defaultOutbounds...) @@ -519,6 +526,55 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, return result } +// genWireguard builds an Xray wireguard outbound for a native WireGuard inbound, +// mirroring genWireguardLink: the peer public key is derived from the inbound +// secretKey, the client owns the private key / tunnel address / pre-shared key, +// and the peer routes the full tunnel. Returns nil when the client has no key. +func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Client) json_util.RawMessage { + if client.PrivateKey == "" { + return nil + } + + var inboundSettings map[string]any + _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + secretKey, _ := inboundSettings["secretKey"].(string) + + peer := map[string]any{ + "endpoint": joinHostPort(inbound.Listen, inbound.Port), + "allowedIPs": []string{"0.0.0.0/0", "::/0"}, + } + if secretKey != "" { + if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil { + peer["publicKey"] = pub + } + } + if client.PreSharedKey != "" { + peer["preSharedKey"] = client.PreSharedKey + } + if client.KeepAlive > 0 { + peer["keepAlive"] = client.KeepAlive + } + + settings := map[string]any{ + "secretKey": client.PrivateKey, + "peers": []any{peer}, + } + if len(client.AllowedIPs) > 0 { + settings["address"] = client.AllowedIPs + } + if mtu, ok := inboundSettings["mtu"].(float64); ok && mtu > 0 { + settings["mtu"] = int(mtu) + } + + outbound := map[string]any{ + "protocol": string(inbound.Protocol), + "tag": "proxy", + "settings": settings, + } + result, _ := json.MarshalIndent(outbound, "", " ") + return result +} + func mergeFinalMask(base any, extra map[string]any) map[string]any { merged := map[string]any{} if baseMap, ok := base.(map[string]any); ok { diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index af6340033..de34030d3 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -2,9 +2,11 @@ package sub import ( "encoding/json" + "reflect" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) func hasDirectOutOutbound(svc *SubJsonService) bool { @@ -331,3 +333,76 @@ func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx) } } + +func TestSubJsonServiceWireguard(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, _, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + inbound := &model.Inbound{ + Listen: "203.0.113.9", + Port: 51820, + Protocol: model.WireGuard, + Settings: `{"secretKey":"` + serverPriv + `","mtu":1420}`, + } + client := model.Client{ + Email: "user", + PrivateKey: clientPriv, + PreSharedKey: "psk-value", + KeepAlive: 25, + AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, + } + + raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client) + if raw == nil { + t.Fatal("genWireguard returned nil for a valid wireguard client") + } + settings := outboundSettings(t, raw) + + if settings["secretKey"] != clientPriv { + t.Fatalf("secretKey = %v, want client private key", settings["secretKey"]) + } + address, _ := settings["address"].([]any) + if len(address) != 2 || address[0] != "10.0.0.2/32" || address[1] != "fd00::2/128" { + t.Fatalf("address = %v, want client tunnel addresses", settings["address"]) + } + if settings["mtu"] != float64(1420) { + t.Fatalf("mtu = %v, want 1420", settings["mtu"]) + } + + peers, _ := settings["peers"].([]any) + if len(peers) != 1 { + t.Fatalf("peers len = %d, want 1", len(peers)) + } + peer, _ := peers[0].(map[string]any) + if peer["publicKey"] != serverPub { + t.Fatalf("peer publicKey = %v, want %v (derived from inbound secretKey)", peer["publicKey"], serverPub) + } + if peer["endpoint"] != "203.0.113.9:51820" { + t.Fatalf("peer endpoint = %v, want 203.0.113.9:51820", peer["endpoint"]) + } + if peer["preSharedKey"] != "psk-value" { + t.Fatalf("peer preSharedKey = %v, want psk-value", peer["preSharedKey"]) + } + if peer["keepAlive"] != float64(25) { + t.Fatalf("peer keepAlive = %v, want 25", peer["keepAlive"]) + } + allowed, _ := peer["allowedIPs"].([]any) + if !reflect.DeepEqual(allowed, []any{"0.0.0.0/0", "::/0"}) { + t.Fatalf("peer allowedIPs = %v, want full tunnel", peer["allowedIPs"]) + } +} + +func TestSubJsonServiceWireguardNoKey(t *testing.T) { + inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`} + client := model.Client{Email: "user"} + + if raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client); raw != nil { + t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw) + } +}