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
+8
View File
@@ -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",
+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),
+69 -14
View File
@@ -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.
+12 -10
View File
@@ -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)
}
})
+150
View File
@@ -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
}
+4
View File
@@ -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
+75 -65
View File
@@ -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)
+2
View File
@@ -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.",
+2
View File
@@ -920,6 +920,8 @@
"amneziaWgPreSharedKey": "Общий ключ AmneziaWG",
"amneziaWgAllowedIPs": "Разрешённые IP AmneziaWG",
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
"amneziaWgForwardedPorts": "Проброс портов",
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
"amneziaWgConfig": "Конфиг AmneziaWG",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",