From d103c0c0121d76995bbde219edf9beae9a9a6c2c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 24 Oct 2026 12:23:24 +0200 Subject: [PATCH] fix(amneziawg): keep the fallback egress port below the panel's reserved ports When 64900 was taken, the egress asked the OS for any free port. Where the ephemeral range reaches above 62789 (Windows), that could be the Xray API port or a node-egress, TUIC or AmneziaWG relay port, which then failed to bind. The fallback now picks a random free loopback port below 62789. --- internal/amneziawgnet/egress.go | 33 ++++++++++++++++++++--- internal/amneziawgnet/egress_port_test.go | 33 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/internal/amneziawgnet/egress.go b/internal/amneziawgnet/egress.go index 821ad533d..28eb41682 100644 --- a/internal/amneziawgnet/egress.go +++ b/internal/amneziawgnet/egress.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + mrand "math/rand/v2" "net" "net/netip" "sync" @@ -23,6 +24,15 @@ import ( // first; generated amneziawg socks bridges dial whichever port it holds. const EgressBasePort = 64900 +// reservedLoopbackFloor is where the panel's own loopback listeners start (Xray API 62789, node +// egress, TUIC and AmneziaWG relays); a fallback egress port must stay below it. +const reservedLoopbackFloor = 62789 + +// listenTCP opens a loopback listener; a test stands in for the OS through it. +var listenTCP = func(ctx context.Context, addr string) (net.Listener, error) { + return (&net.ListenConfig{}).Listen(ctx, "tcp", addr) +} + // socks5EgressServer is a minimal loopback SOCKS5 server routing Xray's // bridged amneziawg outbounds into their embedded devices' netstacks. type socks5EgressServer struct { @@ -114,11 +124,10 @@ func (s *socks5EgressServer) Listen() error { if s.listener != nil { return nil } - lc := &net.ListenConfig{} - ln, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", EgressBasePort)) + ln, err := listenTCP(context.Background(), fmt.Sprintf("127.0.0.1:%d", EgressBasePort)) if err != nil { var fallbackErr error - if ln, fallbackErr = lc.Listen(context.Background(), "tcp", "127.0.0.1:0"); fallbackErr != nil { + if ln, fallbackErr = listenBelowReserved(context.Background()); fallbackErr != nil { return fmt.Errorf("amneziawgnet: egress listen: %w", errors.Join(err, fallbackErr)) } logger.Warningf("amneziawgnet: egress port %d unavailable, using a free port: %v", EgressBasePort, err) @@ -131,6 +140,24 @@ func (s *socks5EgressServer) Listen() error { return nil } +// fallbackPortFloor keeps the fallback clear of the well-known and registered low ports. +const fallbackPortFloor = 40000 + +// listenBelowReserved binds a random loopback port under reservedLoopbackFloor, retrying taken or +// OS-reserved ones; an OS-chosen port could land inside the panel's own ranges. +func listenBelowReserved(ctx context.Context) (net.Listener, error) { + var errs []error + for range 64 { + port := fallbackPortFloor + mrand.IntN(reservedLoopbackFloor-fallbackPortFloor) + ln, err := listenTCP(ctx, fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + return ln, nil + } + errs = append(errs, err) + } + return nil, errors.Join(errs...) +} + // Port is the port generated socks bridges must dial: the bound one, or // EgressBasePort while nothing is bound, since Listen tries it first. func (s *socks5EgressServer) Port() int { diff --git a/internal/amneziawgnet/egress_port_test.go b/internal/amneziawgnet/egress_port_test.go index 5030f0b6a..b9dfa6cb7 100644 --- a/internal/amneziawgnet/egress_port_test.go +++ b/internal/amneziawgnet/egress_port_test.go @@ -1,7 +1,9 @@ package amneziawgnet import ( + "context" "encoding/json" + "errors" "net" "strconv" "testing" @@ -79,3 +81,34 @@ func TestBridgesStaleUntilRegeneratedForTheBoundPort(t *testing.T) { t.Fatalf("regenerated bridge dials %d with BridgesStale() = %v, want %d and false", got, BridgesStale(), srv.Port()) } } + +// An OS-chosen port can land on the Xray API port or another panel relay range on hosts whose +// ephemeral range reaches that high (Windows), so the fallback must pick below that range. +func TestEgressFallbackStaysBelowThePanelRanges(t *testing.T) { + srv := GetEgressServer() + srv.Close() + t.Cleanup(srv.Close) + var asked []string + orig := listenTCP + listenTCP = func(ctx context.Context, addr string) (net.Listener, error) { + asked = append(asked, addr) + if addr == net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)) { + return nil, errors.New("base port taken") + } + return orig(ctx, addr) + } + t.Cleanup(func() { listenTCP = orig }) + + if err := srv.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + for _, addr := range asked[1:] { + _, p, _ := net.SplitHostPort(addr) + if port, _ := strconv.Atoi(p); port == 0 || port >= reservedLoopbackFloor { + t.Fatalf("fallback asked for %s, want a fixed port below %d", addr, reservedLoopbackFloor) + } + } + if port := srv.Port(); port >= reservedLoopbackFloor { + t.Fatalf("Port() = %d, inside the panel's reserved range", port) + } +}