mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-09 11:48:18 +00:00
69de904bf6
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>
151 lines
4.4 KiB
Go
151 lines
4.4 KiB
Go
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
|
|
}
|