Skip to content

fix: preserve per-inbound WireGuard peer addresses - #6344

Open
mvanhorn wants to merge 1 commit into
MHSanaei:mainfrom
mvanhorn:fix/6328-preserve-wireguard-peer-addresses
Open

fix: preserve per-inbound WireGuard peer addresses#6344
mvanhorn wants to merge 1 commit into
MHSanaei:mainfrom
mvanhorn:fix/6328-preserve-wireguard-peer-addresses

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

Summary

WireGuard peers were built from the shared per-email client record, so a client present on both a WireGuard and an AmneziaWG inbound got one tunnel's allowedIPs and preSharedKey on both. This reads the per-inbound client settings and uses the inbound's own entry when one exists for that email.

Why

Closes #6328

Clients are stored once per email in the client table. GetXrayConfig built each WireGuard peer from that shared record, so the per-inbound values were lost whenever the same email appeared on two WireGuard-family inbounds. The second tunnel's peer was then emitted with the first tunnel's address, which breaks routing for that client.

The inbound already carries its own client settings; they were simply not consulted on the WireGuard path.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Documentation
  • Tests only
  • Build / CI / tooling
  • Other

Areas affected

  • Frontend (UI / panel pages)
  • Backend (API endpoints, login, settings)
  • Xray config generation
  • Subscription (share links / Clash / JSON)
  • Statistics / traffic counters
  • Database / migrations
  • Install / upgrade script
  • Docker image
  • Multi-node (sub-nodes)
  • Telegram bot

How was this tested?

Added TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields, which seeds one email on both a WG inbound (10.0.0.5/32, wg-psk) and an AWG inbound (10.8.1.5/32, awg-psk) and asserts each inbound's peer keeps its own address and pre-shared key.

$ go build ./...
$ go test ./internal/web/service/... -run 'WireGuard|Wireguard|WG'
ok  	github.com/mhsanaei/3x-ui/v3/internal/web/service	2.430s
ok  	github.com/mhsanaei/3x-ui/v3/internal/web/service/integration	2.198s

The test pins the bug rather than merely covering the path. Reverting only internal/web/service/xray.go and keeping the test:

--- FAIL: TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields
    xray_wireguard_config_test.go:210: WireGuard peer allowedIPs = [10.8.1.5/32], want [10.0.0.5/32]

That is the reported symptom exactly: the AWG tunnel's address on the WG peer.

Not tested: no run against a live Xray instance with two active tunnels. The assertion is on generated config, not on observed traffic.

Screenshots / recordings

N/A. Backend config generation, no UI change.

Breaking changes

None. Behaviour only changes where an inbound carries its own client entry for that email; when it does not, the existing shared-record values are used exactly as before.

Checklist

  • I tested the change locally and confirmed the described behavior.
  • I added or updated tests for the new behavior (when applicable).
  • go build ./... and the test suite pass locally.
  • For frontend changes: npm run lint, npm run typecheck, and npm run build pass.
  • I updated the Wiki / README / API docs if user-facing behavior changed.
  • My commits follow the project's existing message style.
  • I have no unrelated changes mixed into this PR.

Frontend checks are unchecked because there are no frontend changes. Docs are unchecked because the corrected behaviour is what the panel already documents; nothing user-facing changed.

AI assistance disclosure

  • Type of assistance: drafting the code change, the test, and this description.
  • Scope: internal/web/service/xray.go and internal/web/service/xray_wireguard_config_test.go only.
  • Tools: OpenAI Codex for the implementation, Anthropic Claude for review and this write-up.
  • Level of modification: the build, the test run, and the revert-the-fix check quoted above were run locally and their real output is reproduced here.

AI was used for assistance.

Clients are stored once per email in the client table, so when the same email
exists on more than one WireGuard inbound the shared record's AllowedIPs and
PreSharedKey win for every inbound. A client present on both a WG and an AWG
tunnel was emitted with one tunnel's address on both, so the second tunnel's
peer got the wrong allowedIPs.

Read the per-inbound client settings for WireGuard inbounds and, when the
inbound carries its own entry for that email, use its AllowedIPs and
PreSharedKey when building the peer.
Comment on lines +257 to +260
if inboundClient, ok := wireguardClientsByEmail[strings.ToLower(strings.TrimSpace(c.Email))]; ok {
c.AllowedIPs = inboundClient.AllowedIPs
c.PreSharedKey = inboundClient.PreSharedKey
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Important — the emitted peer now disagrees with the config the subscription server hands the client.

This override moves the server peer's allowedIPs/preSharedKey to the inbound's settings JSON, but the client-facing half of the same pair still comes from the shared clients row:

  • matchingClients primes the per-request cache from GetClientsBySubIdListForInboundBySubId, a plain Table("clients") join —
    func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
    clients, err := s.inboundService.GetClientsBySubId(inbound.Id, subId)
    if err != nil {
    logger.Error("SubService - GetClientsBySubId: Unable to get clients from inbound")
    return nil
    }
    var out []model.Client
    seen := make(map[string]struct{}, len(clients))
    for _, client := range clients {
    key := strings.ToLower(client.Email)
    if _, dup := seen[key]; dup {
    continue
    }
    seen[key] = struct{}{}
    out = append(out, client)
    }
    s.primeLinkClients(inbound.Id, out, false)
    return out
  • clientForLink returns that primed row and never reaches the settings-JSON fallback below it —
    // the settings JSON once and caching every client from it.
    func (s *SubService) clientForLink(inbound *model.Inbound, email string) (model.Client, bool) {
    if m, ok := s.clientsByInbound[inbound.Id]; ok {
    if c, hit := m[email]; hit {
    return c, true
    }
    if s.fullyPrimedInbounds[inbound.Id] {
    return model.Client{}, false
    }
    }
    clients, err := s.inboundService.GetClients(inbound)
    if err != nil {
    return model.Client{}, false
    }
    s.primeLinkClients(inbound.Id, clients, true)
    for i := range clients {
  • genWireguardLink emits address and presharedkey straight off it —
    }
    }
    if joined := strings.Join(client.AllowedIPs, ","); joined != "" {
    params["address"] = joined
    }
    if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
    params["mtu"] = strconv.Itoa(int(mtu))
    }
    if dns, ok := settings["dns"].(string); ok && dns != "" {
    params["dns"] = dns
    }
    if client.PreSharedKey != "" {
    params["presharedkey"] = client.PreSharedKey
    }
    if client.KeepAlive > 0 {
    (json_service.go and clash_service.go receive the same client object)

Before this change both sides read that one row, so they matched by construction. The override only does anything when the row and the settings entry differ — i.e. exactly the #6328 topology — so every behaviour change this PR makes desynchronizes the subscription-issued config.

Concretely, dual@x on WG inbound A (10.0.0.5/32, wg-psk) and AWG inbound B (10.8.1.5/32, awg-psk), B synced last so the shared row holds B's values:

To be fair to the change: the panel's own link export goes the other way and is improved by it — LinksForClient builds a fresh SubService and calls GetLink with an unprimed cache (

func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
svc := p.build(host)
svc.projectThroughFallbackMaster(inbound)
return splitLinkLines(svc.GetLink(inbound, email))
}
), so clientForLink falls through to GetClients(inbound) and already read the settings JSON. So this is a channel swap, not a pure loss. But subscription is the channel most deployments distribute through, so it is the wrong half to break.

Closing it properly means resolving AllowedIPs/PreSharedKey per inbound on the subscription path too — e.g. having matchingClients/clientForLink overlay the inbound's own settings entry for wireguard/amneziawg clients — so all three subscription formats agree with the peer this function emits.

c.AllowedIPs = inboundClient.AllowedIPs
c.PreSharedKey = inboundClient.PreSharedKey
}
wgPeers = append(wgPeers, model.WireguardPeerFromClient(c))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing — this predates the change (nothing outside xray.go moved), so it is not a reason to hold the PR, but it sits inside the block just added and is the same #6328 failure in a harsher form.

WireguardPeerFromClient builds the peer from four fields — PublicKey, AllowedIPs, PreSharedKey, KeepAlive

// 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
}

All four come off the same shared per-email row (ClientRecord.Email is uniqueIndex), and the override above restores two of them. c.PublicKey — the field that actually identifies the peer to xray — is still whichever inbound wrote the row last, and a wrong publicKey means no handshake at all rather than a mis-routed one.

It is reachable through the panel, not just the API: ClientBulkAddModal sends no privateKey/publicKey while allowing several inboundIds, BulkCreate calls AddInboundClient once per inbound with that key-less client, each WG/AWG inbound then mints its own keypair (

for i := range clients {
c := &clients[i]
if c.PrivateKey == "" && c.PublicKey == "" {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
return err
}
c.PrivateKey = priv
c.PublicKey = pub
and the identical block in client_amneziawg.go), and applyClientRecordMerge keeps the last non-empty one —
}
if incoming.PublicKey != "" {
row.PublicKey = incoming.PublicKey
}
if incoming.AllowedIPs != "" {
row.AllowedIPs = incoming.AllowedIPs
}
row.PreSharedKey = incoming.PreSharedKey
row.KeepAlive = incoming.KeepAlive

Extending the override to PublicKey (and KeepAlive) would close it in the same two lines, though only once the subscription-path divergence flagged above is resolved — otherwise it widens that mismatch to the key material as well.

Comment on lines +217 to +224
func TestGetXrayConfigWireGuardDisabledDualProtocolClientExcluded(t *testing.T) {
seedDualTunnelClient(t, false)

peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
if len(peers) != 0 {
t.Fatalf("expected disabled dual-protocol client to be excluded, got %v", peers)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — this test passes with and without the fix, which CLAUDE.md rejects outright:

A test must fail without its fix. Write it, revert the fix, watch it go red, restore. A test that passes either way is worse than no test: it certifies nothing and then gets cited as proof the fix works.

seedDualTunnelClient(t, false) syncs the AWG inbound last with Enable: false, and applyClientRecordMerge assigns row.Enable = incoming.Enable unconditionally (

row.PreSharedKey = incoming.PreSharedKey
row.KeepAlive = incoming.KeepAlive
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
row.ExpiryTime = incoming.ExpiryTime
row.Enable = incoming.Enable
), so the shared row ends up disabled. GetXrayConfig then drops the client at if !c.Enable { continue }
}
if !c.Enable {
continue
}
— which runs before the switch ever reaches case model.WireGuard:. The new override never touches Enable or enableMap, so len(peers) == 0 holds identically with the xray.go hunk reverted.

It is also redundant with the pre-existing TestGetXrayConfigWireGuardDisabledClientExcluded, which already pins that same guard without the dual-protocol setup. Worth deleting, or repurposing to assert something the override could actually break.

(TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields above it is sound — it does go red on revert, as the description claims.)

@github-actions

Copy link
Copy Markdown
Contributor

Code review

1 🔴 / 1 🟡 / 1 🟣

Reviewed head: b455706ad2a1a7469f435a30b75f253cb62526df

  • 🔴 internal/web/service/xray.go:257-260 — the override changes the server peer's allowedIPs/preSharedKey but the subscription server still emits address/presharedkey from the shared clients row. Before the change both sides read that one row and matched by construction; the override only does anything when they differ, so every behaviour change this PR makes desynchronizes the subscription-issued config. In the [Bug]: AmneziaWG together with WireGuard for the same client invokes WG doesn't work #6328 topology a tunnel that connected (with the wrong address) now fails the handshake outright. The panel's own link export goes the other way and is improved by the change, so it is a channel swap — but subscription is the half most deployments distribute through.
  • 🟡 internal/web/service/xray_wireguard_config_test.go:217-224TestGetXrayConfigWireGuardDisabledDualProtocolClientExcluded is green with the xray.go hunk reverted: the client is dropped by the pre-existing if !c.Enable guard before the switch reaches the WireGuard case. CLAUDE.md rejects a test that passes either way. The other new test is sound and does go red on revert, as the description claims.
  • 🟣 internal/web/service/xray.go:261WireguardPeerFromClient reads four fields off the shared row; the fix restores two. publicKey is still last-writer-wins, which breaks the handshake rather than the routing. Predates this PR, so not a reason to hold it, but it sits inside the block the PR just added.

Coverage

  • Diff — 2 files, +100/−0, both in internal/web/service/; read in full at the head SHA.
  • Xray config emission — traced GetXrayConfig's WireGuard branch and WireguardPeerFromClient; confirmed AmneziaWG inbounds continue before the loop body, so the change is correctly scoped to plain WireGuard. strings import present, email key normalization symmetric on build and lookup, nil-map/ignored-error path degrades to prior behaviour.
  • Wire-format consistency — compared the emitted peer against all three subscription formats (internal/sub/service.go, json_service.go, clash_service.go) and the panel link path (internal/sub/links.go); this is the 🔴.
  • Staleness check — traced every writer of wg_allowed_ips/wg_pre_shared_key; SyncInbound/ApplyInboundClientDelta callers all save inbound.Settings in the same transaction, so settings really is authoritative and the change does not make the emitted config stale.
  • Layering / migrations / endpoints — read-only config generation, no runtime.Runtime dispatch involved; no model, db.go, route, endpoints.ts or locale change, so none of those chains apply.
  • CI on this head — all 20 checks green, including go-test, race, golangci, postgres-durable-first, codegen, frontend, fuzz-smoke and govulncheck.
  • Not verified — no live WireGuard handshake was run; the 🔴 failure mode is derived from the emitted config and the subscription output, not observed on the wire. This environment cannot build or execute the PR code.

kuzzrus added a commit to kuzzrus/3x-ui-awg that referenced this pull request Aug 30, 2026
GetXrayConfig built WireGuard peers from ListForInbound, which returns the
shared clients row. That row has a single wg_allowed_ips / wg_pre_shared_key
column, so a client attached to two WireGuard inbounds — a case this fork
supports through AllowedIPsByInbound and shows per inbound via
TunnelAllowedIPsByInbound — had both peers emitted with whichever address was
written last.

The per-inbound value lives in each inbound's own settings JSON, which is also
what UpdateInbound feeds back into SyncInbound, so read the peer's address and
preshared key from there.

The live gRPC AddInbound path was already correct: it converts from the
settings JSON via model.WireguardClientsToPeers. Only the full-config path was
wrong, which made the symptom "works after saving, breaks after a restart".

Same bug as upstream MHSanaei#6344, found by reviewing it against our
tree; the mechanics differ because our clients are normalized.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: AmneziaWG together with WireGuard for the same client invokes WG doesn't work

1 participant