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).
This commit is contained in:
Grigoriy
2026-07-09 01:52:46 +03:00
committed by GitHub
parent cb5b3a803a
commit d2efe9b022
4 changed files with 275 additions and 0 deletions
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}