From cb5b3a803a223ece2f9642c7a030a06a01ff178a Mon Sep 17 00:00:00 2001 From: Grigoriy Date: Thu, 9 Jul 2026 01:52:03 +0300 Subject: [PATCH] fix(wireguard): build peers in GenXrayInboundConfig so node reconcile keeps clients (#5684) Adding a WireGuard client on the master broke every WireGuard connection on the sub-node until Xray was manually restarted on the node. Adding the same client directly on the node worked. Root cause: the panel stores WireGuard clients under the settings key `clients` (the shape every other protocol uses), but xray-core's wireguard inbound is configured with `peers`. The `clients`->`peers` conversion lived only in the full-config generation path (XrayService.GetXrayConfig), which runs on a full Xray restart. The live gRPC AddInbound path goes through (*Inbound).GenXrayInboundConfig, which passed the WireGuard settings verbatim - with `clients` and no `peers`. Why the master path broke it and the node path did not: - Adding on the node is a single safe operation: AddInboundClient -> AddUser -> AlterInbound{AddUser} -> wireguard.Server.AddUser, which appends one peer via IPC without touching the others. The inbound is local (NodeID == nil), so nothing is marked dirty and no reconcile runs. - Adding on the master does two things: it pushes the client to the node (the same safe hot-add, which succeeds), and it marks the node dirty. The reconcile then pushes panel/api/inbounds/update/:id to the node, whose InboundService.UpdateInbound applies it live via DelInbound + AddInbound (buildRuntimeInboundForAPI -> Local.AddInbound -> GenXrayInboundConfig). That re-adds the wireguard inbound with zero peers, wiping the device and dropping every connected client. A manual restart regenerated the full config, converted clients to peers, and restored them - hence "only a restart fixes it". Fix: convert WireGuard `clients` to `peers` in GenXrayInboundConfig itself, the single chokepoint for every live AddInbound (create, edit, node reconcile). WireguardClientsToPeers always rebuilds `peers` from `clients` (matching GetXrayConfig field for field) and drops the `clients` key. It does not gate on `peers` being absent: the panel seeds every WireGuard inbound with an empty `peers: []` placeholder (frontend inbound-defaults), so a "skip if peers present" guard would match that placeholder and make the conversion never run, leaving the live path emitting zero peers. The conversion stays idempotent by removing `clients`, so a second call - or an inbound with no `clients` - is a no-op, leaving the full-config path unaffected. This also fixes plain WireGuard inbound edits on a standalone panel, which went through the same peerless rebuild. --- internal/database/model/model.go | 79 +++++++++++++++ .../model/model_wireguard_peers_test.go | 97 +++++++++++++++++++ internal/web/service/xray.go | 15 +-- 3 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 internal/database/model/model_wireguard_peers_test.go diff --git a/internal/database/model/model.go b/internal/database/model/model.go index a3b1e3978..ccaf0727d 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -294,6 +294,10 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { if stripped, ok := StripVlessInboundEncryption(settings); ok { settings = stripped } + case WireGuard: + if converted, ok := WireguardClientsToPeers(settings); ok { + settings = converted + } } streamSettings := i.StreamSettings if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok { @@ -344,6 +348,81 @@ func StripVmessClientSecurity(settings string) (string, bool) { return string(out), true } +// WireguardPeerFromClient builds the xray wireguard inbound peer object for one +// WireGuard client. It is the single definition of the peer shape, shared by the +// full-config path (XrayService.GetXrayConfig) and the live AddInbound path +// (WireguardClientsToPeers), so both emit identical peers. The client's +// privateKey is intentionally omitted — it is the client's secret, not part of +// the server-side peer. +func WireguardPeerFromClient(c Client) map[string]any { + peer := map[string]any{"email": c.Email, "level": 0} + if c.PublicKey != "" { + peer["publicKey"] = c.PublicKey + } + if len(c.AllowedIPs) > 0 { + peer["allowedIPs"] = c.AllowedIPs + } + if c.PreSharedKey != "" { + peer["preSharedKey"] = c.PreSharedKey + } + if c.KeepAlive > 0 { + peer["keepAlive"] = c.KeepAlive + } + return peer +} + +// WireguardClientsToPeers rewrites a WireGuard inbound's settings JSON from the +// panel's client representation into the peers array xray-core's wireguard +// inbound expects. The panel stores WireGuard clients under "clients" (the shape +// every other protocol uses); xray is configured with "peers". GetXrayConfig +// already does this conversion when it builds the full config, but the live +// gRPC AddInbound paths (inbound create/edit and node reconcile) go through +// GenXrayInboundConfig directly — without the conversion they re-add the +// wireguard inbound with no peers, dropping every connected client until the +// next full restart. Clients are the source of truth and are always rebuilt +// into peers (matching GetXrayConfig), so the panel's empty "peers" placeholder +// never blocks the conversion. Idempotent: converting removes "clients", so a +// second call is a no-op, as is any inbound that carries no "clients". +func WireguardClientsToPeers(settings string) (string, bool) { + if settings == "" { + return settings, false + } + var parsed map[string]any + if err := json.Unmarshal([]byte(settings), &parsed); err != nil { + return settings, false + } + clients, ok := parsed["clients"].([]any) + if !ok { + return settings, false + } + peers := make([]any, 0, len(clients)) + for _, raw := range clients { + cm, ok := raw.(map[string]any) + if !ok { + continue + } + if enable, ok := cm["enable"].(bool); ok && !enable { + continue + } + encoded, err := json.Marshal(cm) + if err != nil { + continue + } + var c Client + if err := json.Unmarshal(encoded, &c); err != nil { + continue + } + peers = append(peers, WireguardPeerFromClient(c)) + } + delete(parsed, "clients") + parsed["peers"] = peers + out, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return settings, false + } + return string(out), true +} + func StripVlessInboundEncryption(settings string) (string, bool) { if settings == "" { return settings, false diff --git a/internal/database/model/model_wireguard_peers_test.go b/internal/database/model/model_wireguard_peers_test.go new file mode 100644 index 000000000..98b295863 --- /dev/null +++ b/internal/database/model/model_wireguard_peers_test.go @@ -0,0 +1,97 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func wgSettingsParsed(t *testing.T, settings string) map[string]any { + t.Helper() + var parsed map[string]any + if err := json.Unmarshal([]byte(settings), &parsed); err != nil { + t.Fatalf("unmarshal settings: %v", err) + } + return parsed +} + +func TestWireguardClientsToPeers(t *testing.T) { + settings := `{ + "secretKey": "c2VydmVyLXNlY3JldC1rZXktYmFzZTY0LTMyYnl0ZXM=", + "mtu": 1420, + "clients": [ + {"email": "alice", "enable": true, "publicKey": "cHVi", "allowedIPs": ["10.0.0.2/32"], "preSharedKey": "cHNr", "keepAlive": 25}, + {"email": "bob", "enable": false, "publicKey": "cHViMg==", "allowedIPs": ["10.0.0.3/32"]} + ] + }` + + out, ok := WireguardClientsToPeers(settings) + if !ok { + t.Fatal("WireguardClientsToPeers returned ok=false, want true") + } + parsed := wgSettingsParsed(t, out) + + if _, has := parsed["clients"]; has { + t.Error("clients key must be removed after conversion") + } + if parsed["secretKey"] != "c2VydmVyLXNlY3JldC1rZXktYmFzZTY0LTMyYnl0ZXM=" { + t.Errorf("secretKey not preserved: %v", parsed["secretKey"]) + } + + peers, ok := parsed["peers"].([]any) + if !ok { + t.Fatalf("peers not an array: %T", parsed["peers"]) + } + if len(peers) != 1 { + t.Fatalf("peers length = %d, want 1 (disabled client must be skipped)", len(peers)) + } + + peer := peers[0].(map[string]any) + if peer["publicKey"] != "cHVi" { + t.Errorf("peer publicKey = %v, want cHVi", peer["publicKey"]) + } + if peer["preSharedKey"] != "cHNr" { + t.Errorf("peer preSharedKey = %v, want cHNr", peer["preSharedKey"]) + } + if peer["keepAlive"].(float64) != 25 { + t.Errorf("peer keepAlive = %v, want 25", peer["keepAlive"]) + } + ips, ok := peer["allowedIPs"].([]any) + if !ok || len(ips) != 1 || ips[0] != "10.0.0.2/32" { + t.Errorf("peer allowedIPs = %v, want [10.0.0.2/32]", peer["allowedIPs"]) + } +} + +func TestWireguardClientsToPeersIdempotent(t *testing.T) { + withPeers := `{"secretKey": "k", "peers": [{"publicKey": "cHVi"}]}` + if out, ok := WireguardClientsToPeers(withPeers); ok || out != withPeers { + t.Errorf("settings with peers must be a no-op: ok=%v out=%q", ok, out) + } + + noClients := `{"secretKey": "k", "mtu": 1420}` + if out, ok := WireguardClientsToPeers(noClients); ok || out != noClients { + t.Errorf("settings without clients must be a no-op: ok=%v out=%q", ok, out) + } +} + +func TestGenXrayInboundConfigWireguardConvertsPeers(t *testing.T) { + ib := &Inbound{ + Protocol: WireGuard, + Port: 51820, + Tag: "wg-in", + Settings: `{"secretKey": "k", "peers": [], "clients": [{"email": "alice", "enable": true, "publicKey": "cHVi", "allowedIPs": ["10.0.0.2/32"]}]}`, + } + + cfg := ib.GenXrayInboundConfig() + parsed := wgSettingsParsed(t, string(cfg.Settings)) + + if _, has := parsed["clients"]; has { + t.Error("GenXrayInboundConfig left clients in a wireguard inbound") + } + peers, ok := parsed["peers"].([]any) + if !ok || len(peers) != 1 { + t.Fatalf("GenXrayInboundConfig did not emit peers: %v", parsed["peers"]) + } + if peers[0].(map[string]any)["publicKey"] != "cHVi" { + t.Errorf("peer publicKey = %v, want cHVi", peers[0].(map[string]any)["publicKey"]) + } +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index afff99176..bdb4c7ce3 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -206,20 +206,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { entry["auth"] = c.Auth } case model.WireGuard: - peer := map[string]any{"email": c.Email, "level": 0} - if c.PublicKey != "" { - peer["publicKey"] = c.PublicKey - } - if len(c.AllowedIPs) > 0 { - peer["allowedIPs"] = c.AllowedIPs - } - if c.PreSharedKey != "" { - peer["preSharedKey"] = c.PreSharedKey - } - if c.KeepAlive > 0 { - peer["keepAlive"] = c.KeepAlive - } - wgPeers = append(wgPeers, peer) + wgPeers = append(wgPeers, model.WireguardPeerFromClient(c)) continue } finalClients = append(finalClients, entry)