filterToMatcherRule produced `Host(x) || Host(.x) && Proto(...) && ...`,
but the matcher DSL (vulcand/predicate) binds && tighter than ||, so the
apex Host(x) branch bypassed the protocol/path conjuncts — a request to
the apex with a non-matching protocol/path matched where the legacy
filter (AND of host/protocol/path) rejected it.
Also normalize `*.example.com` to `.example.com` before the leading-dot
branch: legacy ParseNode did this so both forms matched apex+subdomains;
without it, `*.example.com` reached Host() as a wildcard matching only
subdomains, dropping apex coverage that `.example.com` still had.
- Parenthesize the host OR when protocol/path conjuncts follow.
- Strip leading `*` + ensure leading dot, mirroring legacy normalization.
- Update TestFilterToMatcherRule expectations; add dot/star combined cases.
- Add TestFilterToMatcherRule_Precedence: runtime guard that apex+wrong
proto/path is rejected and apex/subdomain+valid is accepted, for both
`.example.com` and `*.example.com` forms.
The tunnel bind conns (bindConn/bindUDPConn) now expose their metadata (the
tunneled hostname) via the xctx.Context interface, so the service accept loop
forwards it into the handler context. handleRawForwarding selects a hop node by
that hostname when a hop is configured, so a node with a matching filter.host is
picked; otherwise it falls back to dialing the hostname directly. Fixes
reverse-proxy-tunnel TCP routing (gost#898) where the docs example failed with
"node not available" because the mux path never set the host metadata.
Fold the deprecated per-node filter (host/protocol/path) into an equivalent
matcher DSL rule during config parsing, so hop.Select keeps a single
filtering path (routing.Matcher). Legacy filter configs stay accepted.
- filterToMatcherRule() maps host->Host(), protocol->Proto(), path->PathPrefix();
a leading-dot host emits `Host(x) || Host(.x)` to preserve the legacy
apex+subdomain coverage. Converted rules get priority=-1 to keep the
always-through-selector behavior of the legacy path.
- Remove isEligible/checkHost/checkProtocol/checkPath from x/hop.
core/ chain.NodeFilterSettings is left untouched (dead but harmless).
- Migrate hop tests to matchers; add TestFilterToMatcherRule.
Since 486a7766 HandleHTTP records on an internal clone of the passed
recorder object, TestHandleHTTP_BasicProxy's assertions on the original
ro were always empty, failing every run. Assert SrcAddr/DstAddr on the
captured recorder payload instead, and drain the response body before
closing the net.Pipe so HandleHTTP completes the write and exits cleanly.
Add a json-: match prefix to http.rewriteRequestBody/rewriteResponseBody
that deletes the matched JSON field (sjson.DeleteBytes) instead of
replacing it, keeping the existing value-regex conditional semantics.
Replace the 5 duplicated switch statements that dispatch sniffed
connections (forward/local, forward/remote, http CONNECT, sni, relay)
with per-package registries keyed by proto string. Protocol handlers
self-register via init(), following the house registration pattern, so
adding a protocol no longer requires touching every dispatch site.
Unify the two HandleOptions types (forwarder lacked dialTLS; sniffing
lacked hop/node/httpKeepalive) into one exported struct in sniffing;
forwarder aliases it. Unknown protocols fall through to raw forwarding
exactly as before; SSH remains detection-only. redirect/tcp's special
fallback-replay dispatch is intentionally left untouched.
Adds Dispatch helpers and 8 registry tests (register/get/duplicate/
unknown/known) in both packages. Net -151 lines.
POST /config without an explicit path= always wrote gost.yaml/gost.json to
the process CWD, silently leaving the config loaded via -C untouched. Track
the last file read by ReadFile and default the save target to it, deriving
the output format from the file extension so a JSON config is saved back as
JSON. Falls back to the CWD default when no config file was loaded.
WrapConn stores the target and only writes it on first I/O, but the
connector encoded it into a bufpool buffer that was returned to the pool
when Connect returned. A later Connect could reuse the backing array and
overwrite the bytes, misrouting the first connection. Allocate the target
per connection instead of sharing a pooled buffer.
Regression test drives two Connect calls and asserts the first conn's
wire target survives a second Connect reusing the pool.
Failed nodes (http.failCodes marker) were still selected when the
priority short-circuit fired or when FailFilter saw a single-node tier,
so failover never happened for a high-priority matcher node returning
429. Check IsFailed first in the candidate-pool scan, and when every
candidate is failed return the first node matching the request gates as
a best-effort last resort instead of nil.
Refactors the fail-code check into shared selector.IsFailed (cee264e)
wired through FailFilterSettingsOption (64ea518).
Extract the per-item failed-state decision (failure count >= maxFails,
last failure within failTimeout, metadata overrides) from FailFilter
into an exported IsFailed helper, and reuse it in the hop's
pre-selection dead-node filter instead of a local duplicate.
http.failCodes (and probe) marking had no effect when the failing node
had a strictly higher matcher priority: the priority short-circuit
returned the top node directly, and FailFilter no-ops on a single-node
tier (len(vs) <= 1 guard), so the marked node was always re-selected
and failover never happened.
Pre-filter nodes marked failed (same Count >= maxFails && within
failTimeout criteria as FailFilter) out of the candidate pool before
priority selection. Single-node hops keep their lone node (safety net),
and an all-failed pool returns nil as before.
Wired maxFails/failTimeout from the hop selector config into the hop
via FailFilterSettingsOption.
Pulls in the classic-AEAD UDP [addr][payload] response-framing fix that
restores shadowsocks UDP interop with third-party servers/clients
(go-shadowsocks2, shadowsocks-libev). Fixesgo-gost/gost#894.
slog.Logger.With does not deduplicate keys, so chained WithFields calls
(e.g. service -> listener/handler, each setting "kind") emitted duplicate
JSON keys. slogLogger now rebuilds from a pristine base logger with
accumulated attrs deduplicated by key, last write wins.
Commit b3b5986 crammed the URL path into the listen/connect address and
disabled forward mode for path-based protocols, breaking the
`unix://a.sock/b.sock` (listen on a, forward to b) port-forward form and
producing an invalid `a.sockb.sock` address.
Distinguish the two URL forms by host presence:
- three-slash (unix:///abs.sock, empty host) → path is the absolute socket
address, no forward target;
- two-slash (unix://a.sock/b.sock) → host is the socket and the path is a
forward target (service) or ignored (node).
Add regression tests for both forms.
Co-Authored-By: Claude (claude-opus-4-8[1m])
ParseAuthorizedKeysFile looped over ssh.ParseAuthorizedKey, but x/crypto's
parser splits on '\n' and returns rest='\n' for a trailing newline. The next
iteration called ParseAuthorizedKey('\n'), which skips whitespace and returns
'ssh: no key found', aborting the whole parse. Any normal authorized_keys file
(ends with newline, or has comments/blank lines) failed despite being valid.
Break the loop once rest is exhausted so all keys are parsed.
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.
Add RESP protocol detection to the sniffing framework so plaintext
Redis traffic on a port-reuse listener can be identified and routed by
protocol. Sniff() now peeks 1 byte first to match RESP type bytes
(*+-:$) without blocking, then falls through to the existing 5-byte
TLS/HTTP/SSH detection.
ParseRedisMetadata consumes exactly one RESP message via io.TeeReader
and records command/key in HandlerRecorderObject.Redis. HandleRedis in
both the sniffing (injected-dial) and forwarder (hop-selected) packages
replays the captured first message and pipes the rest, so pipelined
commands beyond the first are forwarded transparently.
Wired into all 5 dispatch sites: sni, forward/local, forward/remote,
http CONNECT, and relay tunnel handlers. forwarder paths route via
resolveRedisNode using ProtocolSelectOption(ProtoRedis), enabling
`protocol: redis` hop selection.
Part of two-phase plan (2026-07-29-sniffing-redis-postgres.md);
PostgreSQL deferred to phase 2.
Extract PopulateQUICClientHello/PopulateQUICServerHello into
internal/util/sniffing/quic.go to deduplicate the QUIC ClientHello/ServerHello
population logic that was copy-pasted across forward/local (Handle, handleRawDatagram)
and redirect/udp handlers (3 sites → 2 helpers).
- handler/redirect/udp: add QUIC ClientHello + ServerHello sniff with
sniffing/sniffing.timeout metadata, populating ro.Proto/ro.TLS for recording.
- handler/forward/local: replace inline QUIC blocks with shared helpers;
use h.md.bufferSize instead of hardcoded 8192 for sniff reads.
- internal/util/sniffing: add ProtoQUIC constant.
Bump quic-dissector dependency to v0.1.0.
Adds a RedisCache config block and redisCache implementation for the
generic cache.Cache interface, enabling distributed HTTP response
caching across gost instances.
go-redis/redis/v8 was already a dependency (used by recorder and
internal loader) — no new imports needed.
The service Serve loop counted every handler error — including client-side
disconnects (io.EOF, net.ErrClosed, context cancellation) and policy
decisions (rate limiting, bypass) — as server handler errors, inflating
the error counter and log noise. isServerError() now gates the error-level
log, error counter, and stats increment; non-server errors are logged at
debug level only.
The previous empty-SNI fix clobbered ro.Host with the listener's local
address. For handlers that set ro.Host before HandleTLS (SOCKS5 via the
CONNECT address, SS via target), that destroyed the real destination used
for recording and bypass. Now ro.Host is only set when SNI is present;
the caller's pre-set value is left intact otherwise.
Clients connecting to HTTPS by raw IP (e.g. https://1.1.1.1) don't
send SNI per RFC 6066. Both HandleTLS methods errored immediately
on empty SNI, closing the connection. Now they fall through to the
normal routing path — hop selection in the forwarder, or the caller's
dial closure in the sniffing sniffer (e.g. HTTP CONNECT) — which
handles the empty host gracefully or fails naturally.
Same leak class as the previous chainGroup fix: a forwarder built from
a hopGroup or inline nodes starts probe goroutines at parse time, but
nothing closed them — every SIGHUP reload leaked them permanently.
- parseForwarder returns an io.Closer for service-owned hops (hop
group or inline nodes); registry-owned named hops return nil since
the registry closes them on unregister
- ParseService registers the closer via ClosersOption and closes it
when construction fails partway
- hopGroup.Close no longer closes the underlying hops: they come from
the HopRegistry and are shared with other referents; closing them
here would kill shared state
Adds tests for the new chainGroup code paths the matcher+probe review flagged
as untested: matcher filtering in selectChain, probe goroutine cleanup on
Close(), and probe marking the entry marker on cmd failure. All run clean under
`CGO_ENABLED=1 go test -race`.
chainGroup and hopGroup had verbatim copies of the cmd-probe block (timeout
default, CmdProber run, marker Mark/Reset, log). Move it to
internal/probe.RunCmdProbe(cfg, marker, log) so both call sites share one
implementation (cleanup5 from the chainGroup review).
In selectChain: precompute whether any entry has a matcher and, when none do,
pass entries straight to the selector instead of allocating a new slice on every
Route() call. Drop the single-eligible-entry shortcut so the selector's
FailFilter always runs and can exclude probe-failed chains (design6).
Combined fix for efficiency4 (hot-path allocation) and design6 (single-match
bypass of FailFilter/probe markers) from the chainGroup review.
probeEntry used context.Background() for Route/Dial, so an in-flight probe
could not be cancelled by Close()/reload. Thread the lifecycle ctx through
probeEntry so a stuck route aborts when the group is torn down.
Also promote mapstructure to a direct dependency (now imported by config.go).
chainGroup.Close() existed but was never called: the group is wrapped
in a chain.Router held by the listener/handler, and service.Close()
only closed the handler, observer, and listener. Every SIGHUP reload
leaked the old config's probe goroutines permanently.
Wire the close path end to end:
- Router.Close() closes its chain when it implements io.Closer
- service ClosersOption registers io.Closers called on shutdown
- ParseService passes both routers via ClosersOption, and closes
them on construction failure so probes started early don't leak
chainGroup now selects chains in two stages: a matcher filter (by host/network)
builds an eligible pool, then the group selector applies FailFilter + strategy.
Each entry carries its own failure marker so chain-level probe marking is
independent of node-level failCodes markers (probe Reset() can't undo a node
failCodes Mark()).
Config gains ChainGroupEntry (chain/matcher/probe) replacing the old []string,
with a mapstructure decode hook preserving backward-compatible "chains: [a, b]"
string lists and time.Duration string parsing. Probe goroutines run per-entry
and mark the entry on failure.
Co-Authored-By: Claude <noreply@anthropic.com>
hopGroup wraps named hops with per-entry matcher + probe, implementing
hop.Hop for drop-in compatibility with the existing Forwarder.Forward().
Core design: hopEntry.marker (probe, hop-level) is independent of
node.Marker() (failCodes, node-level) so probe Reset() cannot undo a
failCodes Mark() — the two mechanisms no longer conflict on the single
chain.Node marker.
- hop/group.go: HopEntry + hopGroup + probe infrastructure
- config/config.go: ForwardHopGroupConfig, ForwardHopConfig types
- config/parsing/service/parse.go: parseHopGroup, HopGroup > Hop > Name > Nodes precedence
- config/parsing/node/parse.go: export parseProbeConfig → ParseProbeConfig
Stage 3 shortcut now gates on isBackupNode(maxPriNode) instead of
hasBackup — the old check disabled priority short-circuit whenever any
backup existed anywhere in the pool, even when the winning node was a
primary.
Stage 4 tier loop gains hasAnyPrimary scan + backup-tier skip — a
lower-priority primary always beats a higher-priority backup tier.
Nil-selector path now returns the first node of the first non-skipped
tier instead of unconditionally returning maxPriNode.
When sniffing detects TLS but the ClientHello has no SNI, HandleTLS
errors with 'tls: sni is empty, closing connection'. Previously this
error was returned unconditionally even with sniffingFallback enabled.
Now, bytes consumed by the sniffer are captured via io.TeeReader and
replayed through io.MultiReader when sniffingFallback is true, allowing
the connection to fall through to raw forwarding to the original dstAddr.
The HandleHTTP ro clone (486a776) only updated the local ro variable, but
dial() reads ho.recorderObject directly, getting the original with nil HTTP
field, causing nil pointer dereference at ro.HTTP.StatusCode access.
The cache-hit path (serveCachedResponse) recorded on the same HandlerRecorderObject
as the calling handler's deferred Record(), producing two records per request.
Copy ro at HandleHTTP entry so the sniffer's internal recording stays isolated
from the caller's defer. Also zero ro.Time when the sniffer takes over, so the
handler's defer Record() skips the now-redundant connection-level record.
Fixes 10 files across sniffer and all 8 handlers that support sniffed HTTP forwarding.
Implements the cache.Cache interface with in-memory backend (TTL,
maxSize/maxBytes bounds, oldest/LRU eviction, background cleanup).
Adds httpcache layer for HTTP response policy (per-status TTL,
serve-stale, method/body filtering). Wires into config parser,
loader, registry, and all four sniffing handlers (forward/local,
forward/remote, sni, http) so HTTP responses are cached before
dialing upstream.
Bumps core dependency to v0.6.0 for the new cache.Cache interface.
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
bodySize=0 now uses DefaultMatcherBodySize (1MB) instead of disabling
body reading, so BodyRegexp/BodyJSON matchers work out of the box.
MaxMatcherBodySize raised to 10MB. bodySize=-1 opt-out removed
(no real use case).
The rtcp listener now stores the address returned by Router.Bind() separately
from the requested bind address. This allows callers to distinguish between
the requested prefix (may be rejected by the relay) and the actual server-
assigned address (used for the entrypoint URL).
Adds a 3-parameter form to Header(), Query(), and BodyJSON() matchers
for numeric comparisons: Header("Content-Length", "gt", "1024"). Extracts
the duplicated comparison setup and dispatch into shared compareOp and
parseCompareParams helpers. Includes tests for all six operators across
header, query, and body JSON matchers.
Populate the existing Node field on HandlerRecorderObject after every
hop.Select() call in forwarder code paths. Previously the field was
never set, though it was already consumed by tunnel entrypoint HTTP
handler (ephttp.go:145) and tested for round-trip in recorder tests.
- routing: add BodyJSON(path, regexp) matcher using gjson for JSON path
field lookup instead of regex against raw body bytes
- config: detect 'json:<path>[=regex]' prefix in Match field for
rewriteRequestBody/rewriteResponseBody, using gjson query + sjson set
- forwarder: add shouldApply() helper for rewrite type matching; JSON
rewrites match only application/json content type
Supports three modes via Gost-Record request header:
- (absent)/full: full recording with body (existing behavior)
- headers: metadata only, skip body/payload capture
- off: no recording at all
Header is stripped before forwarding. Per-request granularity
via HandlerRecorderObject.RecordMode field propagating through
the ro clone chain to all sub-paths (WS frames, H2, sniffing).
Collapse the separate anyBackupNode scan and partitionByPriority allocation
into the single candidate loop. Replace the old short-circuit + tiered logic
with four explicit stages:
1. Single-pass candidate scan — priority stats + backup detection
2. Priority short-circuit — top node wins when strictly higher, no backups
3. Uniform fast path — skip sort when all priorities equal (common case)
4. Tiered fallthrough — sort once, feed each priority tier to selector
Removes two helper functions and an extra O(n) allocation. Fixes hasBackup
to use mdutil.GetBool for consistency with BackupFilter.
When nodes match with different priorities (more-specific vs broader
matchers), select only within the highest-priority tier first.
Round-robin / FailFilter still apply within a tier for load balancing.
If the top tier has no surviving node (all dead), fall through to the
next tier, preserving fallback semantics.
ForwardNodeConfig has a Probe field but parseForwarder was missing it
when converting to NodeConfig, silently dropping probe configuration
for forwarder nodes. cmd probe works regardless (no transport needed),
tcp/http probes are limited to the default tcp dialer (plain TCP).
Adds SOCKS5 handler and connector support for Tor-specific RESOLVE (0xF0)
and RESOLVE_PTR (0xF1) commands, enabling tools like torsocks and
tor-resolve to work through GOST when forwarding to a Tor SOCKS5 proxy.
- Define CmdResolve (0xF0) and CmdResolvePTR (0xF1) in x/internal/util/socks
- Add ContextWithSocks5Cmd/Socks5CmdFromContext for per-request command override
- Connector reads context command, sends it upstream, returns resolveConn
- resolveConn wraps net.Conn and exposes resolved addr via Metadata()
- Handler dispatches 0xF0/0xF1 to handleResolve/handleResolvePTR
- Gate behind enableTor metadata (tor=true), reject with CmdUnsupported by default
- 9 unit tests across ctx/, connector/, handler/ packages
The whitelist comments described the opposite of actual behavior: in
whitelist mode matching addresses go through the proxy chain and others
bypass, not the other way around.
Excludes Go test binaries (*.test), coverage output, generated certs,
stray Node tooling, and index/IDE/agent dirs (.codegraph, .claude,
.remember) so they don't get committed.
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
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
Response header overrides were applied before rewriteRespBody, so overriding
Content-Type caused the body rewriter to see the override instead of the
original upstream value, breaking the streaming-vs-non-streaming decision.
Move responseHeader and ro.HTTP.Response capture after the body rewrite.
fix(hop): document Select() stages with inline comments
build(deps): bump plugin v0.4.0 → v0.5.0
- rewriteURL: split replacement at '?' so query portion goes to RawQuery
instead of being percent-encoded as %3F in Path
- requestHeader: empty value now deletes the header instead of setting empty
- hop: add stage-by-stage doc comments to Select() method
Extracts the verified mTLS peer certificate (CN, SANs, SHA-256
fingerprint) from the TLS connection in the HTTP handler after the
handshake completes, stores it in the request context, and forwards
it to both gRPC and HTTP auth plugins via new AuthenticateRequest
fields (client_cn, client_san, client_cert_fingerprint).
Capture client-sent Host/URI/headers/body before node HTTP rewrites are applied, so the recorder sees both the original and forwarded values. Adds a borrowBodyPrefix helper that inspects a body prefix without consuming the stream. Also serializes file recorder writes unconditionally (the underlying io.WriteCloser is not safe for concurrent Write) and drops a debug log from the bodyRegexp matcher.
The forwarded body keeps its original encoding; only the prefix shown to
body matchers is decoded so BodyRegexp sees plaintext regardless of
Content-Encoding. Best-effort: a truncated compressed stream yields
whatever decoded so far, where leading JSON fields live.
Lets a node match routing rules against a size-capped prefix of the
HTTP request body. The forwarder HTTP sniffer reads the prefix before
node selection when any node in the hop opts in via matcher.bodySize
(capped at MaxMatcherBodySize = 1MB), restores the stream, and passes
the prefix through hop.Select to the matcher.
- routing: BodyRegexp predicate; Body field on routing.Request
- chain/hop: plumb Body through SelectOptions
- config: matcher.bodySize on NodeFilterConfig
- sniffer_http: aggregate max body size across hop nodes, read+restore
- registry: NodeList wrapper forwards Nodes() so body-size is visible
for named hops
- bump go-gost/core to v0.5.3
Add udp.bindRange.min/max (udp.minPort/maxPort) metadata to constrain the
client-facing UDP relay socket to a fixed port set, so NAT routers can
forward a small range to the SOCKS server. listenPacketInRange scans
ports from a random offset within [lo, hi] and retries on EADDRINUSE;
with no range configured, the OS chooses (port 0) as before. RFC 1928
leaves relay-port selection to the server, so BND.PORT reports the
actually-bound port.
Expose HTTP request direction (request/response) and request URI to
Rewriter plugins via metadata, alongside the existing sid. Consolidate
sid/direction/uri assembly into baseMetadata helpers and remove the now
unused sidMetadata function.
eventsource_stream requires \n\n to release each SSE event. The end phase
(EOF and scanner-error paths) wrote rewritten data without the terminator,
so response.completed was buffered and never dispatched. Adds writeSSEStreamDelim
helper which only appends \n\n when the data doesn't already end with it.
Adds scanner error end phase path that was missing entirely.
When handler/relay bindUDP and handler/socks5 udpTun create a raw *net.UDPConn,
domainAddr targets from udpTunConn.ReadFrom cannot be consumed by WriteTo (which
expects *net.UDPAddr). Wrap with resolvePacketConn BEFORE metrics/stats wrappers
so domain targets go through hostMapper → configured resolver → system DNS
instead of failing or resolving via system resolver.
Also wrap handler/http handleUDP for the same reason.
Fixes gost#882 part 2 (relay exit server side DNS leak).
- HTTPBodyRewriteConfig gains maxChunkSize field (YAML: maxChunkSize)
- newRewriteBody replaces the unconditional skip of chunked non-streaming
bodies with bounded buffering: reads up to maxChunkSize+1 bytes,
rewrites if fits, passthrough if overflows
- Default maxChunkSize is 1MB when no rule sets one
- Compressed chunked bodies: decompress → rewrite → recompress when fits
- rewriteRespBody/rewriteReqBody clear Transfer-Encoding only when
Content-Length is set (fixes latent stale chunked header bug)
- Replace recursive b.Read(p) with for loop to prevent O(N) stack growth
on consecutively filtered SSE events
- Skip Pattern gating for end-of-stream signal so Rewriter plugins with
match patterns still receive the lifecycle signal
- Cache sid once in rewriteBody struct instead of extracting from context
on every Read call
- Add event_index to end-phase metadata for consistency with start/event
phases
The first SSE event now carries sse_phase:"start" (detected via eventIndex==0)
instead of a separate apply(nil, start) call before any real events. This
avoids both synthetic JSON bodies and metadata-only empty-body calls for
the start phase, while preserving the end-of-stream apply(nil, end) signal
needed for trailing events like message_stop.
Also adds comprehensive doc comments explaining the three processing modes
(non-streaming plain, non-streaming compressed, SSE streaming with lifecycle).
SSE start/end phase signals now use nil body + metadata-only RewriteOption
instead of synthetic JSON events injected into the data stream. The Rewriter
decides what to produce from the phase signal — nothing enters the stream
unless the plugin explicitly returns data.
The apply() guard is relaxed from `len(body) == 0` to
`len(body) == 0 && len(opts) == 0` so that metadata-only phase signals
reach the Rewriter. Event-level metadata (sse_phase, event_index, sid)
is retained since it annotates real events without injecting fake data.
Pass session ID metadata to plugin Rewriters in non-streaming rewrite
paths (plain and compressed) for consistency with the SSE streaming path.
Also restore SSE lifecycle metadata (start/end phase emission, event index
tracking) and fix a missing Rewriters config merge in parser.
The non-streaming paths now carry sid metadata via apply(), while the SSE
path provides sid, sse_phase (start/event/end), and event_index for each
stream event.
Previously, HTTP body rewrite was silently skipped when the response had
a Content-Encoding (gzip, deflate, br, zstd). Now the body is decompressed,
rewritten, and recompressed transparently, making rewrites work with
production servers that serve compressed content.
Supports gzip and deflate via stdlib, br via andybalholm/brotli, and
zstd via klauspost/compress/zstd. SSE + compressed still skipped as
compression is never used with streaming event-stream.
Introduce a unified rewriteBody io.ReadCloser wrapper that handles both
streaming (text/event-stream) and non-streaming body rewrites through
the same code path. SSE events are rewritten individually per-event,
while finite bodies are buffered and rewritten at once. Both
rewriteRespBody and rewriteReqBody are simplified to delegate to this
wrapper.
When a rewrite rule has both match and rewriter set, the body is only
sent to the rewriter plugin if it matches the pattern. A nil pattern
(non-empty match) means unconditional rewrite via the plugin.
Coincidence: fix indentation drift in the previous commit's rewrite
condition blocks (tab alignment was off by one level).
Each rewriteBody/rewriteResponseBody/rewriteRequestBody rule can now
optionally reference a named rewriter plugin (HTTP/gRPC backend) via
the rewriter field. When set, body rewriting delegates to the plugin
rather than applying pattern/replacement, while content-type filtering
still applies.
Coincident fixes:
- rewriter/plugin/grpc: return nil when conn is nil (avoid nil-ptr panic)
- rewriter/plugin/http: drain response body in defer to prevent leaks
- config/parsing/rewriter: pass TimeoutOption to gRPC plugin
- config/parsing/service: warn when referenced rewriter is not registered
Add the first implementation of core/rewriter.Rewriter as a plugin-only
component following the bypass/admission pattern (single-value, no
RewriterObject). Includes:
- plugin/rewriter/proto/: protobuf definition with Rewrite RPC returning
transformed data in the reply
- x/rewriter/plugin/: gRPC and HTTP plugin clients
- x/registry/rewriter.go: hot-reload-safe RewriterRegistry with wrapper
- x/config/config.go: RewriterConfig, Config.Rewriters, ServiceConfig.Rewriter
- x/config/parsing/rewriter/parse.go: config parser (plugin backends only)
- core/handler/option.go: Rewriter field + RewriterOption on handler.Options
- x/config/loader/loader.go: rewriter registration during startup
- x/config/parsing/service/parse.go: inject rewriter into handler options
After RewriteURL mutates req.URL.Path, ro.HTTP.URI still held the
pre-rewrite value because the recorder object was initialized before
the rewrite loop. Update ro.HTTP.URI to match the rewritten path so
the recorded HTTP request reflects the URI actually sent upstream.
SOCKS5 UDP datagrams carrying ATYP=DOMAINNAME were force-resolved via
net.ResolveUDPAddr in udpConn.ReadFrom, which always used the system
resolver and ignored any configured handler resolver (resolver=1.1.1.1),
leaking the query locally. The premature domain→IP conversion also
stripped the hostname before it reached the relay chain, so the exit
node could not resolve DNS itself.
udpConn.ReadFrom now returns a domainAddr for domain targets, letting
the name flow untouched through udp.Relay into the upstream WriteTo.
Chain-backed PacketConns (udpTunConn, udpRelayConn) encode it as
ATYP=Domain and forward to the exit. Direct (no-chain) associations
yield a raw *net.UDPConn that cannot consume a domainAddr, so they are
wrapped with resolvePacketConn, which resolves via hostMapper →
configured resolver → system DNS.
Introduces WhitelistedAuthenticator that wraps an auth.Authenticator to skip
authentication when the client IP matches configured CIDR ranges or exact IPs.
The wrapping happens at service-parse time, transparently covering all handler
types (HTTP, SOCKS4/5, relay, http2, SSH).
Usage:
gost -L 'user:pass@:5999?skipauth=192.168.0.0/24,172.22.22.22/32'
When two or more nodes have an identical matcher rule they receive
identical default priority (rule string length). The priority shortcut
assumed the top-priority node was the single authoritative choice and
returned it directly, bypassing the selector (FailFilter, BackupFilter,
strategy) — so weight, round-robin, and hash were silently ignored.
Add a third condition: the top priority must be strictly greater than
the second-highest. When multiple nodes share the same highest priority
the selector applies normally for load balancing.
Add doc comments on NodeMatcherConfig.Priority explaining the three
semantic ranges (0=auto, negative=always-selector, positive=shortcut).
Sniff() calls Peek(5) which blocks on SOCKS5 greeting (3 bytes:
VER, NMETHODS, METHODS). The SOCKS5 connector sends those 3 bytes
and then waits for the server's method-selection reply — deadlock.
Restore the original 1-byte peek for SOCKS4 (0x04) / SOCKS5 (0x05)
detection first, routing them to their handlers immediately. Only
call Sniff() for non-SOCKS traffic (TLS, HTTP, SSH) where 5 bytes
are guaranteed.
Fixesgo-gost/gost#879
Add a "mode" metadata key to the unix listener so users can set the
file permissions on the unix domain socket after it is created. This
allows programs running under a different user/group to connect to the
socket without requiring a global umask override.
Values accept Go-style octal (0660), Go literal (0o660), or decimal
(432), both via URL query params and YAML config files.
Fixesgo-gost/gost#878
- selector.go: Select() now only returns methods the client actually offered,
defaulting to MethodNoAcceptable (0xFF) when no mutually-supported method
exists. Prevents protocol desync when authenticator is set but client
doesn't offer UserPass. (#1, #2)
- connect.go: CONNECT success reply now carries the outbound connection's
actual local address as BND.ADDR per RFC 1928 §6, instead of 0.0.0.0:0. (#4)
- udp.go: UDP ASSOCIATE now verifies the upstream chain is reachable before
sending Success to the client; on failure sends a proper Failure reply.
Also adds safe type assertions for net.TCPAddr and net.UDPAddr to prevent
panics on wrapped connections, and returns errors on DNS resolution failure
instead of silently dropping datagrams. (#5, #1, #2, #3)
- Extract newRecorderObject, checkRateLimit, sniffingDial, and
handleSniffedProtocol from the Handle method into dedicated files
(sniffing.go, util.go) to reduce Handle from ~100 to ~65 lines.
- Introduce SnifferBuilder: populated once during Init, reused via
Build() per connection. Avoids reconstructing a 9-field sniffing.Sniffer
literal on every inbound connection.
- Hoist router nil check from the lazy dial closure into Handle so
misconfigured handlers fail immediately instead of silently dropping
unrecognised traffic with no error.
- Add nil-addr guard in checkRateLimit (missing from the old inline
version — would panic if the remote address were somehow nil).
- Add comprehensive unit tests (44 tests across 5 test files) covering
handler creation, Init, metadata parsing (defaults, custom, MITM,
error paths), protocol dispatch (HTTP, TLS, unknown, empty), rate
limiting, recorder objects, dial plumbing, and SnifferBuilder.
- Add package-level godoc explaining the handler scope, limitations
vs tcp/forward, and the connection processing flow.
- Fix metadata comment: s/SnifferBuilder/sniffing.Sniffer/.