The quic and wt (WebTransport) listeners wrap the raw UDP socket with
net.PacketConn metrics/stats/admission/limiter wrappers before handing
it to quic-go. WrapPacketConn returns a plain net.PacketConn, which
hides the underlying *net.UDPConn. quic-go probes the conn for
OOBCapablePacketConn (SyscallConn, SetReadBuffer, ReadMsgUDP,
WriteMsgUDP); behind the wrapper that probe fails, so quic-go
disables its kernel offloads and cannot size its receive buffer.
Switch the non-cipher path of both listeners to WrapUDPConn variants,
which forward SyscallConn/SetReadBuffer/ReadMsgUDP/WriteMsgUDP down to
the underlying *net.UDPConn, so quic-go sees an OOBCapablePacketConn
and keeps GSO/GRO + buffer sizing. The same metrics/stats/admission/
limiting still apply.
The quic listener's cipher path keeps the net.PacketConn wrappers,
since CipherPacketConn is not OOB-capable.
+48% throughput (1.80 → 2.69 Gbit/s single-stream, loopback iperf3).
The quic-go receive-buffer warning is gone.
Drop the external github.com/sirupsen/logrus dependency in favor of Go's
standard log/slog package (available since Go 1.21). The core/logger.Logger
interface is preserved unchanged — this is a pure implementation swap.
Key changes:
- slogLogger replaces logrusLogger, backed by slog.Logger + slog.LevelVar
- Custom levelTrace (-8) and levelFatal (+12) constants extend slog's
built-in 4 levels to cover the 6-level logrus-compatible range
- replaceAttr normalises output: lowercase level names and RFC 3339
timestamps with milliseconds (matches previous logrus JSON format)
- Shared *slog.LevelVar ensures WithFields clones inherit parent level
- Level guard (early return) in log/logf avoids wasted fmt.Sprint/Sprintf
when the message level is below the configured threshold
- Documented the stack depth assumption for the caller skip=3 frame
Test coverage (14 tests, all passing with -race):
- convertLevel/revertLevel round-trip for all 6 levels
- levelString for all levels including custom trace/fatal
- replaceAttr timestamp format, level name, groups passthrough
- caller format, level guard, WithFields, IsLevelEnabled, GetLevel
When Accept() returns a transient error, the service enters StateFailed,
sleeps for the retry delay, then loops back to Accept(). Previously the
state stayed StateFailed until the next successful Accept() — which
could take arbitrarily long for idle services, making status observers
see a stale 'error' even though the service has already reconnected
and is ready.
Now set StateReady (and clear lastError) immediately after the retry
sleep. The redundant state transition on successful Accept is removed
since recovery is handled once after the sleep.
When Accept() returns a transient error, the service enters StateFailed,
sleeps for the retry delay, then loops back to Accept(). Previously the
state stayed StateFailed until the next successful Accept() — which
could take arbitrarily long for idle services, making status observers
see a stale 'error' even though the service has already reconnected
and is ready.
Now set StateReady (and clear lastError) immediately after the retry
sleep. Accept will block until the next connection, but the state
correctly reflects that the service is functional.
The /pull handler streams data as base64 chunks with a flush per chunk.
With Nginx proxy_buffering on (the default), these tiny chunks and the
heartbeat newlines are buffered and never flushed to the client, so
active data transfer hangs until the buffer fills or the upstream closes.
Set X-Accel-Buffering: no on the pull response so Nginx forwards each
flushed chunk immediately without buffering. Nginx consumes the header
(it is not forwarded to the client) and it is inert when no proxy sits
in front of the listener, so it is safe to set unconditionally.
Covers pht, phts and h3, which share the same Server handler.
mdutil.GetStrings only matches []string and []any — a plain string from a URL query parameter (routes=0.0.0.0/0) returned nil, silently dropping the route. Added GetString fallback with comma-split, and bare-CIDR support that defaults to config.Gateway. Same fix applied to both tun and tungo listeners.
Services are the only component group that binds a port during parsing
(ParseService calls listener.Init). The generic registerGroup "parse-all-
then-swap" sequence bound every new listener while the old services were
still listening, causing EADDRINUSE on SIGHUP reload — a regression from
82e7e50.
Close and unregister all old services first (unregisterAll relies on
registry.Unregister closing io.Closer values, which frees each service's
port), then parse, bind, and register the new ones. This restores the
pre-82e7e50 close-old-before-bind ordering for the services group.
Mirror WrapConn behavior: WrapUDPConn now increments total and current
connection counts on creation (one accepted UDP client session == one
connection) and decrements the current count on Close. Close is guarded
by a mutex and a closed channel so it is safe to call multiple times —
the current-conns decrement fires exactly once and the monotonic total
is untouched.
The client-facing UDP relay listener (cc) in handleUDP was bound to the
wildcard address, so replies sent from it used the kernel-selected source
IP (the interface's primary IP), which mismatches the BND.ADDR advertised
to the client (the TCP control connection's local IP). Per RFC 1928 6,
compliant clients drop such replies, breaking SOCKS5 UDP associate under
multiple IP aliases where metadata.interface differs from the primary IP.
Bind cc to the TCP control connection's local IP so its reply source IP
always matches BND.ADDR. The outbound relay socket (pc) already binds the
interface IP via Router.Dial -> dialer.ListenUDP(laddr).
Add UnwrapConn() to the admission, proxyproto, conn limiter, metrics,
and observer/stats TCP connection wrappers, complementing the traffic
limiter's limitConn added in the previous commit.
This makes the unwrapConn() helper in handler/sshd able to peel through
any combination of these wrappers uniformly, keeping the pattern
complete and future-proof for handlers that assert on concrete
connection types.
The sshd handler performed a concrete type assertion on the accepted
connection, but the SSH listener's Accept() wraps each connection with
a traffic limiter (limitConn), obscuring the underlying *DirectForwardConn
type and causing 'sshd: wrong connection type' whenever a limiter was
configured.
Add an UnwrapConn() method to the traffic limiter wrapper and an
unwrapConn() helper in the handler that peels through wrapper layers
before the type switch. The handler uses the unwrapped conn only for
the type-specific DstAddr() call, while the original limiter-wrapped
conn drives data transfer so rate limiting remains effective.
Also track the last accept/bind error on service Status so callers can
retrieve the cause when a service enters the failed state.
When using IP aliases on loopback (e.g. ip addr add 10.1.1.1/32 dev lo)
with policy routing, findInterfaceByIP() resolves the alias IP to the
loopback interface. Binding the socket to lo via SO_BINDTODEVICE prevents
outbound traffic from reaching non-local destinations.
Skip SO_BINDTODEVICE when the resolved interface is loopback — source IP
binding via LocalAddr is sufficient for policy routing to steer traffic
through the correct physical interface.
The songgao/water library's InterfaceName parameter is a selection filter
that searches the Windows registry for an existing adapter with a matching
friendly name — it cannot name a new adapter during creation. Passing
name=tap0 caused the library to fail before even creating the adapter.
Fix by omitting InterfaceName from water.New(), letting Windows auto-name
the adapter, then renaming via netsh if a custom name was specified.
Also add directory validation to file handler to catch misconfig early.
Add detailed documentation to the masque handler package covering:
- Package-level doc explaining MASQUE protocol (RFC 9298 CONNECT-UDP,
RFC 9114 CONNECT-TCP) and request flow
- Doc comments on all exported and unexported types, methods, and structs
- Inline comments explaining HTTP/3 context extraction and data path
- Field-level comments on metadata struct explaining each config param
- Step-by-step flow and data path diagrams for handleConnectUDP and
handleConnectTCP
The ssuHandler's connMap cached UDP forwarding connections but spawned
goroutines had no context cancellation mechanism. When relayPacketUDP
exited (e.g. on SS UDP session timeout), orphaned goroutines blocked
indefinitely on ReadFrom, preventing connMap cleanup and leaking both
goroutines and connections.
Fix: derive a cancellable context in relayPacketUDP so all per-session
goroutines are cancelled on exit. Add SetReadDeadline with 30s periodic
timeout so goroutines can check context cancellation instead of blocking
forever. Store cancel functions in a cancels sync.Map for cleanup.
Fixes#821
Add stateless=true metadata option to UDP listener/handler for raw
datagram forwarding without per-client session tracking, similar to
NGINX Stream Module behavior.
- udp.NewListener: add Stateless field to ListenConfig; branch in
NewListener/Accept to skip connPool/listenLoop when stateless
- datagramConn: lightweight net.Conn+PacketConn wrapping a single
UDP datagram — no channels, mutexes, or pool logic
- forward handler: add handleRawDatagram for single request-response
cycle; extract dialTarget to share hop selection/dial/proxyproto
preamble with handleRawForwarding
Usage: gost -L "udp://:10000/127.0.0.1:2000?stateless=true"
The SOCKS5 handler recorder only tracked TCP traffic metrics. UDP
associations (CmdUdp) overwrote TCP byte counts with UDP counts, and
UDP tunnels (CmdUDPTun) recorded zero bytes.
Changes:
- udp.go: change UDP stats assignment (=) to accumulation (+=) so the
TCP control connection bytes tracked by Handle() are preserved.
- udp_tun.go: add xstats.Stats{} wrapper around the PacketConn with
deferred accumulation into HandlerRecorderObject, mirroring the
handleUDP pattern.
- serial/handler.go: capture xnet.Pipe return value in Handle() and forwardSerial()
- serial/handler.go: log Warnf when chain dial returns (nil,nil) before falling back
- sni/handler.go: use defer for read deadline reset to prevent stale deadline
- sni/handler.go: unrecognized protocol returns nil (clean close) instead of error
- sni/metadata.go: update readTimeout comment to reflect dual usage (deadline + SnifferBuilder)
When TLS ClientHello has no SNI extension (ServerName is empty),
normalizeHost returns empty string. Both sniffer_tls.go paths (sniffing
and forwarder packages) previously still called dial with an empty host,
cascading 'missing port in address' errors through connector, router,
and service layers.
Now returns early with a debug-level log, silently closing the
connection instead of attempting to dial upstream.
- Set read deadline before/after sniffing.Sniff to prevent goroutine
leak from slow clients (matches redirect/tcp and ss patterns)
- Log sniff errors at Debug level instead of silently discarding them
- Add nil Router check in dial closure to prevent NPE panic
Add package-level architecture documentation describing two modes
(hop-based forwarding and direct proxy), two-tier dialing strategy
in forwardSerial, recorderConn per-packet logging with direction
convention, and metadata parsing details. No code changes.
- conn.go: Write() now writes first then records b[:n] (not len(b)),
matching the Read() pattern and preventing phantom data on failures.
- handler.go: add nil Router check before h.options.Router.Dial()
in Handle's non-hop path to return a clear error instead of panicking.
- handler.go: guard h.options.Router against nil in forwardSerial;
restructure if/else so ReadTimeout is applied in both the direct
OpenPort path and when the Router has no chain configured.
Bug fixes:
- packetConn.Read: fix slice bounds panic when dlen > len(b);
n was set to dlen (the wire size) but only len(b) bytes were
copied to b; callers doing b[:n] would panic. Clamp n via copy().
- lockWriter.Close: add mutex lock to prevent data race with Write;
Router.Close calls connector.Close (lockWriter.Close) while
handlePacket concurrently calls Writer.Write — both touch w.w.
- Router.DelConnector: delete empty host slices from the map after
removing the last connector, preventing unbounded map growth.
Documentation:
- Package-level doc with architecture diagram, data flow, component
hierarchy, thread-safety invariants, and connector weighting rules.
- Documented every exported type (Connector, Router, ConnectorPool,
ConnectorOptions, routerHandler, metadata, lockWriter, packetConn)
and all non-trivial methods with purpose, parameters, lifecycle,
and algorithmic details (e.g., GetConnector weighted selection).
- Annotated critical code paths with step-by-step comments
(handleAssociate stages, handlePacket routing algorithm,
handleEntrypoint packet format and forwarding logic).
- Explained observeStats retry pattern and metadata key conventions.
Verification: build, vet, 98 tests race-clean.
Translate all Chinese comments in the relay handler package to English:
handler.go, connect.go, bind.go, forward.go, conn.go, entrypoint.go,
observe.go, metadata.go. All inline and doc comments are now in English.
Also set a persistent preference: all future code comments must be
written in English only.
- api: add fillServiceStatus helper and call it from getServiceList/getService
so status field appears in service list and detail API responses
- observeStats: unify retry pattern across all 9 handlers (http, http2, masque,
relay, router, socks4, socks5, tungo, tunnel) — buffer events on failure,
continue to skip fresh collection, clear on success; fix event-loss bug
where interim events were dropped during retry cycles
- handler/router: check WriteTo/Write return errors in associate.go and
entrypoint.go; fix DelConnector and ConnectorPool.Del using RLock instead
of Lock (write under read lock); remove unused fields t and cancel
Allow browser-based dashboards (e.g. Flutter web apps) to access the
metrics endpoint by setting Access-Control-Allow-Origin: * and handling
OPTIONS preflight requests.
The response address sent back to the tunnel client was unconditionally
set to the md5 hash of the tunnel ID. This meant the user's custom host
(e.g. "dash" from "dash:8081") was ignored — the client listened on
the hash instead.
Now the user-supplied host is used directly when:
1. A host is present in the bind address AND ingress is configured.
2. No other tunnel has already claimed that host in ingress
(conflict check via GetRule).
Fall back to the md5 hash when no host is supplied, ingress is nil, or
the host conflicts with an existing ingress rule.
Also fixes indentation in the WriteTo error handling block (extra tab
removed).
Tests:
- TestHandleBind_CustomHost: response AddrFeature uses "dash", not hash
- TestHandleBind_CustomHostConflict: conflicting host falls back to hash
- fakeIngress enhanced with ruleByHost map for conflict simulation