mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user