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
+2
View File
@@ -241,6 +241,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": false,
"expiryTime": 0,
"flow": "",
"forwardedPorts": "",
"group": "",
"id": "",
"keepAlive": 0,
@@ -274,6 +275,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": false,
"expiryTime": 0,
"flow": "",
"forwardedPorts": "",
"group": "",
"id": 0,
"keepAlive": 0,
+8
View File
@@ -1024,6 +1024,10 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Flow control (XTLS)",
"type": "string"
},
"forwardedPorts": {
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
"type": "string"
},
"group": {
"description": "Logical grouping label",
"type": "string"
@@ -1162,6 +1166,9 @@ export const SCHEMAS: Record<string, unknown> = {
"flow": {
"type": "string"
},
"forwardedPorts": {
"type": "string"
},
"group": {
"type": "string"
},
@@ -1225,6 +1232,7 @@ export const SCHEMAS: Record<string, unknown> = {
"enable",
"expiryTime",
"flow",
"forwardedPorts",
"group",
"id",
"keepAlive",
+2
View File
@@ -250,6 +250,7 @@ export interface Client {
enable: boolean;
expiryTime: number;
flow?: string;
forwardedPorts?: string;
group?: string;
id?: string;
keepAlive?: number;
@@ -285,6 +286,7 @@ export interface ClientRecord {
enable: boolean;
expiryTime: number;
flow: string;
forwardedPorts: string;
group: string;
id: number;
keepAlive: number;
+2
View File
@@ -268,6 +268,7 @@ export const ClientSchema = z.object({
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string().optional(),
forwardedPorts: z.string().optional(),
group: z.string().optional(),
id: z.string().optional(),
keepAlive: z.number().int().optional(),
@@ -305,6 +306,7 @@ export const ClientRecordSchema = z.object({
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string(),
forwardedPorts: z.string(),
group: z.string(),
id: z.number().int(),
keepAlive: z.number().int(),
@@ -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 && (
+1
View File
@@ -37,6 +37,7 @@ export const ClientRecordSchema = z.object({
allowedIPs: z.string().optional(),
preSharedKey: z.string().optional(),
keepAlive: z.number().optional(),
forwardedPorts: z.string().optional(),
secret: z.string().optional(),
adTag: z.string().optional(),
createdAt: z.number().optional(),
@@ -6,17 +6,20 @@ import { z } from 'zod';
const optionalClearedInt = (schema: z.ZodNumber) =>
z.preprocess((v) => (v == null ? undefined : v), schema.optional());
// An AmneziaWG client (multi-client model). Field-for-field identical to
// WireguardClientSchema — the panel's generic ClientRecord already has these
// An AmneziaWG client (multi-client model). Same key/address fields as
// WireguardClientSchema — the panel's generic ClientRecord already has those
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
// bulk operations, the QR modal and subscriptions all work unmodified. Keys
// are optional on the wire — the backend generates them when absent.
// bulk operations, the QR modal and subscriptions all work unmodified — plus
// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
// inbound has no host-level iptables layer to hang per-client DNAT off of).
// Keys are optional on the wire — the backend generates them when absent.
export const AmneziawgClientSchema = z.object({
privateKey: z.string().optional(),
publicKey: z.string().optional(),
preSharedKey: z.string().optional(),
allowedIPs: z.array(z.string()).default([]),
keepAlive: optionalClearedInt(z.number().int().min(0)),
forwardedPorts: z.string().default(''),
email: z.string().min(1),
limitIp: z.number().int().min(0).default(0),
totalGB: z.number().int().min(0).default(0),