diff --git a/install.sh b/install.sh index e1e24be7b..29432813c 100644 --- a/install.sh +++ b/install.sh @@ -212,6 +212,29 @@ enable_tproxy_support() { # interface. Best-effort and never fatal to the overall x-ui install: the # panel works fine without it, an AmneziaWG inbound just won't start its # tunnel until the module is installed (surfaced in the panel/logs, not here). +# AmneziaWG is opt-in (see should_install_amneziawg below): installing it +# unconditionally for every user would mean building/loading a DKMS kernel +# module and enabling host-wide IPv4/IPv6 forwarding on every panel install, +# whether or not that install ever uses the protocol. +# +# should_install_amneziawg decides whether to run install_amneziawg at all. +# XUI_INSTALL_AMNEZIAWG=true/false answers it outright (for non-interactive/ +# cloud-init runs); otherwise an interactive install prompts (default: no), +# and a non-interactive one defaults to skipping it -- opt-in stays opt-in +# even when nothing is there to answer the prompt. +should_install_amneziawg() { + case "${XUI_INSTALL_AMNEZIAWG:-}" in + true | TRUE | 1 | yes | y | Y) return 0 ;; + false | FALSE | 0 | no | n | N) return 1 ;; + esac + if [[ "$NONINTERACTIVE" == "1" ]]; then + return 1 + fi + local reply + read -rp "Install native AmneziaWG support (WireGuard + DPI-resistant obfuscation)? This builds a DKMS kernel module and enables host-wide IP forwarding. (y/N): " reply + [[ "$reply" == "y" || "$reply" == "Y" ]] +} + # ppa:amnezia/ppa (Ubuntu/Debian/Armbian) is the primary, tested path; other # distros fall back to plain wireguard-tools with a manual-install pointer. # See https://github.com/amnezia-vpn/amneziawg-linux-kernel-module. @@ -254,9 +277,19 @@ install_amneziawg() { echo -e "${green}AmneziaWG installed successfully via PPA.${plain}" || echo -e "${red}PPA install failed. Install amneziawg manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" else - apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 57290828 2>/dev/null || true - echo "deb https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" >> /etc/apt/sources.list - echo "deb-src https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" >> /etc/apt/sources.list + # apt-key is deprecated/removed on Debian 12+ and Ubuntu 24.04; + # fetch the key into its own keyring file and reference it via + # signed-by= instead of the removed system-wide trust store. + local amneziawg_keyring="/etc/apt/keyrings/amneziawg.gpg" + local amneziawg_list_entry="deb [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" + local amneziawg_src_entry="deb-src [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" + install -d -m 755 /etc/apt/keyrings + gpg --no-default-keyring --keyring "$amneziawg_keyring" --keyserver keyserver.ubuntu.com --recv-keys 57290828 2>/dev/null || true + # Guarded so a retried install (the PPA step failed last time, + # or install.sh simply ran again) doesn't keep appending + # duplicate sources.list entries. + grep -qxF "$amneziawg_list_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_list_entry" >> /etc/apt/sources.list + grep -qxF "$amneziawg_src_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_src_entry" >> /etc/apt/sources.list apt-get update -q && apt-get install -y amneziawg && echo -e "${green}AmneziaWG installed successfully.${plain}" || @@ -1858,7 +1891,12 @@ install_x-ui() { echo -e "${green}Running...${plain}" install_base -install_amneziawg +if should_install_amneziawg; then + install_amneziawg +else + echo -e "${yellow}Skipping AmneziaWG setup. Opt in later by re-running install.sh with XUI_INSTALL_AMNEZIAWG=true, or install it manually:${plain}" + echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" +fi install_x-ui $1 # Secure Boot blocks the AmneziaWG DKMS module from loading (it's unsigned). diff --git a/internal/amneziawg/manager.go b/internal/amneziawg/manager.go index 3960ffba4..490e001ad 100644 --- a/internal/amneziawg/manager.go +++ b/internal/amneziawg/manager.go @@ -134,6 +134,8 @@ func (inst Instance) structuralFingerprint() string { strconv.Itoa(o.S1), strconv.Itoa(o.S2), strconv.Itoa(o.S3), strconv.Itoa(o.S4), o.H1, o.H2, o.H3, o.H4, o.I1, inst.ExternalInterface, + strconv.FormatBool(inst.IPv6Enabled), + inst.IPv6ExternalInterface, } return strings.Join(parts, "|") } @@ -155,8 +157,9 @@ func (inst Instance) peersFingerprint() string { } // hostRulesFingerprint identifies per-peer state that only ever takes effect -// through PostUp/PostDown shell rules — forwarded ports, and (unconditionally, -// for every peer with a usable IPv4 address) the TPROXY rule into this +// through PostUp/PostDown shell rules — forwarded ports, the peer's IPv6 +// address (its NDP-proxy PostUp/PostDown entry), and (unconditionally, for +// every peer with a usable IPv4 address) the TPROXY rule into this // instance's own Xray bridge — rather than the WireGuard peer table itself. // It is checked separately from peersFingerprint because `awg syncconf` // never re-runs PostUp/PostDown, so a change here must force a full @@ -165,11 +168,15 @@ func (inst Instance) peersFingerprint() string { // now tied to every peer's mere presence (there's no more per-peer opt-in // flag), every peer is included unconditionally: adding, removing, or // re-addressing a peer now also forces a bounce, the same way a -// ForwardedPorts-only change always did. +// ForwardedPorts-only change always did. The IPv6 address must be included +// here too: without it, a peer that only changes its IPv6 AllowedIPs entry +// still matches on FirstIPv4 alone, so ensureActionFor would pick the +// syncconf reload path — which never re-runs PostUp — leaving that peer's +// NDP-proxy entry pointed at its old, now-wrong address. func (inst Instance) hostRulesFingerprint() string { pairs := make([]string, 0, len(inst.Peers)) for _, p := range inst.Peers { - pairs = append(pairs, fmt.Sprintf("%s=fwd:%s;ip:%s", p.Email, p.ForwardedPorts, FirstIPv4(p.AllowedIPs))) + pairs = append(pairs, fmt.Sprintf("%s=fwd:%s;ip:%s;ip6:%s", p.Email, p.ForwardedPorts, FirstIPv4(p.AllowedIPs), firstIPv6(p.AllowedIPs))) } slices.Sort(pairs) return strings.Join(pairs, "|") @@ -687,8 +694,13 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) { // IPv6-forwarding sysctl above — it is added idempotently here and // never torn down in PostDown; a second AmneziaWG instance must find // it already in place, not race to remove what the first still needs. + // "ip rule add" is not itself idempotent (a second call inserts a + // duplicate rather than deduplicating), and hostRulesFingerprint keys + // on every peer's presence/IP, so PostUp re-runs on any client + // add/remove/re-IP — without the existence check below, "ip rule + // show" would accumulate one duplicate entry per bounce forever. up = append(up, - fmt.Sprintf("ip rule add fwmark %#x lookup %d 2>/dev/null || true", EgressFwmark, EgressTable), + fmt.Sprintf("ip rule list | grep -q 'fwmark %#x lookup %d' || 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), ) } diff --git a/internal/amneziawg/manager_test.go b/internal/amneziawg/manager_test.go index 6505a8c84..37d0d3eef 100644 --- a/internal/amneziawg/manager_test.go +++ b/internal/amneziawg/manager_test.go @@ -156,6 +156,21 @@ func TestStructuralFingerprintStableAndSensitive(t *testing.T) { if a.structuralFingerprint() != c.structuralFingerprint() { t.Fatal("a peer-only change must NOT change the structural fingerprint") } + + d := baseInstance() + d.IPv6Enabled = true + if a.structuralFingerprint() == d.structuralFingerprint() { + t.Fatal("enabling IPv6 must change the structural fingerprint") + } + + e := baseInstance() + e.IPv6Enabled = true + f := baseInstance() + f.IPv6Enabled = true + f.IPv6ExternalInterface = "eth1" + if e.structuralFingerprint() == f.structuralFingerprint() { + t.Fatal("changing IPv6ExternalInterface must change the structural fingerprint -- otherwise the edit is a complete no-op") + } } func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) { @@ -231,6 +246,12 @@ func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) { if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() { t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer TPROXY rule is needed") } + + ip6Added := baseInstance() + ip6Added.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"} + if a.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() { + t.Fatal("adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp") + } } func TestRouteEgressComment(t *testing.T) { @@ -308,6 +329,9 @@ func TestDefaultPostUpDownEmitsTproxyForEveryPeer(t *testing.T) { 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) } + if wantCheck := fmt.Sprintf("ip rule list | grep -q 'fwmark %#x lookup %d'", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) { + t.Errorf("expected an 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") { t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it") } diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index f1791e2fb..f67b0b45c 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -5,6 +5,7 @@ import ( "fmt" "math/big" "net/netip" + "regexp" "strconv" "strings" ) @@ -140,6 +141,54 @@ func ValidateIPv6Subnet(enabled bool, subnet string) error { return nil } +// interfaceNamePattern matches a plausible Linux network interface name: +// letters, digits, and the handful of separators seen in real device names +// (eth0, wg0, br-lan, eno1.100, eth0:0), capped at 15 bytes (IFNAMSIZ-1). +var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`) + +// ValidateInterfaceName rejects a value that isn't a plausible network +// interface name before it's saved. ExternalInterface and +// IPv6ExternalInterface are interpolated unescaped into a shell-executed +// PostUp/PostDown line by generateServerConfig, so — unlike the client email +// (already hashed for exactly this reason, see routeEgressComment) — an +// unvalidated value here could carry a shell metacharacter straight into a +// root-executed command. A blank value is allowed: it means "auto-detect" +// for ExternalInterface, or "reuse ExternalInterface" for +// IPv6ExternalInterface. +func ValidateInterfaceName(name string) error { + if name == "" { + return nil + } + if !interfaceNamePattern.MatchString(name) { + return fmt.Errorf("invalid interface name %q: must be 1-15 characters of letters, digits, '.', '_', '@', ':' or '-'", name) + } + return nil +} + +// ValidateSubnetIPv4 rejects a malformed IPv4 tunnel subnet before it's +// saved: subnetIP is interpolated into the PostUp/PostDown MASQUERADE rule +// the same way ExternalInterface is (see ValidateInterfaceName), so it needs +// the same protection against a value that isn't really an address at all. +// subnetCIDR <= 0 is treated as unset, mirroring serverAddress's own +// default-to-/24 leniency. +func ValidateSubnetIPv4(subnetIP string, subnetCIDR int) error { + cidr := subnetCIDR + if cidr <= 0 { + cidr = 24 + } + if cidr > 32 { + return fmt.Errorf("invalid subnetCidr %d: must be 0..32", subnetCIDR) + } + prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr)) + if err != nil { + return fmt.Errorf("invalid subnetIp %q: %w", subnetIP, err) + } + if !prefix.Addr().Is4() { + return fmt.Errorf("invalid subnetIp %q: not an IPv4 address", subnetIP) + } + return nil +} + // validateHValue checks one H parameter: empty, a single uint32, or // "low-high" with 0 <= low <= high <= uint32 max. func validateHValue(v string) error { diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 62a2ef863..7a10c04ef 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -153,3 +153,61 @@ func TestValidateObfuscationRejectsBadH(t *testing.T) { } } } + +func TestValidateInterfaceNameAcceptsBlankAndPlausibleNames(t *testing.T) { + for _, name := range []string{"", "eth0", "wg0", "br-lan", "eno1.100", "veth1a2b3c", "eth0:0"} { + if err := ValidateInterfaceName(name); err != nil { + t.Errorf("ValidateInterfaceName(%q) rejected a plausible name: %v", name, err) + } + } +} + +func TestValidateInterfaceNameRejectsShellMetacharactersAndOverlength(t *testing.T) { + cases := []string{ + "eth0 -j ACCEPT; rm -rf /", + "eth0`whoami`", + "eth0$(id)", + "eth0|cat /etc/passwd", + "eth0\nMASQUERADE", + "aaaaaaaaaaaaaaaaaaaa", // 20 chars, over IFNAMSIZ-1 + } + for _, name := range cases { + if err := ValidateInterfaceName(name); err == nil { + t.Errorf("ValidateInterfaceName(%q) must be rejected", name) + } + } +} + +func TestValidateSubnetIPv4AcceptsValidBases(t *testing.T) { + cases := []struct { + ip string + cidr int + }{ + {"10.8.1.0", 24}, + {"10.8.1.0", 0}, // cidr <= 0 defaults to /24, mirroring serverAddress + {"192.168.5.10", 32}, + } + for _, c := range cases { + if err := ValidateSubnetIPv4(c.ip, c.cidr); err != nil { + t.Errorf("ValidateSubnetIPv4(%q, %d) rejected a valid subnet: %v", c.ip, c.cidr, err) + } + } +} + +func TestValidateSubnetIPv4RejectsMalformedOrInjectedValues(t *testing.T) { + cases := []struct { + ip string + cidr int + }{ + {"10.8.1.0 -j ACCEPT; rm -rf /", 24}, // shell injection attempt + {"not-an-ip", 24}, + {"", 24}, + {"fd86::1", 64}, // IPv6, not IPv4 + {"10.8.1.0", 33}, // cidr out of range + } + for _, c := range cases { + if err := ValidateSubnetIPv4(c.ip, c.cidr); err == nil { + t.Errorf("ValidateSubnetIPv4(%q, %d) must be rejected", c.ip, c.cidr) + } + } +} diff --git a/internal/web/job/amneziawg_job.go b/internal/web/job/amneziawg_job.go index 7e449ed62..1f8f82153 100644 --- a/internal/web/job/amneziawg_job.go +++ b/internal/web/job/amneziawg_job.go @@ -13,6 +13,11 @@ import ( // the usual client and inbound traffic accounting. Mirrors MtprotoJob. type AmneziaWGJob struct { inboundService service.InboundService + // warnedMissing tracks whether the "awg/awg-quick not found" warning has + // already been logged, so a host without the AmneziaWG kernel module + // (the Docker image, RHEL, Arch, or a failed install.sh PPA step) logs it + // once instead of every @every-10s tick forever. + warnedMissing bool } // NewAmneziaWGJob creates a new AmneziaWG reconcile/traffic job instance. @@ -29,6 +34,17 @@ func (j *AmneziaWGJob) Run() { return } + // Only relevant once an admin actually has an AmneziaWG inbound: no + // point warning about a missing binary the panel never needed to touch. + if len(desired) > 0 && !amneziawg.IsAwgInstalled() { + if !j.warnedMissing { + j.warnedMissing = true + logger.Warningf("amneziawg job: %d AmneziaWG inbound(s) configured but awg/awg-quick not found on PATH; skipping reconcile until installed", len(desired)) + } + return + } + j.warnedMissing = false + activeTags := make([]string, 0, len(desired)) for _, inst := range desired { activeTags = append(activeTags, inst.Tag) diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index 7c35a3224..1093e5ee0 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -643,6 +643,14 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].KeepAlive == 0 { clients[0].KeepAlive = old.KeepAlive } + // ForwardedPorts is AmneziaWG-only (WireGuard's own inbound never + // reads it), same carry-forward reasoning as the fields above: a + // partial edit (e.g. a Telegram-bot enable/expiry toggle, or an API + // call that omits the field) must not silently drop a client's + // existing port-forwarding spec. + if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts == "" { + clients[0].ForwardedPorts = old.ForwardedPorts + } } var oldSettings map[string]any @@ -693,6 +701,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].KeepAlive > 0 { newMap["keepAlive"] = clients[0].KeepAlive } + if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" { + newMap["forwardedPorts"] = clients[0].ForwardedPorts + } } if oldClientMap != nil && sameClientConfigExceptUpdatedAt(oldClientMap, newMap) { if v, ok2 := oldClientMap["updated_at"]; ok2 { diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index 764f194f6..b773a5784 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/internal/web/service/inbound_amneziawg.go @@ -191,6 +191,15 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil { return fmt.Errorf("amneziawg: %w", err) } + if err := amneziawg.ValidateSubnetIPv4(parsed.Server.SubnetIP, parsed.Server.SubnetCIDR); err != nil { + return fmt.Errorf("amneziawg: %w", err) + } + if err := amneziawg.ValidateInterfaceName(parsed.Server.ExternalInterface); err != nil { + return fmt.Errorf("amneziawg: externalInterface: %w", err) + } + if err := amneziawg.ValidateInterfaceName(parsed.Server.IPv6ExternalInterface); err != nil { + return fmt.Errorf("amneziawg: ipv6ExternalInterface: %w", err) + } bs, err := json.MarshalIndent(parsed, "", " ") if err != nil { diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index ea005c2f6..28f146ec2 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/common" @@ -175,6 +176,24 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) }, nil } + // Every enabled local AmneziaWG inbound gets its own automatic Xray + // bridge (see injectAmneziawgEgress) on 127.0.0.1 at a port derived + // purely from its id (amneziawg.EgressPortForInbound) -- like the + // internal Xray API inbound above, that bridge is not itself a database + // row, so the ordinary DB-backed query below can never see it. Without + // this check, an unrelated inbound saved onto that exact port silently + // fails at the next Xray start, taking every other protocol down with + // it, not just AmneziaWG. + if inbound.NodeID == nil && listenOverlaps("127.0.0.1", inbound.Listen) { + conflict, err := s.checkAmneziawgEgressConflict(inbound, ignoreId, newBits) + if err != nil { + return nil, err + } + if conflict != nil { + return conflict, nil + } + } + db := database.GetDB() var candidates []*model.Inbound @@ -210,6 +229,37 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) return nil, nil } +// checkAmneziawgEgressConflict reports whether inbound's own port collides +// with an existing, enabled local AmneziaWG inbound's automatic Xray bridge +// port. ignoreId excludes one inbound id from the AmneziaWG candidates, the +// same way the general DB-backed conflict query above excludes the inbound +// being edited from matching itself. +func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) { + db := database.GetDB() + var candidates []*model.Inbound + q := db.Model(model.Inbound{}).Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true) + if ignoreId > 0 { + q = q.Where("id != ?", ignoreId) + } + if err := q.Find(&candidates).Error; err != nil { + return nil, err + } + for _, c := range candidates { + if amneziawg.EgressPortForInbound(c.Id) != inbound.Port { + continue + } + return &portConflictDetail{ + InboundID: c.Id, + Remark: c.Remark, + Tag: c.Tag, + Listen: "127.0.0.1", + Port: inbound.Port, + Transports: newBits, + }, nil + } + return nil, nil +} + func sameNode(a, b *int) bool { if a == nil && b == nil { return true diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index 329caa0f6..a72fa0789 100644 --- a/internal/web/service/port_conflict_test.go +++ b/internal/web/service/port_conflict_test.go @@ -8,6 +8,7 @@ import ( "github.com/op/go-logging" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -729,3 +730,102 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) { t.Fatalf("udp-only inbound must coexist with the tcp API inbound; got=%v err=%v", got, err) } } + +// An enabled AmneziaWG inbound's automatic Xray bridge (injectAmneziawgEgress) +// is a synthetic loopback dokodemo-door inbound, not a database row, so +// checkPortConflict needs its own check to catch a collision -- exactly the +// same shape of problem as the reserved API port above. +func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`) + + var awgInbound model.Inbound + if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "vless-bridge", + Listen: "0.0.0.0", + Port: bridgePort, + Protocol: model.VLESS, + } + got, err := svc.checkPortConflict(candidate, 0) + if err != nil { + t.Fatalf("checkPortConflict: %v", err) + } + if got == nil { + t.Fatalf("a local inbound on the AmneziaWG bridge port %d must conflict", bridgePort) + } + if msg := got.String(); !strings.Contains(msg, "awg-1") { + t.Fatalf("conflict message should name the owning AmneziaWG inbound; got %q", msg) + } +} + +// Nodes run their own Xray, so a node inbound landing on the central panel's +// AmneziaWG bridge port must be allowed -- the bridge only ever binds +// 127.0.0.1 on the local panel's own Xray. +func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`) + + var awgInbound model.Inbound + if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "node-bridge", + Listen: "0.0.0.0", + Port: bridgePort, + Protocol: model.VLESS, + NodeID: new(1), + } + if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { + t.Fatalf("a node inbound on the local AmneziaWG bridge port must be allowed; got=%v err=%v", got, err) + } +} + +// A disabled AmneziaWG inbound never gets a bridge injected +// (injectAmneziawgEgress skips !inbound.Enable), so its "reserved" port must +// not block anything. +func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T) { + setupConflictDB(t) + awg := &model.Inbound{Tag: "awg-1", Enable: false, Listen: "0.0.0.0", Port: 51820, Protocol: model.AmneziaWG, Settings: `{}`} + if err := database.GetDB().Create(awg).Error; err != nil { + t.Fatalf("seed disabled awg inbound: %v", err) + } + bridgePort := amneziawg.EgressPortForInbound(awg.Id) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "vless-bridge", + Listen: "0.0.0.0", + Port: bridgePort, + Protocol: model.VLESS, + } + if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { + t.Fatalf("a disabled AmneziaWG inbound's port must not be reserved; got=%v err=%v", got, err) + } +} + +// An unrelated port never conflicts with the bridge. +func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "vless-elsewhere", + Listen: "0.0.0.0", + Port: 9999, + Protocol: model.VLESS, + } + if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { + t.Fatalf("an unrelated port must not conflict with the AmneziaWG bridge; got=%v err=%v", got, err) + } +}