Skip to content

fix(clients): render all tunnel configs for multi-inbound client (#6346) - #6349

Open
TheMRVX wants to merge 1 commit into
MHSanaei:mainfrom
TheMRVX:fix/multi-tunnel-client-configs
Open

fix(clients): render all tunnel configs for multi-inbound client (#6346)#6349
TheMRVX wants to merge 1 commit into
MHSanaei:mainfrom
TheMRVX:fix/multi-tunnel-client-configs

Conversation

@TheMRVX

@TheMRVX TheMRVX commented Aug 28, 2026

Copy link
Copy Markdown

Summary

Fixes #6346.

When a client is attached to multiple AmneziaWG or WireGuard inbounds (such as across remote DE and FI nodes), ClientInfoModal and ClientQrModal previously only returned the first matching inbound. Subsequent inbounds' configurations, node-specific endpoints, and QR codes were unreachable.

Type of change

  • Bug fix

Areas affected

  • Frontend (UI / panel pages)

Changes

  • Multi-inbound selectors: Added findAmneziaWGInbounds and findWireguardInbounds returning all matching inbounds.
  • Unified meta helper: Added formatTunnelConfigMeta in label.ts to unify label, fileName, and qrRemark derivation across modals with collision-free naming.
  • AllowedIPs override: Added addressOverride support to buildWireguardClientConfig matching buildAmneziaWGClientConfig.
  • Client Modals: Rendered separate labeled config blocks in ClientInfoModal and distinct QR panels in ClientQrModal for every attached tunnel inbound.
  • Component Tests: Added multi-tunnel-client-config.test.tsx testing ClientInfoModal and ClientQrModal rendering and verifying distinct endpoints and keys.

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.

Comment on lines +111 to +126
it('findAmneziaWGInbounds returns all matching inbounds in order', () => {
const awgInbounds = findAmneziaWGInbounds(multiClient, inboundsById);
expect(awgInbounds).toHaveLength(2);
expect(awgInbounds[0]?.id).toBe(101);
expect(awgInbounds[1]?.id).toBe(102);
expect(findAmneziaWGInbound(multiClient, inboundsById)?.id).toBe(101);
});

it('findWireguardInbounds returns all matching inbounds in order', () => {
const wgInbounds = findWireguardInbounds(multiClient, inboundsById);
expect(wgInbounds).toHaveLength(2);
expect(wgInbounds[0]?.id).toBe(201);
expect(wgInbounds[1]?.id).toBe(202);
expect(findWireguardInbound(multiClient, inboundsById)?.id).toBe(201);
});

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.

🟡 The new test does not cover the defect this PR fixes.

The bug in #6346 is in the render loops — the modals only emitted the first matching tunnel inbound. That logic now lives at ClientInfoModal.tsx#L794-L818 and ClientQrModal.tsx#L188-L217, but this file imports neither modal — it only calls the four pure helpers directly. Revert both map(...) loops back to rendering index [0] and every assertion here still passes.

REVIEW.md: "A bug fix carries a test that would fail without the fix. A test that cannot tell the broken behaviour from the fixed one passes before and after, so it certifies nothing and is itself the finding."

The two findXInbounds cases (L111-L125) are additionally the kind CLAUDE.md rejects outright: "No test for a getter, a constant, a rename, a pure map lookup"findWireguardInbounds is a .map().filter() over a record.

The repo already renders modals under test (frontend/src/test/client-form-modal.test.tsx), so a .test.tsx asserting "two ConfigBlocks for a client attached to two AmneziaWG inbounds" would drive the actual broken path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated in caef652: replaced the unit tests with multi-tunnel-client-config.test.tsx which directly renders ClientInfoModal and ClientQrModal under React Testing Library, verifying multiple distinct ConfigBlocks and QR panels are rendered for multi-inbound clients.

Comment on lines 25 to 30
export function findWireguardInbound(
client: ClientRecord | null | undefined,
inboundsById: Record<number, InboundOption>,
): InboundOption | undefined {
return findWireguardInbounds(client, inboundsById)[0];
}

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.

🟡 Dead production code: this wrapper (and its AmneziaWG twin) now has no caller outside the new test.

Both modals were fully migrated to the plural finders, so a grep over frontend/src returns only three hits for findWireguardInbound: this definition, the test import, and multi-tunnel-client-config.test.ts#L123-L125. Same for findAmneziaWGInbound at amneziawgConfig.ts#L29-L35.

CLAUDE.md: "A small bug does not earn new columns, jobs, abstractions, config knobs or helper layers." These are helper layers kept alive purely so the test has something to assert on.

Suggested: delete both singular wrappers plus the two assertions at multi-tunnel-client-config.test.ts:116 and :124 and their imports. (No suggestion block — the fix spans three files.)

Note amneziawgConfig.ts:5-9 still documents findAmneziaWGInbound's filter as the thing that disambiguates the two protocols; if the wrapper goes, that comment should name findAmneziaWGInbounds.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Resolved in caef652: deleted findWireguardInbound and findAmneziaWGInbound and updated references to the plural finders.

Comment thread frontend/vite.config.js Outdated

const outDir = path.resolve(import.meta.dirname, '../internal/web/dist');
const BACKEND_TARGET = 'http://localhost:2053';
const BACKEND_TARGET = process.env.BACKEND_TARGET || 'http://localhost:2053';

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.

🟡 Unrelated to the fix, and the new knob is only half-wired.

BACKEND_TARGET feeds the three HTTP proxy entries but the WebSocket entry right below still hardcodes its target:

proxy: {
'^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
'^/$': makeBackendProxy(BACKEND_TARGET),
'^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
'^/(?:[^/]+/)?ws$': {
target: 'ws://localhost:2053',
ws: true,
changeOrigin: true,
rewrite: rewriteToBackend,
},

So BACKEND_TARGET=http://otherhost:2053 npm run dev proxies the panel to the remote host while /ws silently stays on localhost — a dev session with a dead live-update socket and no error to explain it.

It only appears under server.proxy, never in build, so nothing reaches the shipped bundle. But it is scope creep on a client-config fix: CLAUDE.md"Diff is focused; refactors are separate from feature work."

Either drop this hunk from the PR, or route the ws target through the same variable in the same change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Resolved in caef652: reverted vite.config.js back to upstream main to eliminate unrelated scope changes.

Comment on lines +190 to +191
const suffix = wgConfigs.length > 1 ? `-${inbound.remark || inbound.tag || inbound.id}` : '';
const safeSuffix = suffix.replace(/[^\w.-]/g, '_');

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.

🟡 The filename suffix prefers remark, which carries no uniqueness constraint — so the "distinct download filenames" goal is not actually met.

Inbound.Remark is plain free text with no unique index (model.go#L52-L54). Two AmneziaWG inbounds both remarked DE give both panels downloadName="user-DE.conf"; the operator downloads two files the browser disambiguates as user-DE.conf / user-DE (1).conf, with nothing saying which node each belongs to. inbound.id is unique by construction and is already what the Collapse keys use one line below.

Same expression at ClientQrModal.tsx#L220, ClientInfoModal.tsx#L801 and #L831 — appending -${inbound.id} (or falling back to it) at all four sites makes the name collision-free. No suggestion block since the change spans four locations in two files.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Resolved in caef652: unified filename generation with inbound.id fallback in formatTunnelConfigMeta to guarantee unique filenames.

Comment thread frontend/src/pages/clients/ClientInfoModal.tsx Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

No blocking issues — 0 🔴 / 5 🟡 / 1 🟣

The core change is sound. I traced the WireGuard addressOverride end to end: tunnelAllowedIPs[id] comes from ClientService.TunnelAllowedIPsByInbound (internal/web/service/client_lookup.go:144-168), which reads that inbound's own AllowedIPs and is populated for both wireguard and amneziawg. Since the precedence is addressOverride || client.allowedIPs || default, single-inbound output is unchanged and multi-inbound output now matches the server-side peer address instead of the one shared wg_allowed_ips column. No wire-format regression, and the AmneziaWG emitter is untouched so the Go/TS .conf parity fixtures still hold.

The five 🟡 nits are posted inline. In short: the new test does not exercise the modal render loops that actually held the bug; findWireguardInbound/findAmneziaWGInbound are now dead outside that test; vite.config.js's BACKEND_TARGET is scope creep and leaves the /ws proxy target hardcoded; the download-filename suffix is keyed on the non-unique remark; and the label/suffix derivation is copy-pasted four times.

🟣 Pre-existing — buildWireguardClientConfig has no newline guard, unlike its AmneziaWG twin

remark is assembled from the inbound remark, client email and client.comment and written unescaped as # ${remark}; client.privateKey lands unescaped too.

const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
const lines = [
'[Interface]',
`PrivateKey = ${client.privateKey || client.password || ''}`,
`Address = ${address}`,

The AmneziaWG builder refuses exactly this (amneziawgConfig.ts:65-70 returns '' on /[\r\n]/), so does the Go emitter (internal/sub/service.go:703-707), and the repo's own test states the rule — "every emitter must refuse to render it" (frontend/src/test/amneziawg-conf-injection.test.ts:8-9). A client.comment containing \nPostUp = ... therefore injects a root-executed line into a .conf imported via wg-quick. This predates the PR — base emitted the same unguarded text for the first inbound — and it needs a panel admin to set the field, so it is not a public-surface hole. Worth noting because the PR touches this function's signature and now emits its output once per attached inbound, and because the new .filter((c) => !!c.text) implies a guard that only the AmneziaWG side actually has.

Coverage — head 40bd0d7, 6 files, +392/-94

  • WireGuard addressOverride precedence — traced through client_lookup.go:144-168, schemas/client.ts:183-193 and ClientFormModal.tsx:345-347; correct, and the fallback keeps single-inbound .conf output byte-identical.
  • Compile/type surfaceformatInboundLabel already imported at ClientInfoModal.tsx:13, newly at ClientQrModal.tsx:9; tagColor is a real optional prop (ConfigBlock.tsx:16); addressOverride appended last with a default and both call sites updated, so no arity break.
  • React correctnessuseMemo dep lists cover every reactive value read; mapped element keys and Collapse panel keys use inbound.id; firstKey/syncedFirstKey compares a string, so no render loop. .filter((c) => !!c.text) is dead for WireGuard: that builder can never return ''.
  • Filename / key collisions — a client with exactly one WG and one AWG inbound still gets two files named <email>.conf, but that is identical on base, so not a regression; only the same-remark case is new (inline nit).
  • New string handling — labels and remarks land in JSX text nodes (React-escaped) and .replace(/[^\w.-]/g, '_') strips / and control chars from the suffix; no new injection vector.
  • Not verified — CI never ran on this head. The check-suites for 40bd0d7 are action_required (fork PR awaiting maintainer approval); only mention, resolve-conflicts and issue-analyst appear, all skipped. So frontend (vitest/oxlint/typecheck), go-test, race and build have no result on this commit, and the PR description's "all tests passed / 0 errors" is unconfirmed here. This environment must not build or execute PR code, so every claim above is from reading, not running.

…anaei#6346)

When a client belongs to multiple AmneziaWG or WireGuard inbounds (e.g. across
remote nodes), findAmneziaWGInbounds and findWireguardInbounds only returned the
first matching inbound. Consequently, ClientInfoModal and ClientQrModal rendered
only one config block, making other inbounds' configs unreachable.

- Add findAmneziaWGInbounds and findWireguardInbounds returning all matching inbounds
- Add formatTunnelConfigMeta helper to unify label, fileName, and qrRemark resolution
- Support addressOverride in buildWireguardClientConfig from tunnelAllowedIPs
- Render all tunnel configs in ClientInfoModal and ClientQrModal with node remarks
- Distinguish download filenames with inbound remark suffix to avoid collisions
- Add component integration tests covering multi-inbound modal rendering
@TheMRVX
TheMRVX force-pushed the fix/multi-tunnel-client-configs branch from 40bd0d7 to caef652 Compare August 28, 2026 15:44
@TheMRVX

TheMRVX commented Aug 28, 2026

Copy link
Copy Markdown
Author

All review items have been addressed in commit caef652:

  1. Modal Component Tests: Replaced helper tests with multi-tunnel-client-config.test.tsx which renders ClientInfoModal and ClientQrModal under React Testing Library, asserting multiple distinct ConfigBlocks and QR panels are rendered for multi-inbound clients.
  2. Dead Code Cleanup: Deleted singular wrappers findWireguardInbound and findAmneziaWGInbound.
  3. Focused Diff: Reverted vite.config.js to upstream main.
  4. Unified Metadata & Unique Filenames: Added formatTunnelConfigMeta in label.ts with inbound.id fallback to deduplicate config metadata resolution across both modals and ensure collision-free .conf filenames.

All tests, linters, and format checks pass cleanly. Ready for re-review!

@TheMRVX
TheMRVX marked this pull request as draft August 28, 2026 15:56
@TheMRVX
TheMRVX marked this pull request as ready for review August 28, 2026 15:56
@github-actions

Copy link
Copy Markdown
Contributor

Code review

No blocking issues — 0 🔴

Round two. Round one ran against 40bd0d7; the branch was force-pushed to caef652 since, so I re-read the whole diff. Per this repo's review rules a follow-up round reports 🔴 only, so the five 🟡 nits from round one are not re-litigated.

Round-one items I confirmed closed on this head: the singular findWireguardInbound / findAmneziaWGInbound are gone with zero remaining references anywhere in frontend/src; vite.config.js is no longer in the diff; and the helper tests were replaced with multi-tunnel-client-config.test.tsx, which renders both modals for real and does go red on base — base's .find() surfaces one inbound and base's ConfigBlock label is the static t('pages.clients.config'), so getAllByText('DE · Kelsterbach').toHaveLength(2) would see 1 and getByText('FI · Helsinki') would throw.

One item is only partly closed. Stating it as fact rather than re-filing it: formatTunnelConfigMeta's suffix is inbound.remark || inbound.tag || inbound.id, so the id is a last-resort fallback, not a disambiguator. Two inbounds sharing a remark — or remarks differing only outside [\w.-], which replace(/[^\w.-]+/g, '_') collapses — still yield the same label and the same <email>-<remark>.conf. Distinct remarks (the #6346 case) work, and it beats base dropping the second inbound outright, so it stays a nit.

Coverage — head caef652, 6 files, +349/-105

  • WireGuard addressOverride — the one line that changes emitted .conf bytes. TunnelAllowedIPsByInbound gates on Protocol != WireGuard && != AmneziaWG (internal/web/service/client_lookup.go:154), so WG inbounds are in the map, and it returns that inbound's own joined AllowedIPs. Precedence addressOverride || client.allowedIPs || '10.0.0.2/32' leaves single-inbound output byte-identical and makes the multi-inbound case agree with internal/sub/service.go; no Go/TS link-implementation divergence.
  • Compile/type surface — no reference to the removed singular finders survives; all four builder call sites use the 5-arg form; addressOverride is trailing with a default, so the existing 4-arg tests in wireguard-client-config.test.ts are unaffected; filter((ib): ib is InboundOption => …) narrows the map's InboundOption | undefined.
  • ReactuseMemo dep lists cover every reactive value read; wg-* / awg-* keys are unique across two protocol-disjoint lists; firstKey/syncedFirstKey compares a string, so the render-time setState cannot loop even though items is a fresh array.
  • That asymmetric test assertion is correct, not a slipINBOUND_CHIP_LIMIT = 1 (ClientInfoModal.tsx:49, .slice(0, 1) at :530) renders only the first attached inbound as a visible chip; the rest sit in a click-triggered Popover. Hence DE twice (chip + config label), FI once.
  • CLAUDE.md — no new comment block (the single touched comment is a one-word rename inside a pre-existing 5-line block); no new route, i18n key, migration or make gen trigger; test stack and placement match every other file in frontend/src/test/.
  • Not verified — CI has never run on this head. All three github-actions check-suites for caef652 are action_required (fork PR awaiting maintainer approval) and the combined commit status is pending with zero statuses, so frontend (vitest/oxlint/typecheck), go-test, race and build have no result on this commit and the PR's "all tests passed / 0 errors" is unconfirmed here. A maintainer needs to approve the workflow run before merge. This environment must not execute PR code, so every claim above is from reading, not running.
  • Unchanged by this PR — round one's 🟣 still stands: buildWireguardClientConfig interpolates remark, privateKey and inbound.wgDns with no [\r\n] guard, unlike its AmneziaWG twin (amneziawgConfig.ts:61-63) and the Go emitter. Base had it identically; this PR only widens reach by rendering every WG inbound instead of the first.

Reviewed head: caef652

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]: Config section shows only the first config when a client belongs to multiple AmneziaWG/WireGuard inbounds

1 participant