mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 18:57:14 +00: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:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -920,6 +920,8 @@
|
||||
"amneziaWgPreSharedKey": "Общий ключ AmneziaWG",
|
||||
"amneziaWgAllowedIPs": "Разрешённые IP AmneziaWG",
|
||||
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
|
||||
"amneziaWgForwardedPorts": "Проброс портов",
|
||||
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
|
||||
"amneziaWgConfig": "Конфиг AmneziaWG",
|
||||
"mtprotoSecret": "Секрет MTProto",
|
||||
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
|
||||
|
||||
Reference in New Issue
Block a user