mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-13 23:01:00 +00:00
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.
This commit is contained in:
@@ -294,6 +294,10 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
|||||||
if stripped, ok := StripVlessInboundEncryption(settings); ok {
|
if stripped, ok := StripVlessInboundEncryption(settings); ok {
|
||||||
settings = stripped
|
settings = stripped
|
||||||
}
|
}
|
||||||
|
case WireGuard:
|
||||||
|
if converted, ok := WireguardClientsToPeers(settings); ok {
|
||||||
|
settings = converted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
streamSettings := i.StreamSettings
|
streamSettings := i.StreamSettings
|
||||||
if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
|
if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
|
||||||
@@ -344,6 +348,81 @@ func StripVmessClientSecurity(settings string) (string, bool) {
|
|||||||
return string(out), true
|
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) {
|
func StripVlessInboundEncryption(settings string) (string, bool) {
|
||||||
if settings == "" {
|
if settings == "" {
|
||||||
return settings, false
|
return settings, false
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,20 +206,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
entry["auth"] = c.Auth
|
entry["auth"] = c.Auth
|
||||||
}
|
}
|
||||||
case model.WireGuard:
|
case model.WireGuard:
|
||||||
peer := map[string]any{"email": c.Email, "level": 0}
|
wgPeers = append(wgPeers, model.WireguardPeerFromClient(c))
|
||||||
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)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
finalClients = append(finalClients, entry)
|
finalClients = append(finalClients, entry)
|
||||||
|
|||||||
Reference in New Issue
Block a user