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) + } +}