Просмотр исходного кода

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).
Grigoriy 22 часов назад
Родитель
Сommit
d2efe9b022

+ 65 - 0
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.
 //

+ 79 - 0
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)
+	}
+}

+ 56 - 0
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 {

+ 75 - 0
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)
+	}
+}