Files
3x-ui/internal/database/model/model_wireguard_peers_test.go
T
Grigoriy cb5b3a803a 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.
2026-07-09 00:52:03 +02:00

98 lines
3.0 KiB
Go

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"])
}
}