From 0ea11e16437a268e23eabd42aeb402594d1b3a70 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 15:06:34 +0100 Subject: [PATCH] fix(amneziawg): keep a port forward accepting after a failed accept A TCP port forward's accept loop returned on any accept error, leaving the socket bound and the forward registered as open, so nothing served the port again until a panel restart; briefly pushing the panel to its open-file limit was enough. Forward and egress accept loops now log, wait 100 ms (or until closed) and keep accepting. --- internal/amneziawgnet/egress.go | 3 ++ internal/amneziawgnet/portfwd.go | 22 ++++++++++++++- internal/amneziawgnet/portfwd_test.go | 40 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/internal/amneziawgnet/egress.go b/internal/amneziawgnet/egress.go index 28eb41682..1f2d7e793 100644 --- a/internal/amneziawgnet/egress.go +++ b/internal/amneziawgnet/egress.go @@ -218,6 +218,9 @@ func (s *socks5EgressServer) acceptLoop(ln net.Listener, closing chan struct{}) default: } logger.Warningf("amneziawgnet: egress accept: %v", err) + if !waitAcceptRetry(closing) { + return + } continue } select { diff --git a/internal/amneziawgnet/portfwd.go b/internal/amneziawgnet/portfwd.go index 9f5d6c48c..80cbef491 100644 --- a/internal/amneziawgnet/portfwd.go +++ b/internal/amneziawgnet/portfwd.go @@ -270,6 +270,21 @@ func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForward const portForwardDialTimeout = 10 * time.Second +// acceptRetryDelay paces an accept loop's retries, so an error that lasts +// (EMFILE, until descriptors free up) neither spins it nor floods the log. +const acceptRetryDelay = 100 * time.Millisecond + +// waitAcceptRetry sleeps acceptRetryDelay after a failed Accept; false means +// closing fired meanwhile and the loop should return instead of retrying. +func waitAcceptRetry(closing <-chan struct{}) bool { + select { + case <-closing: + return false + case <-time.After(acceptRetryDelay): + return true + } +} + // tunnelNetwork returns the gVisor network protocol number matching addr's // address family, for dialing toward it inside the embedded stack. func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber { @@ -325,7 +340,12 @@ func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key default: } logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err) - return + // Keep accepting: returning would strand a bound socket nobody + // serves, under a key Reconcile still sees as open and never reopens. + if !waitAcceptRetry(l.closing) { + return + } + continue } go relayTCPForward(gstack, conn, inboundID, key, target) } diff --git a/internal/amneziawgnet/portfwd_test.go b/internal/amneziawgnet/portfwd_test.go index 573910719..d33405e46 100644 --- a/internal/amneziawgnet/portfwd_test.go +++ b/internal/amneziawgnet/portfwd_test.go @@ -2,11 +2,14 @@ package amneziawgnet import ( "context" + "errors" "fmt" "io" "net" "net/netip" "sync" + "sync/atomic" + "syscall" "testing" "time" @@ -222,6 +225,43 @@ func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) { set.Close() } +// failFirstAccept fails the first Accept the way the kernel does once the +// process is at RLIMIT_NOFILE, then behaves like the listener it wraps. +type failFirstAccept struct { + net.Listener + failed atomic.Bool +} + +func (l *failFirstAccept) Accept() (net.Conn, error) { + if l.failed.CompareAndSwap(false, true) { + return nil, &net.OpError{Op: "accept", Net: "tcp", Addr: l.Addr(), Err: syscall.EMFILE} + } + return l.Listener.Accept() +} + +func TestTCPForwardAcceptLoopSurvivesTransientError(t *testing.T) { + host, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + l := &tcpForwardListener{ln: &failFirstAccept{Listener: host}, closing: make(chan struct{})} + defer l.Close() + noTarget := func(string) (netip.Addr, bool) { return netip.Addr{}, false } + go l.acceptLoop(newTestStack(t, "10.211.4.1"), 505, portForwardKey{email: "a@x", proto: tcpForward}, noTarget) + + conn, err := net.DialTimeout("tcp", host.Addr().String(), time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + // relayTCPForward closes a connection it has no target for, so EOF proves + // the loop accepted it; a dead loop leaves it queued in the backlog. + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + if _, err := conn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) { + t.Fatalf("read after a transient accept error = %v, want io.EOF from an accepted connection", err) + } +} + // --- Real round trip: a genuine amneziawg-go client handshakes against a // real server Device, PortForwardSet opens a real host listener, and a real // external-side dial (this test's own process) round-trips bytes through