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:
-
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.
-
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).
-
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:
- Create a UDP Stream Proxy rule (e.g. forward
:53 to an upstream DNS server, or any busy UDP service), set any Timeout value.
- 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).
- 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.
- (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.
- 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.
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 newnet.DialUDPsocket and a relay goroutine are created and stored inudpClientMap, but they are never removed — there is noudpClientMap.Delete, noSetReadDeadline, and no idle cleanup anywhere inudpprox.go. They are only released byCloseAllUDPConnections(), which runs solely when the whole proxy rule is stopped.Two concrete defects:
The
Timeoutsetting is ignored for UDP.Timeoutis wired through the config/UI and is honored intcpprox.go(reconnect backoff), butudpprox.gocontains 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.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).
Busy-loop on persistent read errors (secondary). In
RunUDPConnectionRelay, any read error other thannet.ErrClosedis handled with a barecontinue:On a connected UDP socket (
DialUDP), an ICMP port-unreachable from the target surfaces as a stickyECONNREFUSEDread error (e.g. when the upstream service restarts or a NAT mapping changes). That goroutine then spins onRead → error → continueat 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:
:53to an upstream DNS server, or any busy UDP service), set anyTimeoutvalue.for i in $(seq 1 5000); do dig @<zoraxy> example.com & done).watch 'ls /proc/$(pidof zoraxy)/fd | wc -l'— it grows monotonically and never decreases, regardless of the configuredTimeout.ECONNREFUSED.Expected behavior
Timeout, or a sane default), closing the per-client socket and removing theudpClientMapentry so sockets/goroutines do not accumulate.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):
Host Environment (please complete following information, DO NOT REMOVE ANY FIELD(S)):
/opt/zoraxy)Supplementary links
Relevant source (tag
v3.3.3):src/mod/streamproxy/udpprox.go— functionsForwardUDP,createNewUDPConn,RunUDPConnectionRelay,CloseAllUDPConnections.Possibly related (different issue — config persistence): #320.
Additional context
Suggested fix direction:
lastActivitytimestamp and run a janitor (or useSetReadDeadline(time.Now().Add(Timeout))inRunUDPConnectionRelay) so idle sessions close their socket andudpClientMap.Delete(saddr)the entry. Refresh the deadline on each forwarded packet in both directions.RunUDPConnectionRelay, return on non-recoverable errors (and/or add a small backoff) instead of an unconditionalcontinue, 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.