Skip to content

[BUG] UDP stream proxy never releases per-client sockets/goroutines (Timeout ignored) → socket/goroutine leak and eventual forwarding stall #1207

Description

@BadCoder1337

Describe the bug

The UDP path of the Stream Proxy (mod/streamproxy, udpprox.go) has no idle-session expiry. For every distinct client UDP endpoint (srcIP:srcPort) a new net.DialUDP socket and a relay goroutine are created and stored in udpClientMap, but they are never removed — there is no udpClientMap.Delete, no SetReadDeadline, and no idle cleanup anywhere in udpprox.go. They are only released by CloseAllUDPConnections(), which runs solely when the whole proxy rule is stopped.

Two concrete defects:

  1. The Timeout setting is ignored for UDP. Timeout is wired through the config/UI and is honored in tcpprox.go (reconnect backoff), but udpprox.go contains zero references to it. There is no per-session timeout for UDP at all, so the user-configured value is a no-op for UDP rules.

  2. Unbounded socket + goroutine leak. Because map entries are never pruned, the number of open UDP sockets and live goroutines grows without bound. This is especially severe for high source-port-churn UDP traffic such as DNS (every recursive query arrives from a fresh ephemeral source port → a brand-new permanent socket + goroutine per query).

  3. Busy-loop on persistent read errors (secondary). In RunUDPConnectionRelay, any read error other than net.ErrClosed is handled with a bare continue:

    n, err := conn.ServerConn.Read(buffer[0:])
    if err != nil {
        if errors.Is(err, net.ErrClosed) {
            return
        }
        continue   // no backoff
    }

    On a connected UDP socket (DialUDP), an ICMP port-unreachable from the target surfaces as a sticky ECONNREFUSED read error (e.g. when the upstream service restarts or a NAT mapping changes). That goroutine then spins on Read → error → continue at 100% CPU and never exits. As leaked dead sockets accumulate, this starves the scheduler and the proxy stops forwarding until the rule is manually stopped/started.

Net effect observed in production: UDP forwarding (DNS + a WireGuard VPN) periodically stops passing traffic and only recovers after a manual Stop/Start of the Stream Proxy in the admin UI. Monitoring the Zoraxy process showed open UDP sockets climbing continuously (e.g. 1083 → 1881 sockets in ~20 minutes); a Stop/Start dropped it back to ~35 and the climb restarted immediately.

To Reproduce
Steps to reproduce the behavior:

  1. Create a UDP Stream Proxy rule (e.g. forward :53 to an upstream DNS server, or any busy UDP service), set any Timeout value.
  2. Send sustained UDP traffic from many distinct source ports through it (a recursive DNS resolver, or for i in $(seq 1 5000); do dig @<zoraxy> example.com & done).
  3. Watch the Zoraxy process's socket/FD count: watch 'ls /proc/$(pidof zoraxy)/fd | wc -l' — it grows monotonically and never decreases, regardless of the configured Timeout.
  4. (For the busy-loop) point a UDP rule at a target, then stop the target so it returns ICMP port-unreachable while a client keeps sending; the relay goroutine spins on ECONNREFUSED.
  5. Over time, forwarding stalls; Stop/Start of the rule temporarily restores it.

Expected behavior

  • UDP relay sessions should expire after an idle period (the configured Timeout, or a sane default), closing the per-client socket and removing the udpClientMap entry so sockets/goroutines do not accumulate.
  • A read error that is not transient should terminate (or back off) the relay goroutine instead of spinning with a bare continue.

Screenshots
N/A (CLI/process-level; socket-count evidence above).

Browser (if it is a bug appears on the UI section of the system):

  • OS: N/A
  • Browser: N/A
  • Version: N/A

Host Environment (please complete following information, DO NOT REMOVE ANY FIELD(S)):

  • Arch: x86_64 (amd64)
  • Device: VM / Proxmox VE LXC container
  • OS: Debian GNU/Linux 13 (trixie)
  • Version: 13 (trixie)
  • Are you using Docker? (yes / no): no (native systemd deployment, /opt/zoraxy)
  • Docker Version (fill in "N/A" for native deployment): N/A
  • Zoraxy Version: 3.3.3

Note: this is a logic bug in the UDP relay and is environment-independent — it reproduces on any platform.

Supplementary links
Relevant source (tag v3.3.3): src/mod/streamproxy/udpprox.go — functions ForwardUDP, createNewUDPConn, RunUDPConnectionRelay, CloseAllUDPConnections.
Possibly related (different issue — config persistence): #320.

Additional context

Suggested fix direction:

  • Track a per-session lastActivity timestamp and run a janitor (or use SetReadDeadline(time.Now().Add(Timeout)) in RunUDPConnectionRelay) so idle sessions close their socket and udpClientMap.Delete(saddr) the entry. Refresh the deadline on each forwarded packet in both directions.
  • In RunUDPConnectionRelay, return on non-recoverable errors (and/or add a small backoff) instead of an unconditional continue, and ensure the corresponding map entry is deleted when the goroutine exits.

Happy to test a patch against this workload (high-churn DNS + WireGuard) if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions