Files
3x-ui/internal/amneziawg/portfwd.go
T
Kuzz007 df6d2f7652 fix(amneziawg): resolve 4 Low findings from the automated PR review
- manager.go: serverAddress assumed subnetIp always ends in ".0"; a
  base like "10.8.1.5" was used verbatim as the server's own address,
  eventually colliding with peer allocation (which starts at .2
  upward). Now derives the first host of the actual subnetIp/subnetCidr
  network via netip, matching serverAddressV6's own approach. A /32
  base (no host bits at all) is still used as-is. (Finding 12, partial
  -- the /16 pool-widening half of this finding only exists on the
  upstream-pr/amneziawg branch's merged client_wireguard.go, not here;
  handled separately on that branch.)

- manager.go: ensureLocked carried the previous per-peer traffic
  counters (`last`) forward even through a full restart, but
  awg-quick down+up resets the kernel's own counters to zero -- the
  next CollectTraffic computed a large negative delta (clamped to 0),
  silently discarding real traffic. Extracted the decision into
  nextTrafficBaseline: only a reload (syncconf) preserves the
  baseline. (Finding 13)

- portfwd.go: exported ForwardedPortsInclude; inbound_amneziawg.go's
  new checkForwardedPortsConflict uses it to reject, at save time, a
  client's forwardedPorts that would DNAT the panel's own port or
  another enabled inbound's port to the tunnel client --
  portForwardLines has no destination restriction, so this collision
  was previously silent. Wired into both the single-client update path
  and the add-client path (client_inbound_apply.go), plus
  normalizeAmneziaWGSettings for the whole-inbound save path. (Finding 14)

- inbound.go: InboundOption.AwgServer sent the whole ServerSettings
  struct including PrivateKey to GetInboundOptions callers -- a
  shared, admin-wide dropdown-filling endpoint the frontend's own
  AwgServerOptionSchema never reads that field from. Redacted it
  before assigning. (Finding 11)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 11:16:39 +03:00

168 lines
5.1 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
}
// ForwardedPortsInclude reports whether port is covered by any spec in a raw
// ForwardedPorts string (a single port or an inclusive range). For callers
// outside this package that need to check a spec against something other
// than rendering it into iptables rules -- e.g. save-time validation that a
// client isn't about to hijack the panel's own port or another inbound's
// port (portForwardLines has no -d restriction, so a forwarded port that
// collides with one already in use on the host silently redirects it to the
// tunnel client instead).
func ForwardedPortsInclude(forwardedPorts string, port int) bool {
for _, spec := range parseForwardedPorts(forwardedPorts) {
if port >= spec.start && port <= spec.end {
return true
}
}
return false
}
// 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
}