mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-09 14:16:07 +00:00
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:
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user