mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 04:07:14 +00:00
df6d2f7652
- 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>
168 lines
5.1 KiB
Go
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
|
|
}
|