diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 6bf214ddd..9bcb3a494 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1050,6 +1050,10 @@ "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" @@ -1188,6 +1192,9 @@ "flow": { "type": "string" }, + "forwardedPorts": { + "type": "string" + }, "group": { "type": "string" }, @@ -1251,6 +1258,7 @@ "enable", "expiryTime", "flow", + "forwardedPorts", "group", "id", "keepAlive", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 5b00bb56c..0d0391323 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -241,6 +241,7 @@ export const EXAMPLES: Record = { "enable": false, "expiryTime": 0, "flow": "", + "forwardedPorts": "", "group": "", "id": "", "keepAlive": 0, @@ -274,6 +275,7 @@ export const EXAMPLES: Record = { "enable": false, "expiryTime": 0, "flow": "", + "forwardedPorts": "", "group": "", "id": 0, "keepAlive": 0, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index db99dc1df..b55c533f8 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1024,6 +1024,10 @@ export const SCHEMAS: Record = { "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 = { "flow": { "type": "string" }, + "forwardedPorts": { + "type": "string" + }, "group": { "type": "string" }, @@ -1225,6 +1232,7 @@ export const SCHEMAS: Record = { "enable", "expiryTime", "flow", + "forwardedPorts", "group", "id", "keepAlive", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 249564095..dd06334ed 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 827c8f961..a54af8da7 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -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(), diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 11962a984..5a05522fb 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -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({ > + {showAmneziawg && ( + + + + )} )} {showMtproto && ( diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 2e0c9954a..10227a326 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -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(), diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts index 9f8d00f40..e5096af26 100644 --- a/frontend/src/schemas/protocols/inbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts @@ -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), diff --git a/internal/amneziawg/manager.go b/internal/amneziawg/manager.go index 8ba2b769d..ea544fd09 100644 --- a/internal/amneziawg/manager.go +++ b/internal/amneziawg/manager.go @@ -53,10 +53,11 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { continue } peers = append(peers, Peer{ - Email: c.Email, - PublicKey: c.PublicKey, - PresharedKey: c.PreSharedKey, - AllowedIPs: c.AllowedIPs, + Email: c.Email, + PublicKey: c.PublicKey, + PresharedKey: c.PreSharedKey, + AllowedIPs: c.AllowedIPs, + ForwardedPorts: c.ForwardedPorts, }) } if len(peers) == 0 { @@ -140,7 +141,10 @@ func (inst Instance) structuralFingerprint() string { // peersFingerprint identifies the reloadable peer set regardless of order, so // a reordered clients array in the stored settings does not read as a // change. It moves whenever a peer is added, removed, disabled, re-keyed, or -// re-addressed — all of which `awg syncconf` applies in place. +// re-addressed — all of which `awg syncconf` applies in place. Deliberately +// excludes ForwardedPorts: those live in PostUp/PostDown, not the WireGuard +// peer table, so a ports-only change needs portForwardFingerprint's full +// bounce instead of a syncconf reload. func (inst Instance) peersFingerprint() string { pairs := make([]string, 0, len(inst.Peers)) for _, p := range inst.Peers { @@ -150,6 +154,23 @@ func (inst Instance) peersFingerprint() string { return strings.Join(pairs, "|") } +// portForwardFingerprint identifies the per-peer forwarded-ports set. It is +// checked separately from peersFingerprint because DNAT/FORWARD rules only +// live in PostUp/PostDown, which `awg syncconf` never re-runs — a +// ForwardedPorts-only change must force a full interface bounce +// (ensureRestart) to actually take effect, unlike a key/address-only change. +func (inst Instance) portForwardFingerprint() string { + pairs := make([]string, 0, len(inst.Peers)) + for _, p := range inst.Peers { + if p.ForwardedPorts == "" { + continue + } + pairs = append(pairs, fmt.Sprintf("%s=%s", p.Email, p.ForwardedPorts)) + } + slices.Sort(pairs) + return strings.Join(pairs, "|") +} + // peerCounters is the last-seen cumulative transfer counters for one peer, // used to compute per-poll deltas the same way mtproto tracks per-secret // counters. @@ -162,6 +183,7 @@ type managed struct { inst Instance structuralFP string peersFP string + portFwdFP string last map[string]peerCounters // keyed by peer public key } @@ -196,11 +218,13 @@ const ( ) // ensureActionFor decides how to apply a desired instance to the currently -// managed interface. A structural change (or a down interface) forces a -// restart; a peers-only change is a candidate for an in-place `syncconf`; -// identical fingerprints on an up interface need nothing. -func ensureActionFor(up bool, curStructFP, curPeersFP, newStructFP, newPeersFP string) ensureAction { - if !up || curStructFP != newStructFP { +// managed interface. A structural change, a forwarded-ports change (its +// iptables rules only live in PostUp/PostDown), or a down interface all +// force a restart; a peers-only change (keys/addresses) is a candidate for +// an in-place `syncconf`; identical fingerprints on an up interface need +// nothing. +func ensureActionFor(up bool, curStructFP, curPortFwdFP, curPeersFP, newStructFP, newPortFwdFP, newPeersFP string) ensureAction { + if !up || curStructFP != newStructFP || curPortFwdFP != newPortFwdFP { return ensureRestart } if curPeersFP != newPeersFP { @@ -219,12 +243,13 @@ func (m *Manager) Ensure(inst Instance) error { func (m *Manager) ensureLocked(inst Instance) error { structFP := inst.structuralFingerprint() + portFwdFP := inst.portForwardFingerprint() peersFP := inst.peersFingerprint() cur, exists := m.ifaces[inst.Id] action := ensureRestart if exists { - action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.peersFP, structFP, peersFP) + action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.portFwdFP, cur.peersFP, structFP, portFwdFP, peersFP) } switch action { @@ -255,7 +280,7 @@ func (m *Manager) ensureLocked(inst Instance) error { if exists { last = cur.last } - m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, peersFP: peersFP, last: last} + m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, portFwdFP: portFwdFP, peersFP: peersFP, last: last} return nil } @@ -479,8 +504,9 @@ func hOrDefault(v, def string) string { // directions, and — when the instance has IPv6 enabled — the IPv6-forward // rules, proxy_ndp sysctl, and one `ip -6 neigh add proxy` entry per enabled // peer with an IPv6 address, so upstream routers see each client's IPv6 as -// directly reachable on the LAN without NAT66. Per-client port-forwarding and -// RouteViaXray are a later phase (see project TODO). +// directly reachable on the LAN without NAT66. Also emits DNAT+FORWARD rules +// for each enabled peer with a non-empty ForwardedPorts spec. RouteViaXray is +// a later phase (see project TODO). func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) { iface := inst.InterfaceName up := []string{ @@ -523,6 +549,18 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) { } } + for _, p := range inst.Peers { + if p.ForwardedPorts == "" { + continue + } + clientIP := firstIPv4(p.AllowedIPs) + if clientIP == "" { + continue + } + up = append(up, portForwardLines("-A", ext, iface, clientIP, p.Email, p.ForwardedPorts)...) + down = append(down, portForwardLines("-D", ext, iface, clientIP, p.Email, p.ForwardedPorts)...) + } + up = append(up, "sysctl -w net.ipv4.ip_forward=1") return strings.Join(up, "; "), strings.Join(down, "; ") } @@ -553,6 +591,23 @@ func firstIPv6(allowedIPs []string) string { return "" } +// firstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs, +// or "" if none — used as the DNAT target for a peer's forwarded ports. +func firstIPv4(allowedIPs []string) string { + for _, a := range allowedIPs { + if prefix, err := netip.ParsePrefix(a); err == nil { + if prefix.Addr().Is4() { + return prefix.Addr().String() + } + continue + } + if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() { + return addr.String() + } + } + return "" +} + // detectDefaultInterface returns the first non-loopback, non-tunnel, UP // interface that has a routable IPv4 address. Falls back to "eth0" only if // nothing is found. diff --git a/internal/amneziawg/manager_test.go b/internal/amneziawg/manager_test.go index cf0ed0672..8c19063e4 100644 --- a/internal/amneziawg/manager_test.go +++ b/internal/amneziawg/manager_test.go @@ -179,20 +179,22 @@ func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) { func TestEnsureActionFor(t *testing.T) { cases := []struct { - name string - up bool - curStruct, curPeers string - newStruct, newPeers string - want ensureAction + name string + up bool + curStruct, curPortFwd, curPeers string + newStruct, newPortFwd, newPeers string + want ensureAction }{ - {"down forces restart even if identical", false, "s", "p", "s", "p", ensureRestart}, - {"structural change forces restart", true, "s1", "p", "s2", "p", ensureRestart}, - {"peers-only change reloads", true, "s", "p1", "s", "p2", ensureReload}, - {"identical up interface is a noop", true, "s", "p", "s", "p", ensureNoop}, + {"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart}, + {"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart}, + {"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart}, + {"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload}, + {"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := ensureActionFor(c.up, c.curStruct, c.curPeers, c.newStruct, c.newPeers); got != c.want { + got := ensureActionFor(c.up, c.curStruct, c.curPortFwd, c.curPeers, c.newStruct, c.newPortFwd, c.newPeers) + if got != c.want { t.Errorf("ensureActionFor() = %v, want %v", got, c.want) } }) diff --git a/internal/amneziawg/portfwd.go b/internal/amneziawg/portfwd.go new file mode 100644 index 000000000..fceeec092 --- /dev/null +++ b/internal/amneziawg/portfwd.go @@ -0,0 +1,150 @@ +package amneziawg + +import ( + "fmt" + "hash/fnv" + "strconv" + "strings" +) + +// portSpec is a single port (start == end) or an inclusive range start..end. +type portSpec struct { + start int + end int +} + +func (p portSpec) isRange() bool { return p.end > p.start } + +// dportArg returns the iptables --dport argument: "N" or "N:M". +func (p portSpec) dportArg() string { + if p.isRange() { + return fmt.Sprintf("%d:%d", p.start, p.end) + } + return strconv.Itoa(p.start) +} + +// dnatTarget returns the DNAT target: "ip:N" or "ip:N-M". +func (p portSpec) dnatTarget(clientIP string) string { + if p.isRange() { + return fmt.Sprintf("%s:%d-%d", clientIP, p.start, p.end) + } + return fmt.Sprintf("%s:%d", clientIP, p.start) +} + +// parseForwardedPorts splits a user-supplied string ("80, 443; 8000-8100") +// into validated port specs. Tokens are separated by comma or semicolon; +// whitespace is ignored. Invalid tokens are silently dropped — the input is +// a free-form text field and validation is best-effort by design. Every +// returned spec's bounds are integers in [1, 65535], so callers can safely +// embed them in a shell-executed PostUp/PostDown line without further +// escaping. +func parseForwardedPorts(input string) []portSpec { + if input == "" { + return nil + } + input = strings.ReplaceAll(input, ";", ",") + tokens := strings.Split(input, ",") + + var specs []portSpec + seen := make(map[string]struct{}, len(tokens)) + for _, tok := range tokens { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + spec, ok := parsePortToken(tok) + if !ok { + continue + } + key := fmt.Sprintf("%d-%d", spec.start, spec.end) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + specs = append(specs, spec) + } + return specs +} + +func parsePortToken(tok string) (portSpec, bool) { + if idx := strings.IndexByte(tok, '-'); idx >= 0 { + start, ok1 := parsePortNumber(strings.TrimSpace(tok[:idx])) + end, ok2 := parsePortNumber(strings.TrimSpace(tok[idx+1:])) + if !ok1 || !ok2 || start > end { + return portSpec{}, false + } + return portSpec{start: start, end: end}, true + } + p, ok := parsePortNumber(tok) + if !ok { + return portSpec{}, false + } + return portSpec{start: p, end: p}, true +} + +func parsePortNumber(s string) (int, bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > 65535 { + return 0, false + } + return n, true +} + +// portForwardComment returns a short, shell-safe iptables comment tag for one +// peer's forwarded-port rules, so PostDown removes exactly what PostUp added +// regardless of ordering. Derived from a hash of the peer's email rather than +// the email itself: email is admin/API-supplied free text that ends up +// embedded in a shell-executed PostUp/PostDown line, and a hash can never +// carry a shell metacharacter through. +func portForwardComment(email string) string { + if email == "" { + return "awg-fwd" + } + h := fnv.New32a() + _, _ = h.Write([]byte(email)) + return fmt.Sprintf("awg-fwd-%08x", h.Sum32()) +} + +// portForwardLines returns the PostUp ("-A") or PostDown ("-D") iptables +// lines for one peer's forwarded-ports spec: a DNAT rule (tcp and udp) per +// spec in the nat table, plus a matching FORWARD accept rule. UDP is +// included unconditionally since many common uses (games, P2P) need it. +// Returns nil when forwardedPorts has no valid spec or clientIP is empty. +func portForwardLines(action, extIface, tunIface, clientIP, email, forwardedPorts string) []string { + specs := parseForwardedPorts(forwardedPorts) + if len(specs) == 0 { + return nil + } + clientIP = stripCIDRMask(clientIP) + if clientIP == "" { + return nil + } + comment := portForwardComment(email) + + lines := make([]string, 0, len(specs)*4) + for _, spec := range specs { + dport := spec.dportArg() + target := spec.dnatTarget(clientIP) + for _, proto := range []string{"tcp", "udp"} { + nat := fmt.Sprintf("iptables -t nat %s PREROUTING -p %s", action, proto) + if extIface != "" { + nat += fmt.Sprintf(" -i %s", extIface) + } + nat += fmt.Sprintf(" --dport %s -m comment --comment %s -j DNAT --to-destination %s", dport, comment, target) + lines = append(lines, nat) + + fwd := fmt.Sprintf("iptables %s FORWARD -d %s -p %s -o %s --dport %s -m comment --comment %s -j ACCEPT", + action, clientIP, proto, tunIface, dport, comment) + lines = append(lines, fwd) + } + } + return lines +} + +// stripCIDRMask removes a "/N" suffix if present. +func stripCIDRMask(addr string) string { + if idx := strings.IndexByte(addr, '/'); idx >= 0 { + return addr[:idx] + } + return addr +} diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go index 69cd54b30..42213281a 100644 --- a/internal/amneziawg/types.go +++ b/internal/amneziawg/types.go @@ -34,6 +34,10 @@ type Peer struct { PublicKey string PresharedKey string AllowedIPs []string + + // ForwardedPorts is a raw, user-supplied port list ("80, 443, 8000-8100") + // DNAT'd to this peer's tunnel address. Empty means no port-forwarding. + ForwardedPorts string } // Instance is the desired runtime configuration of one AmneziaWG inbound: a diff --git a/internal/database/model/model.go b/internal/database/model/model.go index a306ca5e7..3687d83f6 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -797,60 +797,62 @@ type ClientReverse struct { // Client represents a client configuration for Xray inbounds with traffic limits and settings. type Client struct { - ID string `json:"id,omitempty"` // Unique client identifier - Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") - Password string `json:"password,omitempty"` // Client password - Flow string `json:"flow,omitempty"` // Flow control (XTLS) - Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings - Auth string `json:"auth,omitempty"` // Auth password (Hysteria) - PrivateKey string `json:"privateKey,omitempty"` - PublicKey string `json:"publicKey,omitempty"` - AllowedIPs []string `json:"allowedIPs,omitempty"` - PreSharedKey string `json:"preSharedKey,omitempty"` - KeepAlive int `json:"keepAlive,omitempty"` - Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"` - AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"` - Email string `json:"email"` // Client email identifier - LimitIP int `json:"limitIp"` // IP limit for this client - TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB - ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp - Enable bool `json:"enable" form:"enable"` // Whether the client is enabled - TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications - SubID string `json:"subId" form:"subId"` // Subscription identifier - Group string `json:"group,omitempty" form:"group"` // Logical grouping label - Comment string `json:"comment" form:"comment"` // Client comment - Reset int `json:"reset" form:"reset"` // Reset period in days - CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp - UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp + ID string `json:"id,omitempty"` // Unique client identifier + Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") + Password string `json:"password,omitempty"` // Client password + Flow string `json:"flow,omitempty"` // Flow control (XTLS) + Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings + Auth string `json:"auth,omitempty"` // Auth password (Hysteria) + PrivateKey string `json:"privateKey,omitempty"` + PublicKey string `json:"publicKey,omitempty"` + AllowedIPs []string `json:"allowedIPs,omitempty"` + PreSharedKey string `json:"preSharedKey,omitempty"` + KeepAlive int `json:"keepAlive,omitempty"` + ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100" + Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"` + AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"` + Email string `json:"email"` // Client email identifier + LimitIP int `json:"limitIp"` // IP limit for this client + TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB + ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp + Enable bool `json:"enable" form:"enable"` // Whether the client is enabled + TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications + SubID string `json:"subId" form:"subId"` // Subscription identifier + Group string `json:"group,omitempty" form:"group"` // Logical grouping label + Comment string `json:"comment" form:"comment"` // Client comment + Reset int `json:"reset" form:"reset"` // Reset period in days + CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp + UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } type ClientRecord struct { - Id int `json:"id" gorm:"primaryKey;autoIncrement"` - Email string `json:"email" gorm:"uniqueIndex;not null"` - SubID string `json:"subId" gorm:"index;column:sub_id"` - UUID string `json:"uuid" gorm:"column:uuid"` - Password string `json:"password"` - Auth string `json:"auth"` - Flow string `json:"flow"` - Security string `json:"security"` - Reverse string `json:"reverse" gorm:"column:reverse"` - PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"` - PublicKey string `json:"publicKey" gorm:"column:wg_public_key"` - AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"` - PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"` - KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"` - Secret string `json:"secret" gorm:"column:secret"` - AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"` - LimitIP int `json:"limitIp" gorm:"column:limit_ip"` - TotalGB int64 `json:"totalGB" gorm:"column:total_gb"` - ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"` - Enable bool `json:"enable" gorm:"default:true"` - TgID int64 `json:"tgId" gorm:"column:tg_id"` - Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"` - Comment string `json:"comment"` - Reset int `json:"reset" gorm:"default:0"` - CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` - UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Email string `json:"email" gorm:"uniqueIndex;not null"` + SubID string `json:"subId" gorm:"index;column:sub_id"` + UUID string `json:"uuid" gorm:"column:uuid"` + Password string `json:"password"` + Auth string `json:"auth"` + Flow string `json:"flow"` + Security string `json:"security"` + Reverse string `json:"reverse" gorm:"column:reverse"` + PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"` + PublicKey string `json:"publicKey" gorm:"column:wg_public_key"` + AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"` + PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"` + KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"` + ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"` + Secret string `json:"secret" gorm:"column:secret"` + AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"` + LimitIP int `json:"limitIp" gorm:"column:limit_ip"` + TotalGB int64 `json:"totalGB" gorm:"column:total_gb"` + ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"` + Enable bool `json:"enable" gorm:"default:true"` + TgID int64 `json:"tgId" gorm:"column:tg_id"` + Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"` + Comment string `json:"comment"` + Reset int `json:"reset" gorm:"default:0"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` } func (ClientRecord) TableName() string { return "clients" } @@ -1016,13 +1018,14 @@ func (c *Client) ToRecord() *ClientRecord { CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, - PrivateKey: c.PrivateKey, - PublicKey: c.PublicKey, - AllowedIPs: strings.Join(c.AllowedIPs, ","), - PreSharedKey: c.PreSharedKey, - KeepAlive: c.KeepAlive, - Secret: c.Secret, - AdTag: c.AdTag, + PrivateKey: c.PrivateKey, + PublicKey: c.PublicKey, + AllowedIPs: strings.Join(c.AllowedIPs, ","), + PreSharedKey: c.PreSharedKey, + KeepAlive: c.KeepAlive, + ForwardedPorts: c.ForwardedPorts, + Secret: c.Secret, + AdTag: c.AdTag, } if c.Reverse != nil { if b, err := json.Marshal(c.Reverse); err == nil { @@ -1069,13 +1072,14 @@ func (r *ClientRecord) ToClient() *Client { CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, - PrivateKey: r.PrivateKey, - PublicKey: r.PublicKey, - AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs), - PreSharedKey: r.PreSharedKey, - KeepAlive: r.KeepAlive, - Secret: r.Secret, - AdTag: r.AdTag, + PrivateKey: r.PrivateKey, + PublicKey: r.PublicKey, + AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs), + PreSharedKey: r.PreSharedKey, + KeepAlive: r.KeepAlive, + ForwardedPorts: r.ForwardedPorts, + Secret: r.Secret, + AdTag: r.AdTag, } if r.Reverse != "" { var rev ClientReverse @@ -1246,6 +1250,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM existing.KeepAlive = incoming.KeepAlive } } + if existing.ForwardedPorts != incoming.ForwardedPorts && incoming.ForwardedPorts != "" { + if incomingNewer || existing.ForwardedPorts == "" { + keep("forwardedPorts", existing.ForwardedPorts, incoming.ForwardedPorts, incoming.ForwardedPorts) + existing.ForwardedPorts = incoming.ForwardedPorts + } + } if existing.Comment != incoming.Comment && incoming.Comment != "" { if incomingNewer || existing.Comment == "" { keep("comment", existing.Comment, incoming.Comment, incoming.Comment) diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 04d7ff86a..72ba6b8a6 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -920,6 +920,8 @@ "amneziaWgPreSharedKey": "AmneziaWG Pre-Shared Key", "amneziaWgAllowedIPs": "AmneziaWG Allowed IPs", "amneziaWgAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas", + "amneziaWgForwardedPorts": "Forwarded Ports", + "amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.", "amneziaWgConfig": "AmneziaWG config", "mtprotoSecret": "MTProto secret", "mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 7f66de69d..0865d1137 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -920,6 +920,8 @@ "amneziaWgPreSharedKey": "Общий ключ AmneziaWG", "amneziaWgAllowedIPs": "Разрешённые IP AmneziaWG", "amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми", + "amneziaWgForwardedPorts": "Проброс портов", + "amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.", "amneziaWgConfig": "Конфиг AmneziaWG", "mtprotoSecret": "Секрет MTProto", "mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",