feat(amneziawg): Phase 2b — per-client port-forwarding

Admins can now set a per-client ForwardedPorts string (e.g. "80, 443,
8000-8100") that gets DNAT'd + FORWARD'd to that peer's tunnel address
via iptables rules in PostUp/PostDown, ported and simplified from
coinman-dev/3ax-ui's shared/portfwd.

Two decisions worth flagging for future readers:

- The iptables --comment tag on each rule is awg-fwd-<fnv32a(email)>,
  not the raw client email. Email is admin/API-supplied free text that
  ends up embedded in a shell-executed PostUp/PostDown line; a hash
  can never carry a shell metacharacter through where raw
  interpolation could.
- The reconcile manager gained a third fingerprint (portFwdFP, next to
  the existing structural/peers ones). `awg syncconf` only touches the
  WireGuard peer table — it never re-applies PostUp/PostDown iptables
  rules — so a port-forward-only change has to force a full
  awg-quick down+up bounce, same as a structural change, rather than
  the lighter sync a plain peer add/remove can use.

Also fixes a real pre-existing bug found while wiring up IPv6 client
allocation in the previous commit's spirit: allocateWireguardAddress
always suffixed "/32" regardless of address family, which produced
invalid host bits for IPv6 (needs "/128").

ForwardedPorts flows through model.Client -> model.ClientRecord
(gorm column wg_forwarded_ports, auto-migrated) -> ToRecord/ToClient/
MergeClientRecord, mirroring the awgServer field's earlier lesson
that new fields need checking against a second, hand-maintained
persistence-layer struct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 17:54:05 +03:00
parent 9eaae5fd6a
commit 69de904bf6
15 changed files with 361 additions and 93 deletions
@@ -102,6 +102,7 @@ type Values = ClientFormValues & {
wgPublicKey: string;
wgPreSharedKey: string;
wgAllowedIPs: string;
awgForwardedPorts: string;
secret: string;
adTag: string;
};
@@ -131,6 +132,7 @@ const EMPTY: Values = {
wgPublicKey: '',
wgPreSharedKey: '',
wgAllowedIPs: '',
awgForwardedPorts: '',
secret: '',
adTag: '',
};
@@ -243,6 +245,7 @@ export default function ClientFormModal({
wgPublicKey: client.publicKey || '',
wgPreSharedKey: client.preSharedKey || '',
wgAllowedIPs: client.allowedIPs || '',
awgForwardedPorts: client.forwardedPorts || '',
secret: client.secret || '',
adTag: client.adTag || '',
};
@@ -558,6 +561,11 @@ export default function ClientFormModal({
if (allowedIPs.length > 0) {
clientPayload.allowedIPs = allowedIPs;
}
// Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
// has no host-level iptables layer to hang per-client DNAT off of.
if (showAmneziawg) {
clientPayload.forwardedPorts = values.awgForwardedPorts.trim();
}
}
if (showMtproto) {
@@ -900,6 +908,15 @@ export default function ClientFormModal({
>
<Input placeholder="10.8.1.2/32" />
</FormField>
{showAmneziawg && (
<FormField
name="awgForwardedPorts"
label={t('pages.clients.amneziaWgForwardedPorts')}
extra={t('pages.clients.amneziaWgForwardedPortsHint')}
>
<Input placeholder="80, 443, 8000-8100" />
</FormField>
)}
</>
)}
{showMtproto && (