When no live session exists, each request dials a conn of its own and
registers it in Dial, then builds the session on it in Handshake, each
step under sessionMutex. A Dial that finds a registration still waiting
for its handshake takes it for dead (IsClosed reports true while
session is nil) and replaces it, so the earlier request's Handshake no
longer finds its own conn and fails with "unrecognized connection".
Since c210b74 the replacing Dial also closes that conn.
Once requests queue for the lock, another request's Dial can sit
between every Dial and its Handshake, and no session is completed for
as long as requests keep arriving. Seen on a three-hop relay chain
whose last hop is mtls: after the client host woke from sleep, nearly
every request on a chain with ~28 concurrent clients failed this way
for 80 minutes, until the load happened to drop.
Dial now closes the base conn only of a session that was established
and died; a session still waiting for its handshake belongs to the
request that dialed it, whose Handshake either builds the session on
it or closes it. Handshake no longer fails on a mismatch: if the
registered session is live it uses that and closes its own conn,
otherwise it builds the session on its own conn and registers it, and
the other request's Handshake then finds a live session to use.
The tests dial twice before either handshake, in both orders, against
the package's own listener. On master they fail with "unrecognized
connection", and with "use of closed network connection" when only
Handshake is changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OpenTunnel carries a network: "udp" asks the plugin for a datagram endpoint
(one datagram per write) instead of a byte stream, which is what a tun
handler needs — a stream endpoint coalesces and splits packets. tcp stays
the default, so existing deployments are untouched.
The tun handler could not reach client mode without a service forwarder, so
a chain with no forwarder now means client mode with an empty dial address:
the link is transparent and the peer comes from the chain node (a p2p node's
addr is its peer key). That combination used to be dead config, so it is
logged. A forwarder still wins when present, and server mode is unchanged.
Alongside: the udp dialer no longer hard-asserts *net.UDPConn (a wrapped
base dialer yields a plain conn), udp joins the p2p dialer whitelist, and a
udp tunnel announces its client with an empty datagram right after dialing —
the endpoint learns where to send peer packets only from a packet it
receives, so without it a link whose peer speaks first would never come up.
Requires plugin v0.7.0 for the new field.
A session Dialed but not yet Handshaken has session.session == nil; a
concurrent Dial treated it as dead (IsClosed() is true for nil sessions),
deleted it and closed the base conn under the first caller's Handshake.
Re-initializing on the nil-session case self-heals instead of failing
the identity check.
Dial() deleted a dead muxSession without closing session.conn, leaking
the base TCP conn on every dead session (and, once a p2p base conn is
injected, leaking the plugin-side tunnel). session.Close() is a no-op
when session.session is nil, so the base conn is closed explicitly.
Disable Nagle on the underlying TCP socket for relay+ws, relay+wss, and
relay+tls listeners/dialers. Small latency-sensitive datagrams (e.g.
WireGuard UDP over a relay tunnel) were being coalesced by Nagle until a
peer ACK returned, adding up to one RTT per datagram and inflating
round-trip latency (go-gost/gost#858). wstunnel sets TCP_NODELAY on its
tunnel socket by default and keeps latency flat; gost did not.
Add ApplyNoDelay helper and WrapNoDelayListener (mirrors ApplyKeepalive),
applied on the raw *net.TCPConn before the proxyproto/tls wrapper chain.
The issue #777 analysis was written against v3.2.4; commits 5484717 and
9fab332 already fixed the global mutex stall, the no-timeout OpenStreamSync,
and the missing IsClosed reuse check. This addresses the remaining real
problems:
- P1 (HIGH): quicDialer.Dial received the Router's 15s-timeout ctx but
GetConn built its own context.Background()+30s, dropping request
cancellation/deadline. Thread ctx into GetConn so OpenStreamSync honors
the upstream deadline. Mirrored to the icmp dialer (identical structure).
- P2 (MEDIUM): listener QUIC keepalive defaulted OFF while the dialer
defaulted ON, so a server behind NAT without an explicit keepalive=true
could let its return-path mapping expire. Make the listener default ON
like the dialer.
- P3 (LOW): set an explicit MaxIdleTimeout default (90s) on both dialer
and listener so dead sessions are detected sooner; still overridable via
maxIdleTimeout=.
Docs updated to mark the already-fixed root causes and rename the remaining
items P1-P3.
Fixesgo-gost/gost#777
Convert the concrete *mux.Session into a mux.Session interface backed by
smux and yamux, selected via the `mux.type` metadata key. Empty/unspecified
defaults to "smux", so existing deployments are unchanged. Yamux's stream
window is exposed via `mux.maxStreamWindow`.
Also fix TestHandler_observeStats: the retry_after_observer_error subtest
hung because stats.Events() drains (IsUpdated is Swap(false)) and no fresh
event was generated for the recovery tick's fall-through flush. Inject a real
client2 event so the recovery tick has new data to flush.
Plan: docs/plans/2026-07-13-mux-pluggable-yamux.md
Go's crypto/tls (1.24+) appends post-quantum hybrid curves
(X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024) to the
default CurvePreferences. toUTLSCurveIDs blindly cast each
crypto/tls.CurveID to utls.CurveID, but the two enums diverge:
utls v1.8.x does not define the two SecP* hybrids, so the cast
produces unsupported curve IDs and the handshake aborts with
"tls: CurvePreferences includes unsupported curve".
Translate only the four curves utls understands and drop the rest;
return nil when none survive so utls falls back to the fingerprint's
defaults. This is the curve half of #887 (the unsafe cast that
dropped ServerName/InsecureSkipVerify was the first half).
The utls dialer built its config with
(*utls.Config)(unsafe.Pointer(d.options.TLSConfig)), but crypto/tls.Config
and utls.Config are distinct types whose field offsets diverge from
ServerName onward (ServerName: 128 vs 136, InsecureSkipVerify: 160 vs 168).
The cast read garbage for ServerName/InsecureSkipVerify, so
InsecureSkipVerify set by secure:false came back false and the handshake
failed with 'at least one of ServerName, InsecureSkipVerify or
InsecureServerNameToVerify must be specified'.
Replace the cast with toUTLSConfig, which copies fields explicitly and
converts the types that differ between the two packages (Certificate,
ClientAuth, CurveID, RenegotiationSupport, and the VerifyConnection
callback's ConnectionState).
Refs go-gost/gost#887.
perf(pht): eliminate /authorize HTTP round-trip by self-generating CID
Client now uses xid to generate connection IDs, removing one RTT per
tunnel setup. Server lazily creates pipe pairs in handlePush/handlePull
via getOrCreateConn helper, keeping backward compat for old clients
that still call /authorize.
fix: send close notification before canceling context
Close() was canceling c.ctx before calling write(nil). With a canceled
context, http.Client.Do returns immediately without network I/O, so
the close notification POST never reached the server. Swap the order.
fix: add Flush() after heartbeat write in handlePull
w.Write() on http.ResponseWriter goes to a 2k bufio.Writer. Without a
Flush(), the keepalive newline never reaches the wire, causing the
client long-poll GET to time out.
fix: use LoadOrStore in createPipe to prevent concurrent push/pull race
When push and pull HTTP requests arrive concurrently for a new CID,
both saw !ok from Load() and both called createPipe(), overwriting
the first conns entry. The losing pipe pair leaked and the listener
accepted a phantom connection. LoadOrStore makes the first Store win;
the second caller gets the existing pipe.
fix: close conn and delete entry on empty-data POST in handlePush
A POST body of "\n" produced empty data after TrimSuffix, leaking the
pipe pair and conns entry. Now always cleans up before returning.
fix: use caller ctx instead of context.Background() in Dial
Dial(ctx, addr) was discarding the caller context, so deadlines and
cancellation did not propagate to the HTTP long-poll requests.
fix: detect ListenAndServe failure in Init() and clean up goroutine
Non-blocking select on errChan after starting the server goroutine
surfaces immediate failures (port conflict, TLS error) instead of
leaking a goroutine blocked on server.Accept().
chore: remove dead AuthorizePath from Client struct and dialer wiring
Client no longer calls /authorize; the field and its metadata parsing
in dialer/pht, dialer/http3 are dead code.
test: add unit tests for createPipe race, close notification,
empty-data cleanup, and queue-full fallback
When an SSH session behind an SSHD dialer dies silently (NAT timeout,
server disconnect, idle TCP drop), IsClosed() may still return false
because the health-check goroutine hasn't detected the failure yet.
Returning this dead cached session to the connector causes:
ssh: unexpected packet in response to channel open:
Add a Ping(req)-based liveness check before returning a cached session.
If the ping fails, evict the session so the next Dial rebuilds a fresh
connection. This matches the dead-session eviction pattern already used
by the SSH, KCP, QUIC, and ICMP dialers.
Also export the ping-as-health-check as Session.Ping(timeout) so it can
be used by external health probes. Retain the existing ping() helper
unmodified.
The ICMP dialer required explicit keepalive:true to send QUIC PING frames,
while the regular QUIC dialer enabled keepalive by default (10s period).
Without PING frames, quic-go's 30s idle timeout silently killed connections,
causing HTTP+ICMP tunnels to hang after periods of inactivity.
Fixesgo-gost/gost#352
Also in this commit:
- Release session mutex before calling GetConn() in both ICMP and QUIC
dialers, so a dead session doesn't block all Dial() calls while
OpenStreamSync blocks
- Add 30s timeout context to OpenStreamSync to prevent indefinite blocking
if the QUIC session dies between liveness check and stream open
- Guard session cache deletion with pointer identity check to avoid
evicting a newly created session during a race
When a QUIC session's MaxIdleTimeout expires, the underlying *quic.Conn dies
but the dead quicSession stays in the sessions map. The next Dial() finds
it and calls session.GetConn() which calls OpenStreamSync(context.Background())
against the dead connection — blocking indefinitely.
Add IsClosed() to both quicSession types (using the session context's Done()
channel) and add the liveness guard in both Dial() methods, matching the
existing pattern in KCP and MASQUE dialers.
Fixesgo-gost/gost#225
Add a new "utls" dialer type that uses the refraction-networking/utls
library to mimic browser TLS fingerprints (Chrome, Firefox, Safari, etc.).
- x/dialer/utls/dialer.go: Registered as "utls", mirrors the TLS dialer
with Handshake() using utls.UClient() for fingerprint emulation.
- x/dialer/utls/fingerprint.go: Maps string names (chrome, firefox, ios,
safari, edge, randomized, golang, custom) to utls.ClientHelloID presets.
- x/dialer/utls/metadata.go: Parses fingerprint from dialer metadata.
- go.mod: Add github.com/refraction-networking/utls v1.8.2.
The fingerprint is dialer-only metadata — the standard TLS dialer is
untouched. Falls back to crypto/tls if no fingerprint is configured.
Closesgo-gost/gost#31
The QUIC listener and the QUIC/ICMP dialers unconditionally set
tlsCfg.NextProtos = ["h3", "quic/v1"], discarding any user-specified
ALPN values from TLS options (via tls.options.alpn in config).
Clone the TLS config and only apply the default NextProtos when none are
already configured, matching the existing pattern in the MASQUE HTTP/3
dialer. This enables non-HTTP/3 QUIC use cases like SMB-over-QUIC which
requires ALPN "smb".
Fixesgo-gost/gost#872
On Windows, icmp.ListenPacket("ip4:icmp", "0.0.0.0") does not reliably
deliver ICMP packets (golang/go#38427). Add a platform-specific
ListenPacket helper that binds to a discovered interface address and
enables SIO_RCVALL via WSAIoctl for promiscuous receive.
Fixesgo-gost/x#36
Upgrade github.com/pion/dtls from v2.2.6 to v3.1.1 to fix nonce reuse
vulnerability in AES-GCM ciphers (GHSA-9f3f-wv7r-qc8r). Migrate from
deprecated Config-struct API to ListenWithOptions/ClientWithOptions
options-based API.
v2 Config fields mapped to v3 functional options:
- Certificates, InsecureSkipVerify, ExtendedMasterSecret, ServerName,
RootCAs, ClientCAs, ClientAuth, FlightInterval, MTU
ConnectContextMaker removed — no longer exists in v3; explicit
HandshakeContext(ctx) on *Conn is the replacement for timeout control.
- Return errors from MasqueConn.Read/Write instead of silently
succeeding (prevents infinite busy-loops and silent data loss
if these methods are accidentally called).
- Set recorder Network field in protocol dispatch switch rather
than hardcoding "udp", so early errors don't misreport type.
- Clarify that DatagramConn context-ID handling only supports
context ID 0 (sufficient for CONNECT-UDP per RFC 9298).
Add configurable TCP keepalive (keepalive, keepalive.idle,
keepalive.interval, keepalive.count) to all TCP-based transport types:
tls, mtls, mtcp, ws/wss, mws/mwss, http2, ssh, sshd listeners and
tls, mtls, mtcp, ws/wss, mws/mwss, ssh, sshd dialers.
For types that already use "keepalive" for an app-level protocol
(grpc listener/dialer, ws/mws dialers, ssh/sshd dialers), the TCP
keepalive parameters are exposed under the tcp.keepalive.* prefix to
avoid ambiguity.
Also extract shared helpers into internal/net/keepalive.go:
- WrapKeepaliveListener: applies KeepAliveConfig on every accepted TCPConn
- ApplyKeepalive: applies KeepAliveConfig to a single TCPConn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exposes TCP keepalive configuration via listener and dialer metadata:
keepalive: true
keepalive.idle: 30s
keepalive.interval: 10s
keepalive.count: 6
When enabled, the OS sends keepalive probes after the idle period and
tears down the socket if the remote does not respond. This causes any
blocked Read() on a dead connection to return promptly, releasing
goroutines and memory — covering both legs of a CONNECT tunnel (the
inbound client connection via the listener, and the outbound upstream
connection via the dialer).
This is the correct alternative to a hard application-level read
deadline (SetReadDeadline) for detecting dead connections, as it
distinguishes truly dead connections from legitimately idle ones.
Relates to: https://github.com/go-gost/x/issues/91
- Add Header field to PHT Client and clientConn structs
- Support custom headers in authorize, push, and pull requests
- Add metadata parsing for header configuration as map[string]string
- Enables PHT usage with header-based authentication (e.g., Cloudflare Access)
Backwards compatible - headers are optional.
Implement UDP tunneling over HTTP/3 using HTTP Datagrams (RFC 9297):
- Add masque handler for server-side CONNECT-UDP
- Add masque connector and h3-masque dialer for client-side
- Add enableDatagrams option to HTTP/3 listener
- Add shared utilities for datagram connections and path parsing
Resource management and connection caching:
- Add deferred stream cleanup in connector on error paths
- Add IsClosed() and Close() methods to Client for proper session management
- Clean up stale cached clients in dialer before reuse
- Close underlying stream when DatagramConn is closed
- Move RequestStream opening from connector to dialer to enable dead
connection detection and cache invalidation (follows QUIC dialer pattern)