Compare commits

...
69 Commits
Author SHA1 Message Date
ginuerzh a7041074a8 feat(handler): add configurable UDP port range for SOCKS5 UDP ASSOCIATE
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.
2026-07-02 21:04:19 +08:00
ginuerzh d024c7786e feat(forwarder): pass direction and uri metadata to Rewriter plugins
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.
2026-07-02 20:12:04 +08:00
ginuerzh d8317d876f fix(forwarder): append SSE \n\n terminator after end phase data, guard against double delim
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.
2026-06-30 20:46:12 +08:00
ginuerzh 69c397ded9 fix(handler): wrap raw *net.UDPConn with resolvePacketConn to prevent DNS leak on relay exit
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).
2026-06-30 12:29:37 +08:00
ginuerzh 7f36d10605 add MaxChunkSize for bounded buffering of chunked non-streaming bodies
- 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)
2026-06-30 11:02:48 +08:00
ginuerzh 5b1ff8330b fix(forwarder): default rewriter to match all content types when type is empty 2026-06-28 22:39:39 +08:00
ginuerzh 3193eebd6c fix(forwarder): resolve SSE rewrite bugs from code review
- 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
2026-06-28 19:59:12 +08:00
ginuerzh c71b16490b refactor(forwarder): attach SSE lifecycle phases to real events, remove synthetic start signal
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).
2026-06-28 19:25:30 +08:00
ginuerzh ae73d1ba61 refactor(forwarder): replace synthetic SSE phase JSON with pure metadata signals
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.
2026-06-28 19:04:48 +08:00
ginuerzh 4c086685b1 feat(forwarder): add sid metadata to non-streaming body rewrite paths
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.
2026-06-28 18:42:58 +08:00
ginuerzh 56c72823cb feat(forwarder): decompress, rewrite, and recompress body when Content-Encoding is set
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.
2026-06-28 14:44:52 +08:00
ginuerzh 2a9e059bc2 feat(forwarder): support SSE streaming body rewrite with unified wrapper
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.
2026-06-28 13:26:34 +08:00
ginuerzh f63f36805b fix(forwarder): enforce match pattern as content filter when rewriter is set
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).
2026-06-27 23:19:54 +08:00
ginuerzh 1b07475bf3 feat(forwarder): support plugin-based rewriter in HTTP body rewrite rules
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
2026-06-27 22:52:17 +08:00
ginuerzh 3b25e0317f feat(rewriter): implement plugin-based Rewriter module with gRPC and HTTP backends
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
2026-06-27 21:44:13 +08:00
ginuerzh fb0cf72446 fix(forwarder): record post-rewrite URI in HTTP recorder
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.
2026-06-27 18:50:46 +08:00
ginuerzh ee7b1e9462 fix(handler/socks5): resolve UDP domains via configured resolver to stop DNS leak
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.
2026-06-27 16:12:36 +08:00
ginuerzh 143438a30e feat(auth): add skipauth metadata option to bypass auth for whitelisted client IPs
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'
2026-06-27 14:33:40 +08:00
ginuerzh cd97e5e746 fix(hop): skip priority short-circuit when multiple nodes share the same priority
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).
2026-06-26 22:53:17 +08:00
ginuerzh c1d954d689 fix(handler/auto): sniff SOCKS4/SOCKS5 with 1-byte peek before 5-byte Sniff()
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.

Fixes go-gost/gost#879
2026-06-26 20:17:49 +08:00
PahaKush d4e9450bad fix(connector/relay): handle empty address in bind to fix UDP ASSOCIATE
Fixes a bug where an empty bind address (as in SOCKS5 UDP ASSOCIATE) bypassed the SplitHostPort guard and passed `""` to `AddrFeature.ParseFrom`, which returns an error for empty hosts. Default to `0.0.0.0:0` (any address, any port) when address is empty.
2026-06-26 19:48:47 +08:00
ginuerzh b777bcb81a feat(listener/unix): add mode metadata to chmod unix socket file
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.

Fixes go-gost/gost#878
2026-06-25 16:04:04 +08:00
ginuerzh 39580f04ca fix(handler/socks5): RFC 1928 compliance — method negotiation, BND.ADDR, UDP ordering, and safe type assertions
- 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)
2026-06-25 15:41:15 +08:00
ginuerzh b01f5d065f refactor(handler/sni): extract helpers, add SnifferBuilder, comprehensive tests
- 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/.
2026-06-25 14:51:13 +08:00
ginuerzh 8fd5200bf1 fix(net): defer DNS resolution to final dialer to preserve domain in proxy chain
Commit 0522ae8 added inline DNS resolution (PreferGo: true) in Resolve(),
resolving domain names to IPs before routing. This caused the SOCKS5
connector to send CONNECT with ATYP=IPv4 instead of ATYP=domain, breaking
chains where the backend (e.g., ssh -D) cannot reach the gost-resolved IP.

Revert the DNS fallback in Resolve() — return the address unchanged so
the original hostname flows through the proxy chain. Instead, add
net.Resolver{PreferGo: true} to the TCP dialer in internal/net/dialer,
which resolves DNS at the point of actual TCP connection — still pure
Go (no cgo thread exhaustion) but after the proxy chain routing is done.

Also rename resovle.go → resolve.go (fix typo).

Fixes go-gost/gost#877
2026-06-25 11:02:36 +08:00
ginuerzh ec4791485c feat(routing): add Network matcher for per-protocol node selection
Add Network() matcher to the routing DSL so nodes can be selected based on
connection network type (tcp/udp). Uses prefix matching so Network("tcp")
matches tcp, tcp4, tcp6 and Network("udp") matches udp, udp4, udp6.

- core/routing: add Network field to Request struct (core v0.4.3)
- x/routing/matcher: add network() matcher registered as "Network"
- x/hop: pass options.Network to routing.Request in Select()

Fixes go-gost/gost#703
2026-06-24 17:38:08 +08:00
ginuerzh c06ba17916 fix(dialer): skip SO_BINDTODEVICE when interface is specified as IP address
When the user specifies an IP address as the interface (e.g.
interface=10.1.1.1), GOST resolved it to its hosting interface and
called SO_BINDTODEVICE on it — breaking outbound connectivity for
IP aliases on loopback or dummy interfaces (go-gost/gost#785).

The initial fix (caa82f2) only skipped SO_BINDTODEVICE for loopback
interfaces, missing non-loopback dummy interfaces like ip-aliases.

This change adds an isIP return value to ParseInterfaceAddr so callers
can distinguish 'user specified an IP' (intent: source IP binding for
policy routing — skip SO_BINDTODEVICE) from 'user specified an
interface name' (intent: force traffic through that NIC — keep
SO_BINDTODEVICE). The dialOnce method now only calls bindDevice when
bindToDevice is true AND the input was an interface name (!isIP).

The loopback-specific guard in bindDevice is reverted — superseded by
the broader isIP-based fix that covers all interface types.
2026-06-23 21:43:54 +08:00
ginuerzh a32ffd5608 fix(sniffing): pass bypass option to sniffer HandleHTTP/HandleTLS calls
When sniffing is enabled, the sniffer extracts the real domain from HTTP
Host header or TLS SNI and checks bypass rules against it. However, most
handlers were not passing WithBypass(...) to the sniffer, so the sniffer
internal bypass check was always skipped.

Additionally, the TLS-to-HTTP fallback paths in internal util sniffer
and forwarder also omitted bypass when constructing options for the
recursive HandleHTTP call after decrypting TLS.

Add WithBypass to all sniffer call sites that were missing it across
handlers (http, relay, socks4, socks5, ss, sshd, unix) and internal
TLS fallback paths.

Also fix import ordering in handler/http/connect.go.

Fixes go-gost/gost#874
2026-06-23 20:54:26 +08:00
ginuerzh ffa7ceaed7 feat(tls): persist auto-generated CA certificate to $HOME/.gost/
Previously BuildDefaultTLSConfig generated a new random self-signed
certificate in memory on every run, making it impossible for users to
extract and trust the certificate in their system trust store.

Now when no explicit cert files are configured, GOST:
1. First checks cert.pem / key.pem in CWD (backward compatible)
2. Then checks auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/
3. If none found, generates a new ECDSA P-256 CA certificate, persists
   it to $HOME/.gost/, and uses it

The persisted cert has IsCA=true so tools recognize it as a root CA.
Falls back to in-memory-only when disk writes fail.

Closes go-gost/gost#876
2026-06-23 20:29:57 +08:00
ginuerzh ea3ca94e53 fix(handler): move auth before request validation so probe resistance works (#875)
Probe resistance (probeResist=code:403) was always returning 400 Bad
Request instead of the configured status code because the
non-proxy-form request validation check (req.URL.Scheme != "http")
ran before Authenticate(). Browser/scanner probes send GET / HTTP/1.1
which has an empty URL scheme, so they were rejected at the validation
gate before probe resistance ever had a chance to respond.

Swap the order: run Authenticate() first so probe resistance can
intercept non-proxy-form probes with the configured decoy response.
Legitimate clients with correct credentials proceed to request
validation and routing as before.
2026-06-23 19:57:45 +08:00
ginuerzh 5d6b00c3a6 Revert "chore(deps): bump go-gost/x to v0.12.2"
This reverts commit 0637486025.
2026-06-22 21:46:28 +08:00
ginuerzh 0637486025 chore(deps): bump go-gost/x to v0.12.2
x v0.12.2 adds SO_REUSEPORT support for TCP listeners (#654).
2026-06-22 21:45:28 +08:00
ginuerzh 99a0f7323d feat(listener): add SO_REUSEPORT support for TCP listeners (#654)
Add opt-in reuseport metadata option for tcp, mtcp, and redirect/tcp
listeners, allowing a GOST listener on a specific IP to coexist with
another process on the same port but different address (e.g., Nginx on
0.0.0.0:443 + GOST on 127.0.0.1:443). With SO_REUSEPORT, the kernel
routes to the most specific address match.

On Unix, sets SO_REUSEPORT via ListenConfig.Control before bind().
On Windows, the option is a no-op (SO_REUSEADDR is the default).

Closes go-gost/gost#654
2026-06-22 21:42:00 +08:00
ginuerzh 4057ac54b8 fix(listener): configure all TUN addresses for dual-stack IPv6 on Windows
Only the first entry of config.Net was installed on the wintun adapter,
so dual-stack setups (e.g. net=192.168.123.1/24,fd::2/64) silently
dropped the IPv6 address and IPv6 traffic never worked. Iterate every
configured address and select the netsh context by family (ip for IPv4,
ipv6 for IPv6). A failing address is logged and skipped so one bad
entry no longer aborts the rest or triggers the listenLoop's infinite
recreate-retry. If no address can be configured, return an error
instead of a nil peer IP.

Refs: go-gost/gost#624
2026-06-22 19:43:22 +08:00
ginuerzh 6a0ce8914e test(handler): add unit tests for SOCKS5 networkAddr helper
Add 8-case table-driven test covering nil addr, IPv4/IPv6/domain/unknown
addr types for both tcp and udp networks.

Ref: 03f4b12 (fix(handler): use addr-family-specific network for SOCKS5 BIND)
2026-06-22 19:21:09 +08:00
ginuerzh 03f4b1291e fix(handler): use addr-family-specific network for SOCKS5 BIND (#621)
When the SOCKS5 BIND request carries an IPv4 address (e.g. 0.0.0.0:0),
pass the network as "tcp4"/"udp4" instead of "tcp"/"udp". On Linux,
net.Listen("tcp", "0.0.0.0:0") creates a dual-stack IPv6 socket
binding on [::]:port, so the bound address returned to the client
mismatches the actual listening socket. Same fix for CmdMuxBind and
CmdUDPTun. CmdConnect is left unchanged since dual-stack dialing is
correct for outbound connections.
2026-06-22 19:18:46 +08:00
ginuerzh 2e16457467 fix(config): warn when TLS options are set on a plaintext listener (#108)
Log a warning in ParseService when certFile/keyFile/caFile are configured for a listener type that never terminates TLS (tcp, udp, ws, mws, redirect, tproxy, rtcp, rudp, unix, runix, serial, stdio), so the misconfiguration is no longer silent. Uses a conservative plaintext blocklist; TLS-keyed off raw config strings so normal plaintext services stay quiet.

Refs go-gost/gost#579.
2026-06-22 14:29:41 +08:00
ginuerzh a1d7da4fec fix(listener): exit process when the stdio connection ends
The stdio listener serves exactly one connection: the process's own
stdin/stdout, piped from a parent such as SSH ProxyCommand. When that
connection ended, gost lingered forever. The service's accept loop runs
in a fire-and-forget goroutine and the process blocks on go-svc's
signal wait, so it never exited unless the parent sent a terminating
signal.

On macOS (darwin/arm64) the ProxyCommand child does not reliably
receive that signal, so gost hung after ssh quit.

Close the listener and call os.Exit when the stdio connection closes,
making exit deterministic regardless of whether the parent signals it.

Ref go-gost/gost#433.
2026-06-22 13:07:10 +08:00
ginuerzh 0522ae8155 fix(net): use pure-Go DNS fallback to prevent thread exhaustion
When no custom resolver or host mapper is configured, xnet.Resolve now
resolves hostnames via Go's native pure-Go resolver (PreferGo: true)
instead of returning the hostname unchanged.

Previously, the unresolved hostname would reach net.Dialer.DialContext,
which on CGO_ENABLED=1 builds used cgo-based DNS — blocking a dedicated
OS thread per concurrent lookup. At scale (1000+ listeners), this
exhausted Go's 10000-thread limit.

Fixes go-gost/gost#550
2026-06-21 22:34:43 +08:00
ginuerzh 55a246869e fix(metrics): add cross-platform process metrics collector for non-Linux systems
The standard Prometheus ProcessCollector uses procfs (Linux-only /proc),
so process_resident_memory_bytes and process_virtual_memory_bytes were
silently absent on FreeBSD, Darwin, and other non-Linux platforms.

- Switch to a custom prometheus.Registry to isolate GOST metrics from
  the default registry auto-registrations
- Add gopsutil/v3-based process collector (process_other.go) for
  platforms without procfs (!linux && !windows)
- Keep standard ProcessCollector (process_standard.go) on Linux/Windows
- Update metrics service and handler/metrics to serve from
  promhttp.HandlerFor(customRegistry) instead of promhttp.Handler()
- Add Registry() accessor for the custom registry

Fixes go-gost/gost#509
2026-06-21 21:55:27 +08:00
ginuerzh 3ec3362ac4 fix(handler): forward TLS connections in auto handler to chain router
The auto handler only recognized SOCKS4 (0x04), SOCKS5 (0x05), and HTTP
(first byte) as protocol indicators. TLS ClientHello bytes (0x16) fell
through to the HTTP handler, which called http.ReadRequest() on binary
TLS data and returned a malformed-HTTP error.

Add protocol sniffing via sniffing.Sniff() to detect TLS. When detected,
delegate to sniffing.Sniffer.HandleTLS() which parses the ClientHello
for SNI, dials upstream through the chain router (respecting -F config),
and proxies the TLS connection transparently.

Fixes go-gost/gost#492
2026-06-21 21:19:29 +08:00
ginuerzh 3f01456480 fix(metrics): skip connection wrapping when metrics are disabled
When xmetrics.IsEnabled() is false (no -api / no metrics.addr configured),
WrapListener now returns the listener unchanged instead of wrapping every
accepted connection in a metrics.serverConn. This allows bare *net.TCPConn
to reach the forwarding layer, enabling future splice(2) optimization.
2026-06-21 20:34:41 +08:00
ginuerzh 9ea10763e4 fix(listener): add panic recovery in TUN/TAP listenLoop for wintun crashes
On Windows, wintun.dll may pass an invalid pointer to the logMessage
callback, causing a nil pointer dereference in UTF16PtrToString that
panics the entire process. The upstream wintun-go library has not fixed
this since 2021.

Add defer/recover in tun/tungo/tap listenLoop inner closures to convert
panics from createTun/createTap into logged errors, allowing the retry
loop to recover gracefully instead of crashing the process.

Fixes go-gost/gost#482
2026-06-21 16:47:12 +08:00
ginuerzh e814852ca1 fix(sniffing): re-dial upstream on HTTP Host change + surface no-SNI errors
sniffer_http:
When DNS override directs multiple domains to the same proxy IP, the
browser may reuse a keep-alive connection for a different host. The
HTTP keep-alive loop now detects Host header changes and re-dials a
new upstream connection with correct node selection, preventing CDN
errors (Fastly unknown domain, CloudFront 403).

sniffer_tls:
Return a descriptive error when TLS ClientHello has no SNI, instead
of silently returning nil. This makes the connection drop visible in
logs and recorder output.

Fixes go-gost/gost#479
2026-06-21 16:37:47 +08:00
ginuerzh a102f9c6cf chore: bump go-gost/relay v0.6.0 → v0.6.1 for forward-compatible unknown feature handling 2026-06-21 16:07:12 +08:00
ginuerzh 9ccf402ed1 feat(listener): add stdio listener for SSH ProxyCommand support
The stdio listener wraps os.Stdin/os.Stdout as a net.Listener that accepts
exactly one connection. It enables GOST to be used as an SSH ProxyCommand,
where the parent process (SSH) pipes the transport byte stream through the
child process's standard I/O.

Usage: gost -L stdio://example.com:22 -F http://proxy:8080

The URL host:port is treated as the forwarding target and converted to a
forward node in buildServiceConfig. The standard forward handler then
routes traffic through the configured chain to the destination.

Fixes go-gost/gost#433
2026-06-21 15:39:52 +08:00
ginuerzh 61eb3e2a3b feat(socks5): add udp.resolveDomain option for clients incompatible with ATYP=Domain
Add a  wrapper that intercepts WriteTo on the
client-facing SOCKS5 UDP relay and resolves domain addresses to IPs
before the SOCKS5 header is encoded. Controlled via the
 (or ) handler metadata flag.

When enabled, SOCKS5 UDP response datagrams always carry ATYP=IPv4 or
ATYP=IPv6 — never ATYP=Domain (0x03) — making them compatible with
clients like tun2proxy and Surge that cannot parse domain-typed
addresses in UDP.

The wrapper resolves domains through the router's infrastructure:
host mapper → resolver → system DNS fallback. Unresolvable domains
cause the datagram to be silently dropped rather than forwarded in a
format the client rejects.

Fixes go-gost/gost#434
2026-06-21 15:12:53 +08:00
ginuerzh 92002fa35e fix(listener): route WS/WSS HTTP server errors through GOST logger
Set http.Server.ErrorLog to a logWriter that routes HTTP server errors
(TLS handshake failures, malformed requests) to the GOST logger at Debug
level instead of escaping to stderr via log.Printf.

Without this, network scanners and protocol filters probing the WSS port
produce uncontrolled output:
  http: TLS handshake error from ...

Also downgrade WebSocket upgrade failures from Error to Warn — plain HTTP
requests hitting the WebSocket endpoint are operational noise.

Fixes go-gost/gost#419
2026-06-21 13:47:51 +08:00
ginuerzh a424148018 chore: bump go-gost/core v0.4.1 → v0.4.2 for recorder Metadata field 2026-06-21 13:27:46 +08:00
ginuerzh d4d823df45 fix(recorder): forward per-service recorder metadata to plugin recorders (#412)
User-configured metadata on recorder objects (recorders[].metadata in YAML)
was silently discarded — MetadataRecordOption existed but was never called
anywhere in the codebase, causing plugin (HTTP/gRPC) recorders to always
receive null metadata.

Fix: wrap recorders that carry config metadata in MetadataRecorder, which
auto-appends the metadata to every Record() call. This covers all paths
(handler, router, sniffer, service) with zero call-site changes.
2026-06-21 13:25:27 +08:00
ginuerzh c45d27113e fix(dialer): evict dead SSHD sessions before reuse to prevent channel-open failure (#382)
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.
2026-06-21 13:02:10 +08:00
ginuerzh 9fab332772 fix(dialer): prevent hang on HTTP+ICMP by enabling QUIC keepalive by default
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.

Fixes go-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
2026-06-21 10:09:34 +08:00
ginuerzh 37bc81576c fix(tun): prevent leaked goroutines in bidirectional copy on transport failure
transportClient and transportServer each launch two goroutines for
bidirectional copy between the TUN device and the remote connection.
Previously, when one goroutine errored, the function returned
immediately while the sibling goroutine continued running — reading
from and writing to connections that would soon be closed by the
caller's deferred cleanup. In the retry loop of handleClient and
handleServer, stale goroutines accumulated across iterations, racing
with new goroutines for the TUN device and writing to closed pipes.

This caused the "io: read/write on closed pipe" error reported in
go-gost/gost#345, and more importantly, prevented recovery because
subsequent retry iterations operated on a corrupted state.

Fix:
- Derive a per-transport context in transportClient/transportServer
  and check it at the top of each goroutine's loop to allow the
  first-failing goroutine to signal its sibling to exit
- Replace the single-error select with a collectFirstError helper
  that drains both goroutines and includes a timeout guard (5s) to
  prevent deadlock when a goroutine is stuck in a non-cancelable read
- Use a per-iteration context in handleClient for the keepalive
  goroutine so it is properly cancelled when an iteration ends
- Fix handleServer to use errors.Is(err, ErrTun) for consistency
- Fix transportServer's errc buffer from 1 to 2 to prevent a
  goroutine leak when both goroutines error simultaneously
2026-06-20 22:10:09 +08:00
ginuerzh 504c56f3c3 feat(router): add 'auto' interface for source-in source-out on multi-homed hosts
When a router is configured with interface: "auto", the outbound socket
is bound to the same IP that received the incoming connection (read from
DstAddr in context, which is conn.LocalAddr() from the service accept
loop). This enables the 源进源出 (source-in source-out) pattern without
requiring a hardcoded interface name.

Supports comma-separated interface lists (e.g. "auto,eth1") where only
the "auto" token is replaced with the detected ingress IP. Falls back
to the original string when the ingress address is INADDR_ANY, loopback,
or unavailable, so the remaining entries still take effect.

Closes go-gost/gost#341
2026-06-20 21:30:28 +08:00
ginuerzh ae92f5aee6 fix(dialer): skip SO_BINDTODEVICE for empty-addr UDP relay sockets
Removes bindDevice from dialOnce's UDP empty-addr path. For relay/listener
UDP sockets, the ListenUDP laddr binding already pins the source IP correctly.
The additional SO_BINDTODEVICE forced all outbound datagrams through the named
interface, which conflicts with the kernel routing table for unconnected UDP
sockets and causes silent packet drops — breaking SOCKS5 UDP associate when
interface is configured.

Fixes go-gost/gost#287
2026-06-20 21:13:49 +08:00
ginuerzh 48f31513c7 feat(hosts): add catch-all host mapping with '.' pattern
Adds a catch-all fallback to hostMapper.Lookup() that matches any
hostname when a '.' mapping exists. After the existing three-phase
matching (exact -> dot-prefix -> suffix walk) fails, the lookup now
checks for a standalone '.' entry as a final fallback.

This enables users to map all hostnames to a single IP (e.g., for SNI
proxy usage) with a simple configuration like 'hosts=.:<ip>'.

Fixes go-gost/gost#228
2026-06-20 20:47:10 +08:00
ginuerzh 548471717a fix(dialer): detect dead QUIC sessions in ICMP/QUIC dialer caches
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.

Fixes go-gost/gost#225
2026-06-20 20:25:18 +08:00
ginuerzh e583f5b538 feat(parser): support HTTP(S) URL config sources with -C flag
Add remote config fetching capability to the -C CLI flag, enabling
centralized fleet management of gost nodes.

- readConfig() now detects http:// and https:// URLs and fetches
  them via HTTP GET with a 30s timeout and shared client pool
- Format auto-detection from Content-Type header (using mime
  parsing) with fallback to URL path extension (.yaml/.json)
- 10 MiB response size limit to prevent memory exhaustion
- URL userinfo stripped from error messages for safety
- Fix ReadFile to force viper config type from file extension,
  preventing format detection ambiguity
- Fix viper config search path fallback: skip cfg.Load() when
  -C flags are explicitly provided, avoiding unwanted merges
- Tests: URL fetch (YAML/JSON), non-200, oversize, detectFormat,
  isHTTPURL, sanitizeURL, and combined file+URL Parse scenario
2026-06-20 20:11:43 +08:00
ginuerzh 9c585e2de8 feat(parser): support multiple -C config files, merged left-to-right
Allow passing -C multiple times to load and merge several configuration
files in order. Slice fields are appended; scalar fields (TLS, Log, API,
Metrics, Profiling) are overridden by later files — the same semantics as
the existing mergeConfig() used for CLI+config merging.

- Change Args.CfgFile string to Args.CfgFiles []string
- Extract readConfig() helper from Parse() for single-source loading
- Loop over CfgFiles in Parse(), merging each into the accumulator
- Add Quotas to mergeConfig (was missing from the slice list)
- Add TestParse_MultiFile integration test for two-file merge

Closes go-gost/gost#150
2026-06-20 18:56:08 +08:00
ginuerzh 34549e0e98 fix(redirect): add IPv6 TPROXY support for TCP redirect listener
Add missing IPV6_TRANSPARENT socket option to the TCP redirect listener
Control callback, matching what the UDP redirect listener already sets.
Without this, the kernel silently drops IPv6 TPROXY-redirected connections.

Also clean up handler_linux.go getOriginalDstAddr:
- Remove encoding/binary import and heap allocation for port conversion
- Replace with zero-allocation ntohs (native-to-network byte order swap)
- Document the struct layout hack where GetsockoptIPv6Mreq and
  GetsockoptIPv6MTUInfo are used to read sockaddr_in/sockaddr_in6
  from SO_ORIGINAL_DST (kernel returns smaller structs than the
  getter functions expect, but only the first N bytes are populated)

Fixes go-gost/gost#126
2026-06-20 18:20:44 +08:00
ginuerzh 684a0ddf65 feat(dns): use system nameservers from /etc/resolv.conf as fallback
When no forwarder or dns metadata is configured, the DNS handler now
reads system nameservers from /etc/resolv.conf (Unix) before falling
back to the hardcoded 127.0.0.1:53 default. This makes the DNS proxy
work out-of-the-box without requiring explicit DNS server configuration.

Fixes go-gost/gost#89
2026-06-20 15:54:39 +08:00
ginuerzh 9fcd7d6890 feat(dialer): add utls dialer for TLS ClientHello fingerprint simulation
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.

Closes go-gost/gost#31
2026-06-20 15:43:25 +08:00
ginuerzh 66f502b153 fix(router): skip system route setup for TUN @internal router
When a TUN listener with `route=::/0` (or any route) creates its
fallback @internal router during parseMetadata/Init, the router
immediately calls setSysRoutes before the TUN device exists. This
causes an ENETUNREACH "no route to host" error logged by the kernel
because the gateway is unreachable at that point.

The system routes are correctly added later by the listener's own
addRoutes() after device creation. The @internal router only needs
in-memory routes for GetRoute lookups.

Add NoSysRouteOption which disables netlink route management while
keeping route data for in-memory queries. Use it in the TUN
listener's @internal router.

Fixes go-gost/x#48
2026-06-20 15:15:31 +08:00
ginuerzh 1d34d4543a fix(masque): default enableDatagrams to true on HTTP/3 listener and fix CONNECT-UDP error handling
- listener/http3: default enableDatagrams to true when not explicitly set,
  so CONNECT-UDP (RFC 9298) works out of the box without requiring
  ?enableDatagrams=true in the config URL
- handler/masque: dial target before sending 200 OK in handleConnectUDP,
  matching the handleConnectTCP order; if target dial fails, the handler
  can still return an error status instead of having already committed 200
- handler/masque: add missing WriteHeader(BadRequest) when ResolveUDPAddr
  fails, so the client gets a proper HTTP error instead of an opaque
  H3_REQUEST_CANCELLED stream reset

Fixes #103
2026-06-20 15:01:14 +08:00
ginuerzh 296d87a597 fix(quic): preserve user-configured ALPN instead of hardcoding NextProtos
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".

Fixes go-gost/gost#872
2026-06-20 14:58:19 +08:00
ginuerzh c29517be4d fix(quota): add UnwrapConn to quotaConn wrapper for handler type assertions 2026-06-20 14:45:46 +08:00
Usishchev Yury b23553d37a feat: add quota limiter and quota API (#107) 2026-06-20 14:44:42 +08:00
ginuerzh b7d807b464 fix(wrapper): prevent nil-pointer dereference in WrapUDPConn chain when stats is nil
stats.WrapUDPConn returned nil when pStats was nil (introduced in 7c95d40),
but callers in the kcp and rudp listeners passed the result directly to
admission.WrapUDPConn and limiter.WrapUDPConn, which wrapped a nil PacketConn.
When service.Serve() called conn.LocalAddr(), the auto-generated method
delegated to the nil PacketConn, causing a panic.

Root cause: WrapUDPConn behavior was inconsistent with WrapConn and
WrapPacketConn in the same package, which both return the original connection
instead of nil when the optional config is absent.

Fixes:

1. listener/kcp, listener/rudp — guard stats.WrapUDPConn with Stats != nil
   (consistent with quic and wt listeners which already had this guard).

2. observer/stats/wrapper — when pStats is nil, pass through the original
   connection instead of returning nil (matches WrapConn/WrapPacketConn).

3. admission/wrapper, limiter/traffic/wrapper — add defensive nil guards:
   nil pc → return nil; nil config → pass through original connection.
   All four WrapUDPConn implementations now share the same nil semantics.

4. listener/udp_wrapper_safety_test.go — regression test that verifies the
   full metrics→stats→admission→limiter chain with all optional configs nil
   does not produce a wrapper with a nil internal PacketConn.

Fixes: go-gost/gost#873
2026-06-20 13:52:09 +08:00
ginuerzh dc31d5c15f fix(dialer): add switchNetns stubs for all non-Linux platforms
The switchNetns function was only defined for linux (dialer_netns.go,
build tag linux && !android) and android (dialer_android.go, stub).
This left darwin, freebsd, openbsd, windows, and other platforms with
an undefined symbol at dialer.go:55.

Add no-op stubs to all remaining platform files so the dialer package
compiles on every GOOS.
2026-06-18 14:33:02 +08:00
148 changed files with 7789 additions and 530 deletions
+12 -2
View File
@@ -163,10 +163,20 @@ type udpConn struct {
// WrapUDPConn wraps a net.PacketConn as a udp.Conn with admission control.
// This is used in UDP forwarding paths where the connection is treated
// as a connected UDP socket.
func WrapUDPConn(admission admission.Admission, pc net.PacketConn) udp.Conn {
// If pc is nil, nil is returned. If admission is nil, the original connection
// is returned unchanged (no-op).
func WrapUDPConn(adm admission.Admission, pc net.PacketConn) udp.Conn {
if pc == nil {
return nil
}
if adm == nil {
if uc, ok := pc.(udp.Conn); ok {
return uc
}
}
return &udpConn{
PacketConn: pc,
admission: admission,
admission: adm,
}
}
+7
View File
@@ -145,6 +145,13 @@ func Register(r *gin.Engine, opts *Options) {
config.PUT("/limiters/:limiter", updateLimiter)
config.DELETE("/limiters/:limiter", deleteLimiter)
config.GET("/quotas", getQuotaList)
config.GET("/quotas/:quota", getQuota)
config.POST("/quotas", createQuota)
config.PUT("/quotas/:quota", updateQuota)
config.DELETE("/quotas/:quota", deleteQuota)
config.POST("/quotas/:quota/reset", resetQuota)
config.GET("/climiters", getConnLimiterList)
config.GET("/climiters/:limiter", getConnLimiter)
config.POST("/climiters", createConnLimiter)
+23
View File
@@ -35,6 +35,29 @@ func fillServiceStatus(svc *config.ServiceConfig) {
OutputBytes: st.Get(stats.KindOutputBytes),
}
}
var stopped bool
for _, qname := range svc.Quotas {
lim := registry.QuotaLimiterRegistry().Get(qname)
if lim == nil {
continue
}
qs := lim.Snapshot()
if qs.Blocked {
stopped = true
}
svc.Status.Quotas = append(svc.Status.Quotas, config.ServiceQuota{
Name: qname,
Used: qs.Used,
Limit: qs.Limit,
StartsAt: qs.StartsAtUnix,
ExpiresAt: qs.ExpiresAtUnix,
Active: qs.Active,
Expired: qs.Expired,
Blocked: qs.Blocked,
Direction: qs.Direction,
})
}
svc.Status.StoppedByLimit = stopped
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
+350
View File
@@ -0,0 +1,350 @@
package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
parser "github.com/go-gost/x/config/parsing/quota"
"github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry"
)
func fillQuotaStatus(q *config.QuotaConfig) {
lim := registry.QuotaLimiterRegistry().Get(q.Name)
if lim == nil {
return
}
s := lim.Snapshot()
q.Status = &config.QuotaStatus{
Used: s.Used,
Limit: s.Limit,
StartsAt: s.StartsAtUnix,
ExpiresAt: s.ExpiresAtUnix,
Active: s.Active,
Expired: s.Expired,
Blocked: s.Blocked,
Direction: s.Direction,
}
}
// swagger:parameters getQuotaListRequest
type getQuotaListRequest struct {
}
// successful operation.
// swagger:response getQuotaListResponse
type getQuotaListResponse struct {
// in: body
Data quotaList
}
type quotaList struct {
Count int `json:"count"`
List []*config.QuotaConfig `json:"list"`
}
func getQuotaList(ctx *gin.Context) {
// swagger:route GET /config/quotas Quota getQuotaListRequest
//
// Get quota list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getQuotaListResponse
var req getQuotaListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Quotas
for _, q := range list {
if q == nil {
continue
}
fillQuotaStatus(q)
}
ctx.JSON(http.StatusOK, Response{
Data: quotaList{
Count: len(list),
List: list,
},
})
}
// swagger:parameters getQuotaRequest
type getQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
}
// successful operation.
// swagger:response getQuotaResponse
type getQuotaResponse struct {
// in: body
Data *config.QuotaConfig
}
func getQuota(ctx *gin.Context) {
// swagger:route GET /config/quotas/{quota} Quota getQuotaRequest
//
// Get quota.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getQuotaResponse
var req getQuotaRequest
ctx.ShouldBindUri(&req)
var resp getQuotaResponse
for _, q := range config.Global().Quotas {
if q == nil {
continue
}
if q.Name == req.Quota {
fillQuotaStatus(q)
resp.Data = q
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createQuotaRequest
type createQuotaRequest struct {
// in: body
Data config.QuotaConfig `json:"data"`
}
// successful operation.
// swagger:response createQuotaResponse
type createQuotaResponse struct {
Data Response
}
func createQuota(ctx *gin.Context) {
// swagger:route POST /config/quotas Quota createQuotaRequest
//
// Create a new quota, the name of the quota must be unique in quota list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: createQuotaResponse
var req createQuotaRequest
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Data.Name)
if name == "" {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "quota name is required"))
return
}
req.Data.Name = name
req.Data.Status = nil
if registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
v := parser.ParseQuotaLimiter(&req.Data)
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
c.Quotas = append(c.Quotas, &req.Data)
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters updateQuotaRequest
type updateQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
// in: body
Data config.QuotaConfig `json:"data"`
}
// successful operation.
// swagger:response updateQuotaResponse
type updateQuotaResponse struct {
Data Response
}
func updateQuota(ctx *gin.Context) {
// swagger:route PUT /config/quotas/{quota} Quota updateQuotaRequest
//
// Update quota by name, the quota must already exist. The cumulative counter
// is preserved across the update as long as the window is unchanged.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: updateQuotaResponse
var req updateQuotaRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Quota)
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
req.Data.Name = name
req.Data.Status = nil
v := parser.ParseQuotaLimiter(&req.Data)
registry.QuotaLimiterRegistry().Unregister(name)
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
for i := range c.Quotas {
if c.Quotas[i].Name == name {
c.Quotas[i] = &req.Data
break
}
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters deleteQuotaRequest
type deleteQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
}
// successful operation.
// swagger:response deleteQuotaResponse
type deleteQuotaResponse struct {
Data Response
}
func deleteQuota(ctx *gin.Context) {
// swagger:route DELETE /config/quotas/{quota} Quota deleteQuotaRequest
//
// Delete quota by name. Services referencing it stop being limited (fail-open).
//
// Security:
// basicAuth: []
//
// Responses:
// 200: deleteQuotaResponse
var req deleteQuotaRequest
ctx.ShouldBindUri(&req)
name := strings.TrimSpace(req.Quota)
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
registry.QuotaLimiterRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
quotas := c.Quotas
c.Quotas = nil
for _, q := range quotas {
if q.Name == name {
continue
}
c.Quotas = append(c.Quotas, q)
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters resetQuotaRequest
type resetQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
// in: body
Data resetQuotaData `json:"data"`
}
type resetQuotaData struct {
Used *uint64 `json:"used,omitempty"`
}
// successful operation.
// swagger:response resetQuotaResponse
type resetQuotaResponse struct {
Data Response
}
func resetQuota(ctx *gin.Context) {
// swagger:route POST /config/quotas/{quota}/reset Quota resetQuotaRequest
//
// Overwrite the cumulative counter of a quota (defaults to 0). The new value
// is applied immediately and persisted; if the quota was blocking, services
// resume once the value is below the limit.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: resetQuotaResponse
var req resetQuotaRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Quota)
lim := registry.QuotaLimiterRegistry().Get(name)
if lim == nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
used := uint64(0)
if req.Data.Used != nil {
used = *req.Data.Used
}
lim.Update(quota.Update{Used: &used})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
+95
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"io"
"net"
"strings"
"testing"
"time"
"github.com/go-gost/core/auth"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/loader"
xlogger "github.com/go-gost/x/logger"
)
@@ -1003,3 +1005,96 @@ type staticAuther struct {
func (s *staticAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
return s.id, s.ok
}
// --- whitelist tests ---
func TestWhitelistedAuthenticator_IPMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || !ok {
t.Fatalf("expected (\"\", true) for matched IP, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_IPNoMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for non-matched IP, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_CIDRMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.1.2.3"), Port: 9999})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || !ok {
t.Fatalf("expected (\"\", true) for matched CIDR, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_CIDRNoMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for non-matched CIDR, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_EmptyPatterns(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for empty patterns, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_NoSrcAddr(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
id, ok := w.Authenticate(context.Background(), "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) with no src addr in context, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_NilAuther(t *testing.T) {
// When IP matches, the whitelist returns OK regardless of nil auther.
w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || !ok {
t.Fatalf("expected (\"\", true) for matched whitelist even with nil auther, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_NilAutherNoMatch(t *testing.T) {
// When IP does not match and auther is nil, it falls through to ("", false).
w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || ok {
t.Fatalf("expected (\"\", false) for nil auther with non-matching IP, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_UnderlyingDenies(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: false}, []string{"10.0.0.0/8"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || ok {
t.Fatalf("expected (\"test\", false) when underlying auther denies, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_InvalidPatterns(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"not-an-ip", "also-not-cidr/", ""})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for all-invalid patterns, got (%q, %v)", id, ok)
}
}
+61
View File
@@ -0,0 +1,61 @@
package auth
import (
"context"
"net"
"strings"
"github.com/go-gost/core/auth"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/matcher"
)
// whitelistedAuthenticator wraps an Authenticator to skip authentication
// for client IPs matching the configured whitelist patterns.
type whitelistedAuthenticator struct {
auther auth.Authenticator
ipMatcher matcher.Matcher
cidrMatcher matcher.Matcher
}
// WhitelistedAuthenticator returns an Authenticator that skips the underlying
// auther when the client's IP address matches one of the given patterns.
// Patterns may be IP addresses (e.g. "192.168.1.1") or CIDR ranges
// (e.g. "10.0.0.0/8"). Invalid patterns are silently ignored.
func WhitelistedAuthenticator(auther auth.Authenticator, patterns []string) auth.Authenticator {
var ips []net.IP
var inets []*net.IPNet
for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
if ip := net.ParseIP(pattern); ip != nil {
ips = append(ips, ip)
continue
}
if _, inet, err := net.ParseCIDR(pattern); err == nil {
inets = append(inets, inet)
}
}
return &whitelistedAuthenticator{
auther: auther,
ipMatcher: matcher.IPMatcher(ips),
cidrMatcher: matcher.CIDRMatcher(inets),
}
}
func (w *whitelistedAuthenticator) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
host, _, err := net.SplitHostPort(srcAddr.String())
if err == nil && host != "" {
if w.ipMatcher.Match(host) || w.cidrMatcher.Match(host) {
return "", true
}
}
}
if w.auther != nil {
return w.auther.Authenticate(ctx, user, password, opts...)
}
return "", false
}
+27 -1
View File
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/go-gost/core/chain"
@@ -141,9 +142,34 @@ func (r *Router) dial(ctx context.Context, network, address string, log logger.L
if route == nil {
route = DefaultRoute
}
ifceName := r.options.IfceName
if strings.Contains(ifceName, "auto") {
// "auto" triggers per-connection interface detection: the
// ingress IP (conn.LocalAddr()) is used as the bind address,
// ensuring egress traffic leaves via the same interface that
// received the connection — the "source-in source-out"
// pattern for multi-homed hosts.
if dstAddr := xctx.DstAddrFromContext(ctx); dstAddr != nil {
if host, _, _ := net.SplitHostPort(dstAddr.String()); host != "" &&
host != "0.0.0.0" && host != "::" {
// Replace only exact "auto" tokens in the
// comma-separated interface list.
parts := strings.Split(ifceName, ",")
for i, p := range parts {
if p == "auto" {
parts[i] = host
}
}
ifceName = strings.Join(parts, ",")
}
}
if strings.Contains(ifceName, "auto") {
log.Debugf("auto interface: no suitable ingress address, keeping literal %q", ifceName)
}
}
conn, err = route.Dial(ctx, network, ipAddr,
append([]chain.DialOption{
chain.InterfaceDialOption(r.options.IfceName),
chain.InterfaceDialOption(ifceName),
chain.NetnsDialOption(r.options.Netns),
chain.SockOptsDialOption(r.options.SockOpts),
chain.LoggerDialOption(log),
+11
View File
@@ -466,6 +466,17 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
}
}
// stdio listener uses url.Host as the forwarding target.
if listener == "stdio" && len(nodes) == 0 && url.Host != "" {
nodes = append(nodes, &config.ForwardNodeConfig{
Name: fmt.Sprintf("%starget-0", namePrefix),
Addr: url.Host,
})
if handler == "auto" {
handler = "tcp"
}
}
if len(nodes) > 0 {
if len(services) == 1 {
services[0].Forwarder = &config.ForwarderConfig{
+83 -6
View File
@@ -3,6 +3,8 @@ package config
import (
"encoding/json"
"io"
"path/filepath"
"strings"
"sync"
"time"
@@ -314,6 +316,11 @@ type RecorderObject struct {
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
type RewriterConfig struct {
Name string `json:"name"`
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
type LimiterConfig struct {
Name string `json:"name"`
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
@@ -395,6 +402,9 @@ type HTTPBodyRewriteConfig struct {
Type string
Match string
Replacement string
// name of the rewriter plugin (via registry)
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
MaxChunkSize int `yaml:"maxChunkSize,omitempty" json:"maxChunkSize,omitempty"`
}
type NodeFilterConfig struct {
@@ -403,6 +413,15 @@ type NodeFilterConfig struct {
Path string `yaml:",omitempty" json:"path,omitempty"`
}
// NodeMatcherConfig defines a routing-rule matcher for a hop node.
//
// Priority controls election among multiple matching nodes:
// - 0 (default): auto-computed from the rule string length — longer rules
// (more specific) get higher priority.
// - negative: the node participates in matching but priority short-circuit
// is disabled; the selector (round-robin, random, hash, etc.) always applies.
// - positive: explicit priority; when a single node has strictly higher
// priority than all others, it wins directly, bypassing the selector.
type NodeMatcherConfig struct {
Rule string `yaml:",omitempty" json:"rule,omitempty"`
Priority int `yaml:",omitempty" json:"priority,omitempty"`
@@ -470,13 +489,15 @@ type ServiceConfig struct {
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Limiter string `yaml:",omitempty" json:"limiter,omitempty"`
Quotas []string `yaml:",omitempty" json:"quotas,omitempty"`
CLimiter string `yaml:"climiter,omitempty" json:"climiter,omitempty"`
RLimiter string `yaml:"rlimiter,omitempty" json:"rlimiter,omitempty"`
Logger string `yaml:",omitempty" json:"logger,omitempty"`
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
Observer string `yaml:",omitempty" json:"observer,omitempty"`
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
@@ -485,10 +506,12 @@ type ServiceConfig struct {
}
type ServiceStatus struct {
CreateTime int64 `yaml:"createTime" json:"createTime"`
State string `yaml:"state" json:"state"`
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
CreateTime int64 `yaml:"createTime" json:"createTime"`
State string `yaml:"state" json:"state"`
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
StoppedByLimit bool `yaml:"stoppedByLimit,omitempty" json:"stoppedByLimit,omitempty"`
Quotas []ServiceQuota `yaml:",omitempty" json:"quotas,omitempty"`
}
type ServiceEvent struct {
@@ -504,6 +527,57 @@ type ServiceStats struct {
OutputBytes uint64 `yaml:"outputBytes" json:"outputBytes"`
}
// QuotaConfig is a named cumulative traffic-volume limiter; the same name
// referenced by several services (ServiceConfig.Quotas) shares one counter.
type QuotaConfig struct {
Name string `json:"name"`
Limit string `yaml:",omitempty" json:"limit,omitempty"`
StartsAt string `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt string `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Direction string `yaml:",omitempty" json:"direction,omitempty"`
Flush string `yaml:",omitempty" json:"flush,omitempty"`
Store *QuotaStoreConfig `yaml:",omitempty" json:"store,omitempty"`
// read-only
Status *QuotaStatus `yaml:",omitempty" json:"status,omitempty"`
}
type QuotaStoreConfig struct {
Type string `json:"type"` // "file" (default) | "redis" (stub, not implemented)
File string `yaml:",omitempty" json:"file,omitempty"`
Redis *QuotaRedisConfig `yaml:",omitempty" json:"redis,omitempty"`
}
type QuotaRedisConfig struct {
Addr string `json:"addr"`
DB int `yaml:",omitempty" json:"db,omitempty"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
Key string `yaml:",omitempty" json:"key,omitempty"`
}
type QuotaStatus struct {
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ServiceQuota struct {
Name string `yaml:"name" json:"name"`
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ChainConfig struct {
Name string `json:"name"`
Hops []*HopConfig `json:"hops"`
@@ -572,7 +646,9 @@ type Config struct {
Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"`
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
RLimiters []*LimiterConfig `yaml:"rlimiters,omitempty" json:"rlimiters,omitempty"`
Observers []*ObserverConfig `yaml:",omitempty" json:"observers,omitempty"`
@@ -603,6 +679,7 @@ func (c *Config) Read(r io.Reader, configType string) error {
func (c *Config) ReadFile(file string) error {
v.SetConfigFile(file)
v.SetConfigType(strings.TrimPrefix(filepath.Ext(file), ".")) // force format from extension
if err := v.ReadInConfig(); err != nil {
return err
}
+24
View File
@@ -22,6 +22,7 @@ import (
reg "github.com/go-gost/core/registry"
"github.com/go-gost/core/resolver"
"github.com/go-gost/core/router"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/sd"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
@@ -35,11 +36,14 @@ import (
limiter_parser "github.com/go-gost/x/config/parsing/limiter"
logger_parser "github.com/go-gost/x/config/parsing/logger"
observer_parser "github.com/go-gost/x/config/parsing/observer"
quota_parser "github.com/go-gost/x/config/parsing/quota"
recorder_parser "github.com/go-gost/x/config/parsing/recorder"
resolver_parser "github.com/go-gost/x/config/parsing/resolver"
rewriter_parser "github.com/go-gost/x/config/parsing/rewriter"
router_parser "github.com/go-gost/x/config/parsing/router"
sd_parser "github.com/go-gost/x/config/parsing/sd"
service_parser "github.com/go-gost/x/config/parsing/service"
quota "github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry"
)
@@ -242,6 +246,16 @@ func register(cfg *config.Config) error {
}
}
{
var entries []named[rewriter.Rewriter]
for _, c := range cfg.Rewriters {
entries = append(entries, named[rewriter.Rewriter]{c.Name, rewriter_parser.ParseRewriter(c)})
}
if err := registerGroup(entries, registry.RewriterRegistry()); err != nil {
return err
}
}
{
var entries []named[traffic.TrafficLimiter]
for _, c := range cfg.Limiters {
@@ -252,6 +266,16 @@ func register(cfg *config.Config) error {
}
}
{
var entries []named[*quota.Limiter]
for _, c := range cfg.Quotas {
entries = append(entries, named[*quota.Limiter]{c.Name, quota_parser.ParseQuotaLimiter(c)})
}
if err := registerGroup(entries, registry.QuotaLimiterRegistry()); err != nil {
return err
}
}
{
var entries []named[conn.ConnLimiter]
for _, c := range cfg.CLimiters {
+31 -27
View File
@@ -29,6 +29,30 @@ import (
// metadata-driven options (so_mark, interface, netns, proxy protocol), sets up
// bypass rules, node filters, HTTP settings, and TLS node settings. The hop
// parameter is used only for logging context.
func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []chain.HTTPBodyRewriteSettings {
var out []chain.HTTPBodyRewriteSettings
for _, v := range vs {
pattern, _ := regexp.Compile(v.Match)
rw := chain.HTTPBodyRewriteSettings{
Type: v.Type,
Pattern: pattern,
Replacement: []byte(v.Replacement),
MaxChunkSize: v.MaxChunkSize,
}
if v.Rewriter != "" {
if !registry.RewriterRegistry().IsRegistered(v.Rewriter) {
log.Warnf("rewriter %q not found in registry for rewrite rule", v.Rewriter)
}
rw.Rewriter = registry.RewriterRegistry().Get(v.Rewriter)
}
if pattern != nil || rw.Rewriter != nil {
out = append(out, rw)
}
}
return out
}
func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) {
if cfg == nil {
return nil, nil
@@ -176,6 +200,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
if matcher, err := routing.NewMatcher(rule); err == nil {
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule)
// Priority 0 means "use default": automatically set to the
// rule length so longer (more specific) rules outrank shorter
// ones. Use a negative priority to opt out of this behavior
// and always go through the selector.
if priority == 0 {
priority = len(cfg.Matcher.Rule)
}
@@ -222,33 +250,9 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
})
}
}
for _, v := range cfg.HTTP.RewriteBody {
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
Type: v.Type,
Pattern: pattern,
Replacement: []byte(v.Replacement),
})
}
}
for _, v := range cfg.HTTP.RewriteResponseBody {
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
Type: v.Type,
Pattern: pattern,
Replacement: []byte(v.Replacement),
})
}
}
for _, v := range cfg.HTTP.RewriteRequestBody {
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
settings.RewriteRequestBody = append(settings.RewriteRequestBody, chain.HTTPBodyRewriteSettings{
Type: v.Type,
Pattern: pattern,
Replacement: []byte(v.Replacement),
})
}
}
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
opts = append(opts, chain.HTTPNodeOption(settings))
}
+256 -3
View File
@@ -1,6 +1,11 @@
package parser
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-gost/x/config"
@@ -47,6 +52,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
SDs: []*config.SDConfig{{Name: "sd1"}},
Recorders: []*config.RecorderConfig{{Name: "rec1"}},
Limiters: []*config.LimiterConfig{{Name: "l1"}},
Quotas: []*config.QuotaConfig{{Name: "q1"}},
CLimiters: []*config.LimiterConfig{{Name: "cl1"}},
RLimiters: []*config.LimiterConfig{{Name: "rl1"}},
Loggers: []*config.LoggerConfig{{Name: "log1"}},
@@ -66,6 +72,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
SDs: []*config.SDConfig{{Name: "sd2"}},
Recorders: []*config.RecorderConfig{{Name: "rec2"}},
Limiters: []*config.LimiterConfig{{Name: "l2"}},
Quotas: []*config.QuotaConfig{{Name: "q2"}},
CLimiters: []*config.LimiterConfig{{Name: "cl2"}},
RLimiters: []*config.LimiterConfig{{Name: "rl2"}},
Loggers: []*config.LoggerConfig{{Name: "log2"}},
@@ -111,6 +118,9 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
if len(got.Limiters) != 2 {
t.Fatal("limiters not properly appended")
}
if len(got.Quotas) != 2 {
t.Fatal("quotas not properly appended")
}
if len(got.CLimiters) != 2 {
t.Fatal("climiters not properly appended")
}
@@ -194,7 +204,7 @@ func TestMergeConfig_ScalarKeepsCfg1WhenCfg2Nil(t *testing.T) {
func TestInit(t *testing.T) {
Init(Args{
CfgFile: "test.yml",
CfgFiles: []string{"test.yml"},
Services: []string{"s1"},
Nodes: []string{"n1"},
Debug: true,
@@ -203,8 +213,8 @@ func TestInit(t *testing.T) {
MetricsAddr: ":9090",
})
if defaultParser.args.CfgFile != "test.yml" {
t.Fatalf("CfgFile not set: got %q", defaultParser.args.CfgFile)
if len(defaultParser.args.CfgFiles) != 1 || defaultParser.args.CfgFiles[0] != "test.yml" {
t.Fatalf("CfgFiles not set: got %v", defaultParser.args.CfgFiles)
}
if len(defaultParser.args.Services) != 1 || defaultParser.args.Services[0] != "s1" {
t.Fatal("Services not set")
@@ -225,3 +235,246 @@ func TestInit(t *testing.T) {
t.Fatal("MetricsAddr not set")
}
}
func TestParse_MultiFile(t *testing.T) {
// Create two temporary config files.
cfg1 := &config.Config{
Services: []*config.ServiceConfig{{Name: "s1", Addr: ":8080"}},
}
cfg2 := &config.Config{
Services: []*config.ServiceConfig{{Name: "s2", Addr: ":8081"}},
Log: &config.LogConfig{Level: "debug"},
Quotas: []*config.QuotaConfig{{Name: "q1"}},
}
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "cfg1.yml")
file2 := filepath.Join(tmpDir, "cfg2.yml")
for _, entry := range []struct {
path string
cfg *config.Config
}{
{file1, cfg1},
{file2, cfg2},
} {
f, err := os.Create(entry.path)
if err != nil {
t.Fatal(err)
}
if err := entry.cfg.Write(f, "yaml"); err != nil {
f.Close()
t.Fatal(err)
}
f.Close()
}
Init(Args{
CfgFiles: []string{file1, file2},
})
got, err := Parse()
if err != nil {
t.Fatalf("Parse(): %v", err)
}
if len(got.Services) != 2 {
t.Fatalf("expected 2 services, got %d", len(got.Services))
}
if got.Log == nil || got.Log.Level != "debug" {
t.Fatal("Log.Level not set from cfg2")
}
if len(got.Quotas) != 1 || got.Quotas[0].Name != "q1" {
t.Fatal("Quotas not merged from cfg2")
}
}
func TestIsHTTPURL(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"http://example.com/config.yaml", true},
{"https://example.com/config.json", true},
{"ftp://example.com/config.yaml", false},
{"config.yaml", false},
{"/path/to/config.yml", false},
{"http://[::1]:8080/cfg", true},
}
for _, tt := range tests {
if got := isHTTPURL(tt.input); got != tt.expected {
t.Errorf("isHTTPURL(%q) = %v, want %v", tt.input, got, tt.expected)
}
}
}
func TestDetectFormat(t *testing.T) {
tests := []struct {
contentType string
url string
want string
}{
{"", "http://example.com/config.json", "json"},
{"", "http://example.com/config.yml", "yaml"},
{"", "http://example.com/config.yaml", "yaml"},
{"", "http://example.com/config", "yaml"}, // default
{"application/json", "http://example.com/config", "json"},
{"application/yaml", "http://example.com/config", "yaml"},
{"application/x-yaml", "http://example.com/config", "yaml"},
{"text/yaml", "http://example.com/config", "yaml"},
{"text/x-yaml", "http://example.com/config", "yaml"},
{"text/plain; charset=utf-8", "http://example.com/config.json", "json"}, // content-type ignored, extension wins
{"text/plain", "http://example.com/config", "yaml"}, // unknown -> default
}
for _, tt := range tests {
if got := detectFormat(tt.contentType, tt.url); got != tt.want {
t.Errorf("detectFormat(%q, %q) = %q, want %q", tt.contentType, tt.url, got, tt.want)
}
}
}
func TestSanitizeURL(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"http://user:pass@example.com/path", "http://example.com/path"},
{"https://example.com/path", "https://example.com/path"},
{"not a url", "not%20a%20url"},
{"http://user@example.com/path", "http://example.com/path"}, // user with no password
}
for _, tt := range tests {
if got := sanitizeURL(tt.input); got != tt.expected {
t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestReadConfigFromURL_YAML(t *testing.T) {
// Write a YAML config and serve it via httptest.
cfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "svc", Addr: ":8080"}},
Log: &config.LogConfig{Level: "debug"},
}
var buf bytes.Buffer
if err := cfg.Write(&buf, "yaml"); err != nil {
t.Fatal(err)
}
body := buf.Bytes()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(body)
}))
defer ts.Close()
got := &config.Config{}
if err := readConfigFromURL(ts.URL, got); err != nil {
t.Fatalf("readConfigFromURL: %v", err)
}
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
t.Fatalf("unexpected services: %+v", got.Services)
}
if got.Log == nil || got.Log.Level != "debug" {
t.Fatalf("unexpected log: %+v", got.Log)
}
}
func TestReadConfigFromURL_JSON(t *testing.T) {
// Remote serving JSON with explicit Content-Type.
cfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "json-svc", Addr: ":9090"}},
}
var buf bytes.Buffer
if err := cfg.Write(&buf, "json"); err != nil {
t.Fatal(err)
}
body := buf.Bytes()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}))
defer ts.Close()
got := &config.Config{}
if err := readConfigFromURL(ts.URL, got); err != nil {
t.Fatalf("readConfigFromURL: %v", err)
}
if got.Services[0].Name != "json-svc" {
t.Fatalf("unexpected service name: %s", got.Services[0].Name)
}
}
func TestReadConfigFromURL_Non200(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
err := readConfigFromURL(ts.URL, &config.Config{})
if err == nil {
t.Fatal("expected error for 404 response")
}
}
func TestReadConfigFromURL_OverSize(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(make([]byte, maxConfigSize+1))
}))
defer ts.Close()
err := readConfigFromURL(ts.URL, &config.Config{})
if err == nil {
t.Fatal("expected error for oversized response")
}
}
func TestParse_FileAndURL(t *testing.T) {
// Create a local file.
fileCfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "file-svc", Addr: ":8080"}},
}
tmpDir := t.TempDir()
f, err := os.Create(filepath.Join(tmpDir, "local.yml"))
if err != nil {
t.Fatal(err)
}
if err := fileCfg.Write(f, "yaml"); err != nil {
f.Close()
t.Fatal(err)
}
f.Close()
// Serve a second config over HTTP.
urlCfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "url-svc", Addr: ":8081"}},
Log: &config.LogConfig{Level: "debug"},
}
var buf bytes.Buffer
if err := urlCfg.Write(&buf, "yaml"); err != nil {
t.Fatal(err)
}
body := buf.Bytes()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(body)
}))
defer ts.Close()
Init(Args{
CfgFiles: []string{filepath.Join(tmpDir, "local.yml"), ts.URL},
})
got, err := Parse()
if err != nil {
t.Fatalf("Parse(): %v", err)
}
if len(got.Services) != 2 {
t.Fatalf("expected 2 services, got %d", len(got.Services))
}
if got.Log == nil || got.Log.Level != "debug" {
t.Fatal("Log.Level not set from URL config")
}
}
+145 -28
View File
@@ -2,9 +2,17 @@ package parser
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
@@ -35,9 +43,10 @@ func Parse() (*config.Config, error) {
// Args holds the raw CLI flags and positional arguments that Init will merge
// into the final configuration.
type Args struct {
// CfgFile is the path to a YAML or JSON config file, "-" for stdin, or
// an inline JSON string.
CfgFile string
// CfgFiles is the list of YAML or JSON config sources: file paths,
// HTTP(S) URLs, "-" for stdin, or an inline JSON string.
// Multiple sources are merged left-to-right.
CfgFiles []string
// Services is the list of service definitions from -L flags.
Services []string
@@ -65,31 +74,16 @@ type parser struct {
func (p *parser) Parse() (*config.Config, error) {
cfg := &config.Config{}
if cfgFile := strings.TrimSpace(p.args.CfgFile); cfgFile != "" {
if cfgFile == "-" { // stdin
br := bufio.NewReader(os.Stdin)
b, err := br.Peek(1)
if err != nil {
return nil, err
}
if b[0] == '{' {
if err := cfg.Read(br, "json"); err != nil {
return nil, err
}
} else {
if err := cfg.Read(br, "yaml"); err != nil {
return nil, err
}
}
} else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
return nil, err
}
} else {
if err := cfg.ReadFile(cfgFile); err != nil { // file
return nil, err
}
for _, cfgFile := range p.args.CfgFiles {
cfgFile = strings.TrimSpace(cfgFile)
if cfgFile == "" {
continue
}
fcfg, err := readConfig(cfgFile)
if err != nil {
return nil, err
}
cfg = mergeConfig(cfg, fcfg)
}
cmdCfg, err := cmd.BuildConfigFromCmd(p.args.Services, p.args.Nodes)
@@ -98,7 +92,8 @@ func (p *parser) Parse() (*config.Config, error) {
}
cfg = mergeConfig(cfg, cmdCfg)
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil {
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil &&
len(p.args.CfgFiles) == 0 {
if err := cfg.Load(); err != nil {
return nil, err
}
@@ -193,6 +188,126 @@ func (p *parser) Parse() (*config.Config, error) {
return cfg, nil
}
// readConfig reads a single configuration source, which may be "-" (stdin),
// an HTTP(S) URL, an inline JSON string starting with "{", or a file path.
func readConfig(cfgFile string) (*config.Config, error) {
cfg := &config.Config{}
if cfgFile == "-" { // stdin
br := bufio.NewReader(os.Stdin)
b, err := br.Peek(1)
if err != nil {
return nil, err
}
if b[0] == '{' {
if err := cfg.Read(br, "json"); err != nil {
return nil, err
}
} else {
if err := cfg.Read(br, "yaml"); err != nil {
return nil, err
}
}
} else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
return nil, err
}
} else if isHTTPURL(cfgFile) { // URL
if err := readConfigFromURL(cfgFile, cfg); err != nil {
return nil, err
}
} else {
if err := cfg.ReadFile(cfgFile); err != nil { // file
return nil, err
}
}
return cfg, nil
}
// isHTTPURL reports whether s is an HTTP or HTTPS URL.
func isHTTPURL(s string) bool {
u, err := url.Parse(s)
return err == nil && u.Scheme != "" && u.Host != "" &&
(u.Scheme == "http" || u.Scheme == "https")
}
// maxConfigSize is the maximum response body size for remote config files.
const maxConfigSize = 10 << 20 // 10 MiB
// sharedHTTPClient is reused across config URL fetches to benefit from
// connection pooling.
var sharedHTTPClient = &http.Client{
Timeout: 30 * time.Second,
}
// readConfigFromURL fetches a configuration from an HTTP(S) URL and reads it
// into cfg. The format (yaml or json) is detected from the Content-Type header
// or the URL path extension.
func readConfigFromURL(urlStr string, cfg *config.Config) error {
resp, err := sharedHTTPClient.Get(urlStr)
if err != nil {
return fmt.Errorf("fetch config from %s: %w", sanitizeURL(urlStr), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body) // drain body for connection reuse; ignore error since we are already failing
return fmt.Errorf("fetch config from %s: %s", sanitizeURL(urlStr), resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxConfigSize+1))
if err != nil {
return fmt.Errorf("read config from %s: %w", sanitizeURL(urlStr), err)
}
if len(data) > maxConfigSize {
return fmt.Errorf("config from %s exceeds maximum size %d", sanitizeURL(urlStr), maxConfigSize)
}
format := detectFormat(resp.Header.Get("Content-Type"), urlStr)
if err := cfg.Read(bytes.NewReader(data), format); err != nil {
return fmt.Errorf("parse config from %s: %w", sanitizeURL(urlStr), err)
}
return nil
}
// sanitizeURL returns the URL with any embedded userinfo stripped for safe
// use in error messages.
func sanitizeURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
u.User = nil
return u.String()
}
// detectFormat determines the config format from the HTTP Content-Type header
// or the URL path extension. Returns "yaml" or "json".
func detectFormat(contentType, urlStr string) string {
if mediatype, _, err := mime.ParseMediaType(contentType); err == nil {
switch mediatype {
case "application/json":
return "json"
case "application/yaml", "application/x-yaml",
"text/yaml", "text/x-yaml":
return "yaml"
}
}
if u, err := url.Parse(urlStr); err == nil {
switch strings.ToLower(strings.TrimPrefix(path.Ext(u.Path), ".")) {
case "json":
return "json"
case "yaml", "yml":
return "yaml"
}
}
return "yaml"
}
func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
if cfg1 == nil {
return cfg2
@@ -213,7 +328,9 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
SDs: append(cfg1.SDs, cfg2.SDs...),
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
Rewriters: append(cfg1.Rewriters, cfg2.Rewriters...),
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
RLimiters: append(cfg1.RLimiters, cfg2.RLimiters...),
Loggers: append(cfg1.Loggers, cfg2.Loggers...),
+104
View File
@@ -0,0 +1,104 @@
package quota
import (
"strings"
"time"
"github.com/alecthomas/units"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xquota "github.com/go-gost/x/limiter/quota"
)
// ParseQuotaLimiter is best-effort: malformed fields are logged and treated as unset.
func ParseQuotaLimiter(cfg *config.QuotaConfig) *xquota.Limiter {
if cfg == nil {
return nil
}
log := logger.Default().WithFields(map[string]any{
"kind": "quota",
"quota": cfg.Name,
})
var limit uint64
if s := strings.TrimSpace(cfg.Limit); s != "" && s != "0" {
if v, err := units.ParseBase2Bytes(s); err != nil {
log.Warnf("quota: parse limit %q: %v", s, err)
} else if v > 0 {
limit = uint64(v)
}
}
var dir xquota.Direction
switch strings.ToLower(strings.TrimSpace(cfg.Direction)) {
case "in":
dir = xquota.DirectionIn
case "out":
dir = xquota.DirectionOut
default:
dir = xquota.DirectionTotal
}
var flush time.Duration
if s := strings.TrimSpace(cfg.Flush); s != "" {
if d, err := time.ParseDuration(s); err != nil {
log.Warnf("quota: parse flush %q: %v", s, err)
} else {
flush = d
}
}
return xquota.NewLimiter(cfg.Name, xquota.Options{
Limit: limit,
StartsAt: parseQuotaTime(cfg.StartsAt, log, "startsAt"),
ExpiresAt: parseQuotaTime(cfg.ExpiresAt, log, "expiresAt"),
Direction: dir,
Flush: flush,
Store: parseQuotaStore(cfg.Store, log),
Logger: log,
})
}
func parseQuotaTime(s string, log logger.Logger, field string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
log.Warnf("quota: parse %s %q: %v", field, s, err)
return time.Time{}
}
return t
}
func parseQuotaStore(sc *config.QuotaStoreConfig, log logger.Logger) xquota.Store {
const defaultFile = "gost-quota.json"
if sc == nil || sc.Type == "" || strings.EqualFold(sc.Type, "file") {
path := defaultFile
if sc != nil && strings.TrimSpace(sc.File) != "" {
path = sc.File
}
return xquota.NewFileStore(path)
}
if strings.EqualFold(sc.Type, "redis") {
log.Warnf("quota: redis store not implemented; counter will not be persisted")
var rc config.QuotaRedisConfig
if sc.Redis != nil {
rc = *sc.Redis
}
return xquota.NewRedisStore(xquota.RedisConfig{
Addr: rc.Addr,
Username: rc.Username,
Password: rc.Password,
DB: rc.DB,
Key: rc.Key,
})
}
log.Warnf("quota: unknown store type %q; using file store %q", sc.Type, defaultFile)
return xquota.NewFileStore(defaultFile)
}
+48
View File
@@ -0,0 +1,48 @@
package rewriter
import (
"crypto/tls"
"strings"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
rewriter_plugin "github.com/go-gost/x/rewriter/plugin"
)
// ParseRewriter converts a RewriterConfig into a rewriter.Rewriter.
// It currently supports plugin backends only (HTTP or gRPC).
// Returns nil when cfg is nil or no backend is configured.
func ParseRewriter(cfg *config.RewriterConfig) rewriter.Rewriter {
if cfg == nil {
return nil
}
if cfg.Plugin != nil {
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
ServerName: cfg.Plugin.TLS.ServerName,
InsecureSkipVerify: !cfg.Plugin.TLS.Secure,
}
}
switch strings.ToLower(cfg.Plugin.Type) {
case "http":
return rewriter_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
default:
return rewriter_plugin.NewGRPCPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
}
}
return nil
}
+71 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-gost/core/handler"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
@@ -29,15 +30,32 @@ import (
logger_parser "github.com/go-gost/x/config/parsing/logger"
selector_parser "github.com/go-gost/x/config/parsing/selector"
tls_util "github.com/go-gost/x/internal/util/tls"
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xstats "github.com/go-gost/x/observer/stats"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
xservice "github.com/go-gost/x/service"
"github.com/vishvananda/netns"
)
// plaintextListeners are listener types that never terminate TLS on their
// accepted connections, so any certFile/keyFile/caFile configured for them is
// silently ignored. This catches the common footgun where a user writes e.g.
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
// new plaintext listeners are added.
var plaintextListeners = map[string]bool{
"tcp": true, "udp": true,
"ws": true, "mws": true,
"redirect": true, "tproxy": true,
"rtcp": true, "rudp": true,
"unix": true, "runix": true, "serial": true, "stdio": true,
}
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
// It defaults the listener to "tcp" and the handler to "auto", resolves named
// components from the registry (authers, admissions, bypasses, resolvers,
@@ -87,6 +105,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
}
if (tlsCfg.CertFile != "" || tlsCfg.KeyFile != "" || tlsCfg.CAFile != "") && plaintextListeners[cfg.Listener.Type] {
serviceLogger.Warnf("TLS certificate options are configured but the %q listener does not use TLS and will ignore them; the connection will be plaintext. Use a TLS-capable listener (e.g. mwss, wss, tls, quic, grpc, http2, http3) to enable TLS.",
cfg.Listener.Type)
}
authers := auth_parser.List(cfg.Listener.Auther, cfg.Listener.Authers...)
if len(authers) == 0 {
if auther := auth_parser.ParseAutherFromAuth(cfg.Listener.Auth); auther != nil {
@@ -246,6 +269,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
for _, qname := range cfg.Quotas {
ln = quota_wrapper.WrapListener(ln, strings.TrimSpace(qname))
}
handlerLogger := serviceLogger.WithFields(map[string]any{
"kind": "handler",
})
@@ -277,11 +304,44 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
auther = xauth.AuthenticatorGroup(authers...)
}
// Parse skipauth from handler metadata to whitelist client IPs that
// should bypass authentication. Accepts both YAML arrays
// (skipauth: ["10.0.0.0/8"]) and comma-separated URL query values
// (?skipauth=10.0.0.0/8,192.168.1.1).
if cfg.Handler.Metadata != nil {
hmd := metadata.NewMetadata(cfg.Handler.Metadata)
skipauth := mdutil.GetStrings(hmd, "skipauth")
if len(skipauth) == 0 {
if s := mdutil.GetString(hmd, "skipauth"); s != "" {
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
skipauth = append(skipauth, p)
}
}
}
}
if len(skipauth) > 0 {
if auther != nil {
auther = xauth.WhitelistedAuthenticator(auther, skipauth)
handlerLogger.Debugf("skipauth whitelist applied: %v", skipauth)
} else {
handlerLogger.Warnf("skipauth configured but no auther set — authentication is already disabled for all clients")
}
}
}
var recorders []recorder.RecorderObject
for _, r := range cfg.Recorders {
md := metadata.NewMetadata(r.Metadata)
rec := registry.RecorderRegistry().Get(r.Name)
if r.Metadata != nil {
rec = &xrecorder.MetadataRecorder{
Recorder: rec,
Metadata: r.Metadata,
}
}
recorders = append(recorders, recorder.RecorderObject{
Recorder: registry.RecorderRegistry().Get(r.Name),
Recorder: rec,
Record: r.Record,
Options: &recorder.Options{
Direction: mdutil.GetBool(md, parsing.MDKeyRecorderDirection),
@@ -290,9 +350,18 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
HTTPBody: mdutil.GetBool(md, parsing.MDKeyRecorderHTTPBody),
MaxBodySize: mdutil.GetInt(md, parsing.MDKeyRecorderHTTPMaxBodySize),
},
Metadata: r.Metadata,
})
}
var rew rewriter.Rewriter
if cfg.Rewriter != "" {
if !registry.RewriterRegistry().IsRegistered(cfg.Rewriter) {
serviceLogger.Warnf("rewriter %q not found in registry", cfg.Rewriter)
}
rew = registry.RewriterRegistry().Get(cfg.Rewriter)
}
routerOpts = []chain.RouterOption{
chain.RetriesRouterOption(cfg.Handler.Retries),
chain.TimeoutRouterOption(dialTimeout),
@@ -322,6 +391,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
handler.RecordersOption(recorders...),
handler.RewriterOption(rew),
handler.LoggerOption(handlerLogger),
handler.ServiceOption(cfg.Name),
handler.NetnsOption(netnsIn),
+98 -22
View File
@@ -12,6 +12,8 @@ import (
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"sync/atomic"
"time"
@@ -24,6 +26,24 @@ var (
defaultTLSConfig atomic.Value
)
// testDefaultCertDir overrides the default certificate directory in tests.
// It is set only from same-package test code and left empty in production.
var testDefaultCertDir string
// defaultCertDir returns the directory used for persisting auto-generated
// certificates. It uses testDefaultCertDir when set (for testing), otherwise
// returns $HOME/.gost/.
func defaultCertDir() string {
if testDefaultCertDir != "" {
return testDefaultCertDir
}
dir, err := os.UserHomeDir()
if err != nil {
return ".gost"
}
return filepath.Join(dir, ".gost")
}
// DefaultTLSConfig returns the global default TLS configuration used as a
// fallback when a listener or handler does not specify its own TLS settings.
// The returned config is a shared instance; callers that need to mutate it
@@ -40,44 +60,99 @@ func SetDefaultTLSConfig(cfg *tls.Config) {
}
// BuildDefaultTLSConfig loads or generates the default TLS certificate and key
// from the given config. If cfg is nil it attempts to load well-known files
// ("cert.pem", "key.pem", "ca.pem"). On failure it generates a self-signed
// ECDSA P-256 certificate valid for one year.
// from the given config.
//
// When explicit certificate files are configured (CertFile, KeyFile, or CAFile
// is non-empty) it loads them via tls_util.LoadDefaultConfig — the original
// behaviour preserved for backward compatibility.
//
// When no certificate files are configured (all three empty), it tries sources
// in this order:
// 1. cert.pem / key.pem in the current working directory (backward compatible)
// 2. auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/ (persisted)
// 3. If none found, generates a new ECDSA P-256 CA certificate, persists it
// to $HOME/.gost/, and uses it.
//
// If cfg is nil an empty TLSConfig is used (equivalent to no explicit files),
// so the CWD → persisted → generate path is taken.
func BuildDefaultTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
log := logger.Default()
if cfg == nil {
cfg = &config.TLSConfig{
CertFile: "cert.pem",
KeyFile: "key.pem",
CAFile: "ca.pem",
}
cfg = &config.TLSConfig{}
}
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
if err != nil {
// generate random self-signed certificate.
cert, err := genCertificate(cfg.Validity, cfg.Organization, cfg.CommonName)
if cfg.CertFile != "" || cfg.KeyFile != "" || cfg.CAFile != "" {
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
if err != nil {
return nil, err
}
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
}
log.Debug("load global TLS certificate files failed, use random generated certificate")
} else {
log.Debug("load global TLS certificate files OK")
return tlsConfig, nil
}
tlsConfig, err := loadOrGeneratePersistentTLSConfig(cfg)
if err != nil {
return nil, err
}
return tlsConfig, nil
}
func genCertificate(validity time.Duration, org string, cn string) (cert tls.Certificate, err error) {
rawCert, rawKey, err := generateKeyPair(validity, org, cn)
if err != nil {
return
// loadOrGeneratePersistentTLSConfig tries loading an auto-generated CA
// certificate from these locations (in order):
// 1. cert.pem / key.pem in the current working directory (backward compatible)
// 2. auto-ca-cert.pem / auto-ca-key.pem under defaultCertDir() (persisted)
//
// If none is found it generates a new one, persists it to defaultCertDir()
// (best-effort), and returns it.
func loadOrGeneratePersistentTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
log := logger.Default()
// 1. Try CWD cert.pem / key.pem first (backward compatible).
cwdCert, cwdErr := tls.LoadX509KeyPair("cert.pem", "key.pem")
if cwdErr == nil {
log.Debug("loaded default certificate from current working directory (cert.pem / key.pem)")
return &tls.Config{Certificates: []tls.Certificate{cwdCert}}, nil
}
return tls.X509KeyPair(rawCert, rawKey)
// 2. Try persisted certificate in defaultCertDir().
dir := defaultCertDir()
certFile := filepath.Join(dir, "auto-ca-cert.pem")
keyFile := filepath.Join(dir, "auto-ca-key.pem")
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
log.Debugf("loaded persisted default certificate from %s", dir)
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
}
log.Debug("generating new default certificate (no persisted certificate found)")
// 3. Generate a new certificate.
rawCert, rawKey, err := generateKeyPair(cfg.Validity, cfg.Organization, cfg.CommonName)
if err != nil {
return nil, err
}
// Best-effort persist to disk. If it fails the in-memory certificate is
// still usable.
if err := os.MkdirAll(dir, 0700); err != nil {
log.Warnf("failed to create certificate directory %s: %v", dir, err)
} else {
if err := os.WriteFile(certFile, rawCert, 0644); err != nil {
log.Warnf("failed to persist certificate: %v", err)
} else if err := os.WriteFile(keyFile, rawKey, 0600); err != nil {
log.Warnf("failed to persist private key: %v", err)
} else {
log.Debugf("persisted default certificate to %s", dir)
}
}
cert, err = tls.X509KeyPair(rawCert, rawKey)
if err != nil {
return nil, err
}
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
}
func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, rawKey []byte, err error) {
@@ -124,6 +199,7 @@ func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, ra
x509.ExtKeyUsageServerAuth,
},
BasicConstraintsValid: true,
IsCA: true,
}
if _, isRSA := priv.(*rsa.PrivateKey); isRSA {
template.KeyUsage |= x509.KeyUsageKeyEncipherment
+181 -9
View File
@@ -2,15 +2,58 @@ package parsing
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
// writeTestCertKey writes a self-signed cert/key pair to the given file paths.
// The cert is NOT persisted via the defaultCertDir mechanism — it simulates
// user-provided cert.pem / key.pem in a working directory.
func writeTestCertKey(t *testing.T, certFile, keyFile string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "cwd-test.local"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
if err := os.WriteFile(certFile, certPEM, 0644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
t.Fatalf("write key: %v", err)
}
}
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
@@ -47,6 +90,10 @@ func TestDefaultTLSConfig_InitiallyNil(t *testing.T) {
}
func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
// Use a temp dir so the test is isolated and doesn't touch $HOME/.gost
testDefaultCertDir = t.TempDir()
t.Cleanup(func() { testDefaultCertDir = "" })
// BuildDefaultTLSConfig with nil should generate a self-signed cert
tlsCfg, err := BuildDefaultTLSConfig(nil)
if err != nil {
@@ -58,9 +105,22 @@ func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
if len(tlsCfg.Certificates) == 0 {
t.Fatal("expected at least one certificate")
}
// Verify that the certificate and key files were persisted.
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
if _, err := os.Stat(certFile); os.IsNotExist(err) {
t.Fatal("cert file was not persisted")
}
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
t.Fatal("key file was not persisted")
}
}
func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
testDefaultCertDir = t.TempDir()
t.Cleanup(func() { testDefaultCertDir = "" })
cfg := &config.TLSConfig{
Validity: 0, // uses default
Organization: "TestOrg",
@@ -78,26 +138,138 @@ func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
}
}
func TestBuildDefaultTLSConfig_TwoCallsDifferentCerts(t *testing.T) {
func TestBuildDefaultTLSConfig_TwoCallsSameCerts(t *testing.T) {
testDefaultCertDir = t.TempDir()
t.Cleanup(func() { testDefaultCertDir = "" })
cfg1, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("unexpected error on first call: %v", err)
}
cfg2, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("unexpected error on second call: %v", err)
}
if len(cfg1.Certificates) == 0 || len(cfg2.Certificates) == 0 {
t.Fatal("expected certificates in both configs")
}
// Each call generates a random cert; they should have different raw bytes.
if len(cfg1.Certificates[0].Certificate) == 0 || len(cfg2.Certificates[0].Certificate) == 0 {
t.Fatal("expected non-empty certificate raw bytes")
}
// Both calls should return the SAME persisted certificate.
c1 := cfg1.Certificates[0].Certificate[0]
c2 := cfg2.Certificates[0].Certificate[0]
if bytes.Equal(c1, c2) {
t.Fatal("expected different certificates from two calls")
if !bytes.Equal(c1, c2) {
t.Fatal("expected same certificate from two calls (persisted)")
}
}
func TestBuildDefaultTLSConfig_PersistsAndReloads(t *testing.T) {
testDefaultCertDir = t.TempDir()
defer func() { testDefaultCertDir = "" }()
// First call generates and persists.
cfg1, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("unexpected error on first call: %v", err)
}
// Verify files exist on disk.
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
if _, err := os.Stat(certFile); os.IsNotExist(err) {
t.Fatal("cert file was not persisted after first call")
}
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
t.Fatal("key file was not persisted after first call")
}
// Second call loads the persisted cert.
cfg2, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("unexpected error on second call: %v", err)
}
c1 := cfg1.Certificates[0].Certificate[0]
c2 := cfg2.Certificates[0].Certificate[0]
if !bytes.Equal(c1, c2) {
t.Fatal("expected same certificate on reload (persisted)")
}
}
func TestBuildDefaultTLSConfig_FallsBackWhenWriteBlocked(t *testing.T) {
// Point to a directory that can't be created (e.g. a file path).
// We use a path under /proc on Linux which is a read-only filesystem.
testDefaultCertDir = "/proc/doesnotexist/certdir"
defer func() { testDefaultCertDir = "" }()
tlsCfg, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("unexpected error (should fall back to in-memory): %v", err)
}
if tlsCfg == nil {
t.Fatal("expected non-nil config even when write fails")
}
if len(tlsCfg.Certificates) == 0 {
t.Fatal("expected at least one certificate from fallback")
}
}
// TestBuildDefaultTLSConfig_CWDTakesPriority verifies that when cert.pem and
// key.pem exist in the current working directory, they take precedence over
// both the persisted certificate and auto-generation.
func TestBuildDefaultTLSConfig_CWDTakesPriority(t *testing.T) {
// Create a temp dir with cert.pem / key.pem.
cwd := t.TempDir()
writeTestCertKey(t, filepath.Join(cwd, "cert.pem"), filepath.Join(cwd, "key.pem"))
// Also set up a persisted cert directory.
testDefaultCertDir = t.TempDir()
defer func() { testDefaultCertDir = "" }()
// Pre-populate persisted cert so we can prove CWD wins.
_, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("pre-populate persisted cert: %v", err)
}
// Change to the CWD that has cert.pem / key.pem.
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(cwd); err != nil {
t.Fatalf("chdir: %v", err)
}
defer os.Chdir(origDir)
// BuildDefaultTLSConfig should load CWD cert, not the persisted one.
tlsCfg, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tlsCfg.Certificates) == 0 {
t.Fatal("expected at least one certificate")
}
// Load CWD cert directly and compare.
cwdCert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
if err != nil {
t.Fatalf("load CWD cert: %v", err)
}
got := tlsCfg.Certificates[0].Certificate[0]
want := cwdCert.Certificate[0]
if !bytes.Equal(got, want) {
t.Fatal("CWD certificate was not used (persisted or auto-generated cert took priority)")
}
// Load persisted cert to prove it's different from CWD cert.
persistedCert, err := tls.LoadX509KeyPair(
filepath.Join(testDefaultCertDir, "auto-ca-cert.pem"),
filepath.Join(testDefaultCertDir, "auto-ca-key.pem"),
)
if err != nil {
t.Fatalf("load persisted cert: %v", err)
}
if bytes.Equal(got, persistedCert.Certificate[0]) {
t.Fatal("CWD cert should differ from persisted cert; test setup issue")
}
}
+5 -3
View File
@@ -106,9 +106,11 @@ func (c *relayConnector) bind(conn net.Conn, cmd relay.CmdType, network, address
})
fa := &relay.AddrFeature{}
if network != "unix" {
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
address = net.JoinHostPort("0.0.0.0", address)
}
if address == "" {
address = "0.0.0.0:0"
} else if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
address = net.JoinHostPort("0.0.0.0", address)
}
}
fa.ParseFrom(address)
req.Features = append(req.Features, fa)
+13 -1
View File
@@ -3,6 +3,7 @@ package quic
import (
"context"
"net"
"time"
"github.com/quic-go/quic-go"
)
@@ -12,7 +13,9 @@ type quicSession struct {
}
func (session *quicSession) GetConn() (*quicConn, error) {
stream, err := session.session.OpenStreamSync(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
return nil, err
}
@@ -23,6 +26,15 @@ func (session *quicSession) GetConn() (*quicConn, error) {
}, nil
}
func (session *quicSession) IsClosed() bool {
select {
case <-session.session.Context().Done():
return true
default:
return false
}
}
func (session *quicSession) Close() error {
return session.session.CloseWithError(quic.ApplicationErrorCode(0), "closed")
}
+16 -3
View File
@@ -75,9 +75,12 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Lock()
defer d.sessionMutex.Unlock()
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
ok = false
}
if !ok {
options := &dialer.DialOptions{}
for _, opt := range opts {
@@ -91,6 +94,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
pc, err = icmp_pkg.ListenPacket("ip4:icmp", "")
}
if err != nil {
d.sessionMutex.Unlock()
return
}
@@ -105,16 +109,22 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
if err != nil {
d.logger.Error(err)
pc.Close()
d.sessionMutex.Unlock()
return nil, err
}
d.sessions[addr] = session
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
if err != nil {
d.sessionMutex.Lock()
if d.sessions[addr] == session {
delete(d.sessions, addr)
}
session.Close()
delete(d.sessions, addr)
d.sessionMutex.Unlock()
return nil, err
}
@@ -133,7 +143,10 @@ func (d *icmpDialer) initSession(ctx context.Context, addr net.Addr, conn net.Pa
}
tlsCfg := d.options.TLSConfig
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
tlsCfg = tlsCfg.Clone()
if len(tlsCfg.NextProtos) == 0 {
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
}
session, err := quic.DialEarly(ctx, conn, addr, tlsCfg, quicConfig)
if err != nil {
+11 -4
View File
@@ -14,14 +14,21 @@ type metadata struct {
}
func (d *icmpDialer) parseMetadata(md mdata.Metadata) (err error) {
if mdutil.GetBool(md, "keepalive") {
d.md.keepAlivePeriod = mdutil.GetDuration(md, "ttl")
const (
keepAlive = "keepAlive"
keepAlivePeriod = "ttl"
handshakeTimeout = "handshakeTimeout"
maxIdleTimeout = "maxIdleTimeout"
)
if md == nil || !md.IsExists(keepAlive) || mdutil.GetBool(md, keepAlive) {
d.md.keepAlivePeriod = mdutil.GetDuration(md, keepAlivePeriod)
if d.md.keepAlivePeriod <= 0 {
d.md.keepAlivePeriod = 10 * time.Second
}
}
d.md.handshakeTimeout = mdutil.GetDuration(md, "handshakeTimeout")
d.md.maxIdleTimeout = mdutil.GetDuration(md, "maxIdleTimeout")
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
return
}
+13 -1
View File
@@ -3,6 +3,7 @@ package quic
import (
"context"
"net"
"time"
"github.com/quic-go/quic-go"
)
@@ -12,7 +13,9 @@ type quicSession struct {
}
func (session *quicSession) GetConn() (*quicConn, error) {
stream, err := session.session.OpenStreamSync(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
return nil, err
}
@@ -23,6 +26,15 @@ func (session *quicSession) GetConn() (*quicConn, error) {
}, nil
}
func (session *quicSession) IsClosed() bool {
select {
case <-session.session.Context().Done():
return true
default:
return false
}
}
func (session *quicSession) Close() error {
return session.session.CloseWithError(quic.ApplicationErrorCode(0), "closed")
}
+17 -3
View File
@@ -58,9 +58,12 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Lock()
defer d.sessionMutex.Unlock()
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
ok = false
}
if !ok {
options := &dialer.DialOptions{}
for _, opt := range opts {
@@ -69,11 +72,13 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
c, err := options.Dialer.Dial(ctx, "udp", "")
if err != nil {
d.sessionMutex.Unlock()
return nil, err
}
pc, ok := c.(net.PacketConn)
if !ok {
c.Close()
d.sessionMutex.Unlock()
return nil, errors.New("quic: wrong connection type")
}
@@ -85,16 +90,22 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
if err != nil {
d.logger.Error(err)
pc.Close()
d.sessionMutex.Unlock()
return nil, err
}
d.sessions[addr] = session
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
if err != nil {
d.sessionMutex.Lock()
if d.sessions[addr] == session {
delete(d.sessions, addr)
}
session.Close()
delete(d.sessions, addr)
d.sessionMutex.Unlock()
return nil, err
}
@@ -115,7 +126,10 @@ func (d *quicDialer) initSession(ctx context.Context, addr net.Addr, conn net.Pa
}
tlsCfg := d.options.TLSConfig
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
tlsCfg = tlsCfg.Clone()
if len(tlsCfg.NextProtos) == 0 {
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
}
session, err := quic.DialEarly(ctx, conn, addr, tlsCfg, quicConfig)
if err != nil {
+12
View File
@@ -63,6 +63,18 @@ func (d *sshdDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
delete(d.sessions, addr) // session is dead
ok = false
}
if ok {
// Health check: verify the cached session is still alive before
// returning it. A dead-but-not-yet-detected session (e.g. idle
// connection silently dropped by NAT) would cause "ssh: unexpected
// packet in response to channel open" later in the connector.
if err := session.Ping(d.md.keepaliveTimeout); err != nil {
d.options.Logger.Debugf("sshd session ping failed: %v, recreating", err)
session.Close()
delete(d.sessions, addr)
ok = false
}
}
if !ok {
var options dialer.DialOptions
for _, opt := range opts {
+106
View File
@@ -0,0 +1,106 @@
package utls
import (
"context"
"crypto/tls"
"net"
"time"
"unsafe"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/logger"
md "github.com/go-gost/core/metadata"
xctx "github.com/go-gost/x/ctx"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/net/proxyproto"
"github.com/go-gost/x/registry"
utls "github.com/refraction-networking/utls"
)
func init() {
registry.DialerRegistry().Register("utls", NewDialer)
}
type utlsDialer struct {
md metadata
log logger.Logger
options dialer.Options
}
func NewDialer(opts ...dialer.Option) dialer.Dialer {
options := dialer.Options{}
for _, opt := range opts {
opt(&options)
}
return &utlsDialer{
log: options.Logger,
options: options,
}
}
func (d *utlsDialer) Init(md md.Metadata) (err error) {
return d.parseMetadata(md)
}
func (d *utlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) {
var options dialer.DialOptions
for _, opt := range opts {
opt(&options)
}
conn, err := options.Dialer.Dial(ctx, "tcp", addr)
if err != nil {
d.log.Error(err)
}
if d.md.keepalive {
xnet.ApplyKeepalive(conn, net.KeepAliveConfig{
Enable: true,
Idle: d.md.keepaliveIdle,
Interval: d.md.keepaliveInterval,
Count: d.md.keepaliveCount,
})
}
conn = proxyproto.WrapClientConn(
d.options.ProxyProtocol,
xctx.SrcAddrFromContext(ctx),
xctx.DstAddrFromContext(ctx),
conn)
return conn, err
}
// Handshake implements dialer.Handshaker.
func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...dialer.HandshakeOption) (net.Conn, error) {
if d.md.handshakeTimeout > 0 {
conn.SetDeadline(time.Now().Add(d.md.handshakeTimeout))
defer conn.SetDeadline(time.Time{})
}
clientHelloID, ok := GetClientHelloID(d.md.fingerprint)
if ok {
d.log.Debugf("utls handshake with fingerprint: %s", d.md.fingerprint)
utlsCfg := (*utls.Config)(unsafe.Pointer(d.options.TLSConfig))
uconn := utls.UClient(conn, utlsCfg, clientHelloID)
if err := uconn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return uconn, nil
}
// fingerprint not set, empty, "golang", or unknown: fall through to
// standard crypto/tls.
if d.md.fingerprint != "" {
d.log.Warnf("unknown utls fingerprint: %s, falling back to standard TLS", d.md.fingerprint)
}
tlsConn := tls.Client(conn, d.options.TLSConfig)
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return tlsConn, nil
}
+33
View File
@@ -0,0 +1,33 @@
package utls
import (
utls "github.com/refraction-networking/utls"
)
// fingerprintMap maps user-facing string names to uTLS ClientHelloID presets.
// Each _Auto alias automatically tracks the latest known fingerprint for that
// browser in the uTLS library.
var fingerprintMap = map[string]utls.ClientHelloID{
"chrome": utls.HelloChrome_Auto,
"firefox": utls.HelloFirefox_Auto,
"ios": utls.HelloIOS_Auto,
"safari": utls.HelloSafari_Auto,
"edge": utls.HelloEdge_Auto,
"randomized": utls.HelloRandomized,
"randomized-alpn": utls.HelloRandomizedALPN,
"randomized-noalpn": utls.HelloRandomizedNoALPN,
"golang": utls.HelloGolang,
"custom": utls.HelloCustom,
}
// GetClientHelloID returns the utls.ClientHelloID for the given fingerprint
// name. The empty string and "golang" both return ok=false, signalling the
// caller to fall through to standard crypto/tls. Unknown names also return
// ok=false.
func GetClientHelloID(name string) (utls.ClientHelloID, bool) {
if name == "" || name == "golang" {
return utls.ClientHelloID{}, false
}
id, ok := fingerprintMap[name]
return id, ok
}
+37
View File
@@ -0,0 +1,37 @@
package utls
import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
)
type metadata struct {
handshakeTimeout time.Duration
keepalive bool
keepaliveIdle time.Duration
keepaliveInterval time.Duration
keepaliveCount int
fingerprint string
}
func (d *utlsDialer) parseMetadata(md mdata.Metadata) (err error) {
const (
handshakeTimeout = "handshakeTimeout"
fingerprint = "fingerprint"
)
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
d.md.keepalive = mdutil.GetBool(md, "keepalive")
d.md.keepaliveIdle = mdutil.GetDuration(md, "keepalive.idle")
d.md.keepaliveInterval = mdutil.GetDuration(md, "keepalive.interval")
d.md.keepaliveCount = mdutil.GetInt(md, "keepalive.count")
d.md.fingerprint = mdutil.GetString(md, fingerprint)
return
}
+75
View File
@@ -0,0 +1,75 @@
# Plan: Add uTLS Fingerprint Dialer (`utls`)
## Context
**Issue**: [go-gost/gost#31](https://github.com/go-gost/gost/issues/31) — Feature request to add TLS ClientHello fingerprint simulation using the [uTLS](https://github.com/refraction-networking/utls) library. GOST currently uses Go's `crypto/tls` which produces a distinctive Go TLS fingerprint that's easily blocked. Users want to mimic Chrome/Firefox/Safari/etc. fingerprints.
**Approach**: New `"utls"` dialer type (standalone, not modifying the existing `"tls"` dialer). The fingerprint is dialer-only metadata — not on the shared `TLSConfig` struct. This keeps the standard TLS dialer untouched and avoids pulling in the uTLS dependency for all users.
## Files to Create
### 1. `x/dialer/utls/fingerprint.go` — String→ClientHelloID map
Maps user-facing fingerprint names to `utls.ClientHelloID` presets. Supported names: `chrome`, `firefox`, `ios`, `safari`, `edge`, `randomized`, `randomized-alpn`, `randomized-noalpn`, `golang`, `custom`. Empty string and `"golang"` both mean "fall through to standard crypto/tls" (return `ok=false`). Unknown names get a warning log and also fall through.
### 2. `x/dialer/utls/metadata.go` — Metadata parsing
Mirrors `dialer/tls/metadata.go` exactly, adding a `fingerprint string` field. Parsed keys:
- `handshakeTimeout` (duration)
- `keepalive`, `keepalive.idle`, `keepalive.interval`, `keepalive.count` (TCP keepalive)
- `fingerprint` (string) — **new**
### 3. `x/dialer/utls/dialer.go` — Core dialer
Mirrors `dialer/tls/dialer.go`. Registered as `"utls"`. The `Dial()` method is identical to the TLS dialer. The `Handshake()` method diverges: looks up the fingerprint; if `ok`, uses `utls.UClient(conn, tlsConfig, clientHelloID)`; otherwise falls through to `crypto/tls.Client()`.
## Files to Modify
### 4. `x/go.mod` — Add uTLS dependency
Add `github.com/refraction-networking/utls v1.8.2` to the require block, then run `go mod tidy` from `x/`.
### 5. `gost/cmd/gost/register.go` — Blank import
Add `_ "github.com/go-gost/x/dialer/utls"` in the "Register dialers" section, alphabetically between the `unix` and `ws` imports.
## No Changes Needed
- **Config parsing** — `node/parse.go` already passes `dialCfg.Metadata` to `d.Init()`; no changes required to any parsing code.
- **`config/config.go`** — `DialerConfig.Metadata` already supports arbitrary keys; `fingerprint` flows through naturally.
- **Metadata key constants** — No new constant in `config/parsing/parse.go`; local `const` in `parseMetadata()` follows existing dialer convention.
## Usage Example
```yaml
services:
- name: service-0
addr: :8080
handler:
type: tcp
listener:
type: tcp
forwarder:
nodes:
- name: target-0
addr: example.com:443
dialer:
type: utls
metadata:
fingerprint: chrome
```
## Verification
```bash
# 1. Tidy dependencies
cd x && go mod tidy
# 2. Build + vet the x module
cd x && go build ./... && go vet ./...
# 3. Build + vet the gost binary (verifies blank import)
cd gost && go build ./cmd/gost/... && go vet ./...
```
No unit tests needed — the existing codebase has no tests in `x/dialer/`.
+17 -4
View File
@@ -4,15 +4,16 @@ go 1.26.3
require (
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
github.com/andybalholm/brotli v1.0.6
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.1
github.com/go-gost/core v0.4.1
github.com/go-gost/core v0.5.2
github.com/go-gost/go-shadowsocks2 v0.1.3
github.com/go-gost/gosocks4 v0.1.0
github.com/go-gost/gosocks5 v0.5.0
github.com/go-gost/plugin v0.3.0
github.com/go-gost/relay v0.6.0
github.com/go-gost/plugin v0.4.0
github.com/go-gost/relay v0.6.1
github.com/go-gost/tls-dissector v0.2.0
github.com/go-redis/redis/v8 v8.11.5
github.com/gobwas/glob v0.2.3
@@ -20,6 +21,7 @@ require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.17.4
github.com/miekg/dns v1.1.61
github.com/mitchellh/go-homedir v1.1.0
github.com/patrickmn/go-cache v2.1.0+incompatible
@@ -28,8 +30,10 @@ require (
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.59.1
github.com/quic-go/webtransport-go v0.10.0
github.com/refraction-networking/utls v1.8.2
github.com/rs/xid v1.3.0
github.com/shadowsocks/go-shadowsocks2 v0.1.5
github.com/shirou/gopsutil/v3 v3.24.5
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.11.1
@@ -56,7 +60,16 @@ require (
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20
)
require github.com/sirupsen/logrus v1.9.3 // indirect
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
)
require (
github.com/alessio/shellescape v1.4.1 // indirect
+37 -6
View File
@@ -4,6 +4,8 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAu
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -49,24 +51,28 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-gost/core v0.4.1 h1:/MZYv0tSkw9yoK2nl0GLl3GjT1/3qKl5u3E/DADyeZE=
github.com/go-gost/core v0.4.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
github.com/go-gost/core v0.5.1 h1:HbIn3naEOC661Z4SwzaSYUzffWSRbU0P6gkxiHmuG0I=
github.com/go-gost/core v0.5.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
github.com/go-gost/core v0.5.2 h1:TofTjnvOkga3IO625qTcUWukhR80cwBXI1RSkoOU0p4=
github.com/go-gost/core v0.5.2/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
github.com/go-gost/go-shadowsocks2 v0.1.3 h1:6CUZLp+mTWXnKP2aK8/Z9ZP+ERMX9gSbywmPu4kGX/A=
github.com/go-gost/go-shadowsocks2 v0.1.3/go.mod h1:866zFNNI3He6Wef1M/IvAjTal74WhcfKfBgRpTlkKys=
github.com/go-gost/gosocks4 v0.1.0 h1:eAzev6qw4fzkFQKC9uCHLVNnnPdHyqCggbnfNN80Pmk=
github.com/go-gost/gosocks4 v0.1.0/go.mod h1:hzVjwijJuZR1pp3GqpTj+AKcSGrx68RlWTrQMFMYBP0=
github.com/go-gost/gosocks5 v0.5.0 h1:YE37l1MJwde8diIQdynStqogMotG5enoTdborhA5yic=
github.com/go-gost/gosocks5 v0.5.0/go.mod h1:1G6I7HP7VFVxveGkoK8mnprnJqSqJjdcASKsdUn4Pp4=
github.com/go-gost/plugin v0.3.0 h1:pmll8nNd9PX92BWMB5+b2y2SEkBAWJLxD2ANfX+WHuw=
github.com/go-gost/plugin v0.3.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
github.com/go-gost/relay v0.6.0 h1:o8r0B3eTw8Rwwent+5x5Cw1g4AyNUl4hNqiyDJXkFvA=
github.com/go-gost/relay v0.6.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
github.com/go-gost/plugin v0.4.0 h1:M7MR5PL7QAFCrdWfcXVX+5iLNTVIZlhl8FfdV/K8dZY=
github.com/go-gost/plugin v0.4.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
github.com/go-gost/relay v0.6.1 h1:7SqnHFbY8x/DzvjpK03a5zcVH9+TbJAcnW/RT6s1ecc=
github.com/go-gost/relay v0.6.1/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
github.com/go-gost/tls-dissector v0.2.0 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc1SicVg=
github.com/go-gost/tls-dissector v0.2.0/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -104,6 +110,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -123,6 +130,8 @@ github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9q
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -135,6 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -173,6 +184,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -188,6 +201,8 @@ github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBi
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -200,6 +215,12 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28=
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -240,6 +261,10 @@ github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbH
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -263,6 +288,8 @@ github.com/xtaci/tcpraw v1.2.25 h1:VDlqo0op17JeXBM6e2G9ocCNLOJcw9mZbobMbJjo0vk=
github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1GgJQbLk=
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
@@ -327,13 +354,17 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+62 -7
View File
@@ -2,6 +2,7 @@ package auto
import (
"bufio"
"bytes"
"context"
"net"
"time"
@@ -12,7 +13,10 @@ import (
"github.com/go-gost/gosocks4"
"github.com/go-gost/gosocks5"
ctxvalue "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/util/sniffing"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
)
@@ -94,6 +98,13 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
}
br := bufio.NewReader(conn)
// Peek 1 byte first to detect small protocols (SOCKS4/SOCKS5)
// that send fewer bytes than the 5-byte TLS record header that
// Sniff() needs. If we called Sniff() first, its Peek(5) would
// block indefinitely on a SOCKS5 greeting (3 bytes: VER, NMETHODS,
// METHODS), deadlocking with the client that is itself waiting for
// the server's method-selection reply.
b, err := br.Peek(1)
if err != nil {
log.Error(err)
@@ -101,20 +112,64 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
return err
}
conn = xnet.NewReadWriteConn(br, conn, conn)
switch b[0] {
case gosocks4.Ver4: // socks4
case gosocks4.Ver4:
conn = xnet.NewReadWriteConn(br, conn, conn)
if h.socks4Handler != nil {
return h.socks4Handler.Handle(ctx, conn)
}
case gosocks5.Ver5: // socks5
return nil
case gosocks5.Ver5:
conn = xnet.NewReadWriteConn(br, conn, conn)
if h.socks5Handler != nil {
return h.socks5Handler.Handle(ctx, conn)
}
default: // http
if h.httpHandler != nil {
return h.httpHandler.Handle(ctx, conn)
}
return nil
}
// Not SOCKS — sniff for TLS, HTTP, or SSH.
proto, _ := sniffing.Sniff(ctx, br)
conn = xnet.NewReadWriteConn(br, conn, conn)
if proto == sniffing.ProtoTLS {
return h.handleTLS(ctx, conn, log)
}
// Default to HTTP.
if h.httpHandler != nil {
return h.httpHandler.Handle(ctx, conn)
}
return nil
}
// handleTLS forwards a TLS connection that arrived at the auto handler.
// It parses the ClientHello for SNI, dials upstream through the router,
// forwards the ClientHello, records ServerHello metadata, and pipes data
// bidirectionally.
func (h *autoHandler) handleTLS(ctx context.Context, conn net.Conn, log logger.Logger) error {
ro := &xrecorder.HandlerRecorderObject{
Network: "tcp",
Service: h.options.Service,
RemoteAddr: conn.RemoteAddr().String(),
LocalAddr: conn.LocalAddr().String(),
SID: ctxvalue.SidFromContext(ctx).String(),
Time: time.Now(),
}
sniffer := &sniffing.Sniffer{}
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
if err != nil {
return nil, err
}
ro.Route = buf.String()
return cc, nil
}
return sniffer.HandleTLS(ctx, "tcp", conn,
sniffing.WithDial(dial),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
}
+17
View File
@@ -108,6 +108,23 @@ func (h *dnsHandler) Init(md md.Metadata) (err error) {
h.exchangers[node.Name] = ex
}
if len(h.exchangers) == 0 {
// Try system nameservers first (from /etc/resolv.conf on Unix).
for i, addr := range systemNameservers() {
ex, err := exchanger.NewExchanger(
addr,
exchanger.RouterOption(h.options.Router),
exchanger.TimeoutOption(h.md.timeout),
exchanger.LoggerOption(log),
)
if err != nil {
log.Warnf("system nameserver %s: %v", addr, err)
continue
}
h.exchangers[fmt.Sprintf("system-%d", i)] = ex
}
}
if len(h.exchangers) == 0 {
ex, err := exchanger.NewExchanger(
defaultNameserver,
+50
View File
@@ -0,0 +1,50 @@
//go:build !windows
package dns
import (
"bufio"
"fmt"
"net"
"os"
"strings"
)
// systemNameservers reads the system DNS configuration from /etc/resolv.conf
// and returns the nameserver addresses as URL-like strings suitable for
// exchanger.NewExchanger (e.g., "udp://1.1.1.1:53").
// Returns nil if the file cannot be read or contains no nameserver entries.
func systemNameservers() []string {
f, err := os.Open("/etc/resolv.conf")
if err != nil {
return nil
}
defer f.Close()
var servers []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' || line[0] == ';' {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != "nameserver" {
continue
}
addr := fields[1]
// Verify it's a valid IP address.
if ip := net.ParseIP(addr); ip == nil {
continue
}
// If no port, default to 53.
if _, _, err := net.SplitHostPort(addr); err != nil {
addr = net.JoinHostPort(addr, "53")
}
servers = append(servers, fmt.Sprintf("udp://%s", addr))
}
if err := scanner.Err(); err != nil {
return nil
}
return servers
}
+8
View File
@@ -0,0 +1,8 @@
package dns
// systemNameservers returns nil on Windows because reading the system DNS
// configuration requires the Windows IP Helper API. Callers should fall
// back to the hardcoded default when this returns nil.
func systemNameservers() []string {
return nil
}
+5 -3
View File
@@ -15,9 +15,9 @@ import (
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/recorder"
stats "github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/logger"
stats "github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xnet "github.com/go-gost/x/internal/net"
@@ -132,6 +132,7 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -140,6 +141,7 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -233,7 +235,7 @@ type SnifferBuilder struct {
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to sniffing.Sniffer.
// See sniffing.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration
ReadTimeout time.Duration
}
// Build creates a new sniffing.Sniffer from the builder's configuration.
+17 -14
View File
@@ -445,20 +445,9 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt
ro.HTTP.Response.Header = resp.Header
}()
// HTTP/2 connection preface "PRI * HTTP/2.0" is rejected, as are
// non-CONNECT requests without an http:// scheme.
if req.Method == "PRI" ||
(req.Method != http.MethodConnect && req.URL.Scheme != "http") {
resp.StatusCode = http.StatusBadRequest
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
return resp.Write(conn)
}
// Authenticate before request validation so that probe resistance
// can intercept non-proxy-form requests (e.g., browser/scanner probes
// that send "GET / HTTP/1.1" instead of proxy-form URLs).
result := h.auth.Authenticate(ctx, req)
if !result.OK {
if result.PipeTo != "" {
@@ -477,6 +466,20 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt
return errors.New("authentication failed")
}
// HTTP/2 connection preface "PRI * HTTP/2.0" is rejected, as are
// non-CONNECT requests without an http:// scheme.
if req.Method == "PRI" ||
(req.Method != http.MethodConnect && req.URL.Scheme != "http") {
resp.StatusCode = http.StatusBadRequest
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
return resp.Write(conn)
}
log = log.WithFields(map[string]any{"clientID": result.ClientID})
ro.ClientID = result.ClientID
+56
View File
@@ -4,13 +4,17 @@ import (
"bytes"
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"strconv"
"time"
"github.com/go-gost/core/hosts"
"github.com/go-gost/core/logger"
stats "github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/resolver"
ictx "github.com/go-gost/x/internal/ctx"
"github.com/go-gost/x/internal/net/udp"
"github.com/go-gost/x/internal/util/socks"
@@ -93,6 +97,16 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, clientID str
return err
}
// Wrap raw *net.UDPConn so domainAddr from socks.UDPTunServerConn.ReadFrom
// are resolved through the configured resolver instead of failing WriteTo.
if _, ok := pc.(*net.UDPConn); ok {
pc = &resolvePacketConn{
PacketConn: pc,
resolver: h.options.Router.Options().Resolver,
hostMapper: h.options.Router.Options().HostMapper,
}
}
// Wrap the HTTP connection as a SOCKS5 UDP tunnel server conn so
// that the relay can read/write SOCKS5-encapsulated UDP datagrams.
relay := udp.NewRelay(socks.UDPTunServerConn(conn), pc).
@@ -110,3 +124,45 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, clientID str
return nil
}
// resolvePacketConn wraps a net.PacketConn and resolves domain addresses
// in WriteTo calls through hostMapper → configured resolver → system DNS.
type resolvePacketConn struct {
net.PacketConn
resolver resolver.Resolver
hostMapper hosts.HostMapper
}
func (c *resolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
host, portStr, err := net.SplitHostPort(addr.String())
if err != nil {
return c.PacketConn.WriteTo(b, addr)
}
if net.ParseIP(host) != nil {
return c.PacketConn.WriteTo(b, addr)
}
var ips []net.IP
if c.hostMapper != nil {
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
}
if len(ips) == 0 && c.resolver != nil {
ips, _ = c.resolver.Resolve(context.Background(), "ip", host)
}
if len(ips) == 0 {
ips, _ = net.LookupIP(host)
}
if len(ips) == 0 {
return 0, fmt.Errorf("http udp: cannot resolve %s", host)
}
ip := ips[0]
for _, candidate := range ips {
if candidate.To4() != nil {
ip = candidate
break
}
}
port, _ := strconv.Atoi(portStr)
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
}
+38 -37
View File
@@ -355,6 +355,7 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
// Resolve target address
raddr, err := net.ResolveUDPAddr("udp", targetAddr)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
log.Error("masque: failed to resolve target address: ", err)
return err
}
@@ -370,43 +371,6 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
// Get the underlying HTTP/3 stream
stream := streamer.HTTPStream()
// Send success response with capsule-protocol header
w.Header().Set("Capsule-Protocol", "?1")
w.WriteHeader(http.StatusOK)
// Flush the response headers
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
// Create datagram connection wrapping the HTTP/3 stream (client side)
datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr)
defer datagramConn.Close()
// Wrap with recorder stats and traffic limiter
var clientPC net.PacketConn = datagramConn
clientPC = stats_wrapper.WrapPacketConn(clientPC, pStats)
clientPC = traffic_wrapper.WrapPacketConn(
clientPC,
h.limiter,
clientID,
limiter.ServiceOption(h.options.Service),
limiter.ScopeOption(limiter.ScopeClient),
limiter.NetworkOption("udp"),
limiter.AddrOption(targetAddr),
limiter.ClientOption(clientID),
limiter.SrcOption(ro.RemoteAddr),
)
// Track per-client connection stats
if h.options.Observer != nil {
pstats := h.stats.Stats(clientID)
pstats.Add(stats.KindTotalConns, 1)
pstats.Add(stats.KindCurrentConns, 1)
defer pstats.Add(stats.KindCurrentConns, -1)
clientPC = stats_wrapper.WrapPacketConn(clientPC, pstats)
}
// Get target connection - either through router/chain or direct
var targetPC net.PacketConn
@@ -460,6 +424,43 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
// Wrap target with metrics
targetPC = metrics.WrapPacketConn(h.options.Service, targetPC)
// Send success response with capsule-protocol header
w.Header().Set("Capsule-Protocol", "?1")
w.WriteHeader(http.StatusOK)
// Flush the response headers
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
// Create datagram connection wrapping the HTTP/3 stream (client side)
datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr)
defer datagramConn.Close()
// Wrap with recorder stats and traffic limiter
var clientPC net.PacketConn = datagramConn
clientPC = stats_wrapper.WrapPacketConn(clientPC, pStats)
clientPC = traffic_wrapper.WrapPacketConn(
clientPC,
h.limiter,
clientID,
limiter.ServiceOption(h.options.Service),
limiter.ScopeOption(limiter.ScopeClient),
limiter.NetworkOption("udp"),
limiter.AddrOption(targetAddr),
limiter.ClientOption(clientID),
limiter.SrcOption(ro.RemoteAddr),
)
// Track per-client connection stats
if h.options.Observer != nil {
pstats := h.stats.Stats(clientID)
pstats.Add(stats.KindTotalConns, 1)
pstats.Add(stats.KindCurrentConns, 1)
defer pstats.Add(stats.KindCurrentConns, -1)
clientPC = stats_wrapper.WrapPacketConn(clientPC, pstats)
}
// Relay UDP packets between client and target
relay := udp.NewRelay(clientPC, targetPC).
WithService(h.options.Service).
+1 -1
View File
@@ -43,7 +43,7 @@ func (h *metricsHandler) Init(md md.Metadata) (err error) {
xmetrics.Enable(true)
h.handler = promhttp.Handler()
h.handler = promhttp.HandlerFor(xmetrics.Registry(), promhttp.HandlerOpts{})
mux := http.NewServeMux()
mux.Handle(h.md.path, http.HandlerFunc(h.handleFunc))
+9 -4
View File
@@ -1,7 +1,6 @@
package redirect
import (
"encoding/binary"
"errors"
"net"
"syscall"
@@ -30,6 +29,10 @@ func (h *redirectHandler) getOriginalDstAddr(conn net.Conn) (addr net.Addr, err
var cerr error
err = rc.Control(func(fd uintptr) {
if tcpAddr.IP.To4() != nil {
// SO_ORIGINAL_DST returns a struct sockaddr_in (16 bytes).
// GetsockoptIPv6Mreq reads into a 20-byte ipv6_mreq; only the
// first 16 bytes (the Multiaddr field) are populated.
// Layout: [0:2]=sin_family, [2:4]=sin_port (BE), [4:8]=sin_addr.
mreq, err := unix.GetsockoptIPv6Mreq(int(fd), unix.IPPROTO_IP, unix.SO_ORIGINAL_DST)
if err != nil {
cerr = err
@@ -41,17 +44,19 @@ func (h *redirectHandler) getOriginalDstAddr(conn net.Conn) (addr net.Addr, err
Port: int(mreq.Multiaddr[2])<<8 + int(mreq.Multiaddr[3]),
}
} else {
// SO_ORIGINAL_DST returns a struct sockaddr_in6 (28 bytes).
// GetsockoptIPv6MTUInfo reads into a 32-byte ipv6_mtuinfo; only
// the first 28 bytes (the Addr field) are populated.
info, err := unix.GetsockoptIPv6MTUInfo(int(fd), unix.IPPROTO_IPV6, unix.SO_ORIGINAL_DST)
if err != nil {
cerr = err
return
}
var buf = make([]byte, 2)
binary.BigEndian.PutUint16(buf, info.Addr.Port)
// info.Addr.Port is in network byte order (big-endian).
addr = &net.TCPAddr{
IP: net.IP(info.Addr.Addr[:]),
Port: int(binary.NativeEndian.Uint16(buf)),
Port: int(uint16(info.Addr.Port>>8 | info.Addr.Port<<8)),
}
}
})
+67 -1
View File
@@ -4,13 +4,16 @@ import (
"context"
"fmt"
"net"
"strconv"
"time"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/hosts"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/resolver"
"github.com/go-gost/relay"
ctxvalue "github.com/go-gost/x/ctx"
xnet "github.com/go-gost/x/internal/net"
@@ -261,8 +264,25 @@ func (h *relayHandler) bindUDP(ctx context.Context, conn net.Conn, network, addr
})
ro.SrcAddr = pc.LocalAddr().String()
// lc.ListenPacket returns a raw *net.UDPConn which can't resolve
// domainAddr returned by UDPTunServerConn.ReadFrom for domain targets.
// Wrap it so domains resolve through hostMapper → configured resolver
// → system DNS instead of failing WriteTo with an unknown addr type.
if _, ok := pc.(*net.UDPConn); ok {
var r resolver.Resolver
var hm hosts.HostMapper
if h.options.Router != nil {
r = h.options.Router.Options().Resolver
hm = h.options.Router.Options().HostMapper
}
pc = &resolvePacketConn{
PacketConn: pc,
resolver: r,
hostMapper: hm,
}
}
pc = metrics.WrapPacketConn(serviceName, pc)
// pc = admission.WrapPacketConn(l.options.Admission, pc)
// pc = limiter.WrapPacketConn(l.options.TrafficLimiter, pc)
defer pc.Close()
@@ -294,3 +314,49 @@ func (h *relayHandler) bindUDP(ctx context.Context, conn net.Conn, network, addr
}).Debugf("%s >-< %s", conn.RemoteAddr(), pc.LocalAddr())
return nil
}
// resolvePacketConn wraps a net.PacketConn and resolves domain addresses
// in WriteTo calls through hostMapper → configured resolver → system DNS.
//
// After udpTunConn.ReadFrom returns a domainAddr for ATYP=DOMAINNAME
// datagrams, a raw *net.UDPConn cannot consume it (WriteTo needs *net.UDPAddr).
// This wrapper resolves the domain before forwarding.
type resolvePacketConn struct {
net.PacketConn
resolver resolver.Resolver
hostMapper hosts.HostMapper
}
func (c *resolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
host, portStr, err := net.SplitHostPort(addr.String())
if err != nil {
return c.PacketConn.WriteTo(b, addr)
}
if net.ParseIP(host) != nil {
return c.PacketConn.WriteTo(b, addr)
}
var ips []net.IP
if c.hostMapper != nil {
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
}
if len(ips) == 0 && c.resolver != nil {
ips, _ = c.resolver.Resolve(context.Background(), "ip", host)
}
if len(ips) == 0 {
ips, _ = net.LookupIP(host)
}
if len(ips) == 0 {
return 0, fmt.Errorf("relay udp: cannot resolve %s", host)
}
ip := ips[0]
for _, candidate := range ips {
if candidate.To4() != nil {
ip = candidate
break
}
}
port, _ := strconv.Atoi(portStr)
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
}
+86
View File
@@ -2,10 +2,14 @@ package relay
import (
"context"
"errors"
"net"
"testing"
"time"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/hosts"
"github.com/go-gost/core/resolver"
"github.com/go-gost/relay"
)
@@ -107,4 +111,86 @@ func TestHandleBind_UDP(t *testing.T) {
if resp.Status != relay.StatusOK {
t.Errorf("Status = %d, want %d", resp.Status, relay.StatusOK)
}
}
// ---------------------------------------------------------------------------
// resolvePacketConn tests
// ---------------------------------------------------------------------------
type testPacketConn struct {
net.PacketConn
lastAddr net.Addr
lastData []byte
}
func (c *testPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
c.lastAddr = addr
c.lastData = make([]byte, len(b))
copy(c.lastData, b)
return len(b), nil
}
type stubResolver struct {
ips []net.IP
err error
}
func (r *stubResolver) Resolve(ctx context.Context, network, host string, opts ...resolver.Option) ([]net.IP, error) {
return r.ips, r.err
}
type stubHostMapper struct{}
func (m *stubHostMapper) Lookup(ctx context.Context, network, host string, opts ...hosts.Option) ([]net.IP, bool) {
return nil, false
}
type testDomainAddr struct{}
func (a *testDomainAddr) Network() string { return "udp" }
func (a *testDomainAddr) String() string { return "dns.google:53" }
func Test_resolvePacketConn_domain(t *testing.T) {
r := &resolvePacketConn{
PacketConn: &testPacketConn{},
resolver: &stubResolver{ips: []net.IP{net.ParseIP("1.2.3.4")}},
hostMapper: &stubHostMapper{},
}
_, err := r.WriteTo([]byte("data"), &testDomainAddr{})
if err != nil {
t.Fatal(err)
}
tpc := r.PacketConn.(*testPacketConn)
udpAddr, ok := tpc.lastAddr.(*net.UDPAddr)
if !ok {
t.Fatalf("expected *net.UDPAddr, got %T", tpc.lastAddr)
}
if !udpAddr.IP.Equal(net.ParseIP("1.2.3.4")) || udpAddr.Port != 53 {
t.Fatalf("expected 1.2.3.4:53, got %v", udpAddr)
}
}
func Test_resolvePacketConn_ipPassthrough(t *testing.T) {
r := &resolvePacketConn{
PacketConn: &testPacketConn{},
resolver: &stubResolver{err: errors.New("should not be called")},
hostMapper: &stubHostMapper{},
}
ipAddr := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 53}
_, err := r.WriteTo([]byte("data"), ipAddr)
if err != nil {
t.Fatal(err)
}
tpc := r.PacketConn.(*testPacketConn)
udpAddr, ok := tpc.lastAddr.(*net.UDPAddr)
if !ok {
t.Fatalf("expected *net.UDPAddr, got %T", tpc.lastAddr)
}
if !udpAddr.IP.Equal(net.ParseIP("8.8.8.8")) || udpAddr.Port != 53 {
t.Fatalf("expected 8.8.8.8:53, got %v", udpAddr)
}
}
+3 -1
View File
@@ -237,6 +237,7 @@ func (h *relayHandler) handleConnect(ctx context.Context, conn net.Conn, network
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -245,6 +246,7 @@ func (h *relayHandler) handleConnect(ctx context.Context, conn net.Conn, network
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -306,4 +308,4 @@ func (a *serialAddr) Network() string {
func (a *serialAddr) String() string {
return a.port
}
}
+79 -89
View File
@@ -1,11 +1,47 @@
// Package sni implements a TLS SNI-based forwarding handler for protocol-aware
// routing. It sniffs every inbound TCP connection, parses the TLS ClientHello
// (or HTTP request header) to extract the server name, and routes traffic
// through the proxy chain.
//
// # How it differs from the "tcp" / "forward" handler
//
// The SNI handler is purpose-built for TLS SNI routing — it always sniffs
// and only handles HTTP and TLS traffic:
//
// - Non-HTTP/non-TLS connections are silently dropped (no raw forwarding
// fallback). If you need raw TCP fallback, use the "tcp" or "forward"
// handler instead.
// - It does not implement [handler.Forwarder], so it has no hop-based node
// selection, load balancing, or per-node authentication/rewrite settings.
// All routing uses the SNI hostname as the destination address through
// the configured chain.
//
// # Connection processing flow
//
// Each inbound net.Conn is processed by Handle:
//
// Handle()
// ├─ newRecorderObject (session metadata: service, SID, addresses)
// ├─ checkRateLimit (connection rate limiter, if configured)
// ├─ Router nil check → errRouterNotAvailable
// ├─ sniffing.Sniff (peek buffer, detect protocol)
// │ └─ handleSniffedProtocol()
// │ ├─ sniffing.ProtoHTTP → SnifferBuilder.Build().HandleHTTP()
// │ ├─ sniffing.ProtoTLS → SnifferBuilder.Build().HandleTLS()
// │ └─ default → silent drop
// └─ unrecognised protocol → silently returns nil
//
// # SnifferBuilder pattern
//
// The sniffer configuration (MITM certs, WebSocket recording, bypass rules)
// is immutable after Init. A [SnifferBuilder] is populated once during Init
// and reused via [SnifferBuilder.Build] to create per-connection
// [sniffing.Sniffer] instances.
package sni
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"errors"
"net"
"time"
@@ -13,8 +49,6 @@ import (
md "github.com/go-gost/core/metadata"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/util/sniffing"
tls_util "github.com/go-gost/x/internal/util/tls"
@@ -29,26 +63,32 @@ func init() {
registry.HandlerRegistry().Register("sni", NewHandler)
}
// sniHandler is a protocol-aware forwarding handler that routes by TLS SNI
// or HTTP Host header. It always sniffs the initial bytes to determine the
// protocol and delegates to [sniffing.Sniffer] for HTTP/TLS handling.
type sniHandler struct {
md metadata
options handler.Options
recorder recorder.RecorderObject
certPool tls_util.CertPool
sniffer *SnifferBuilder
}
// NewHandler creates an SNI handler with the given options.
// The handler registers for the "sni" protocol.
func NewHandler(opts ...handler.Option) handler.Handler {
options := handler.Options{}
for _, opt := range opts {
opt(&options)
}
h := &sniHandler{
return &sniHandler{
options: options,
}
return h
}
// Init parses metadata and initialises the sniffer builder and TLS certificate
// pool. It implements [handler.Handler].
func (h *sniHandler) Init(md md.Metadata) (err error) {
if err = h.parseMetadata(md); err != nil {
return
@@ -65,26 +105,32 @@ func (h *sniHandler) Init(md md.Metadata) (err error) {
h.certPool = tls_util.NewMemoryCertPool()
}
h.sniffer = &SnifferBuilder{
Websocket: h.md.sniffingWebsocket,
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
Recorder: h.recorder.Recorder,
RecorderOptions: h.recorder.Options,
Certificate: h.md.certificate,
PrivateKey: h.md.privateKey,
ALPN: h.md.alpn,
CertPool: h.certPool,
MitmBypass: h.md.mitmBypass,
ReadTimeout: h.md.readTimeout,
}
return nil
}
// Handle processes an inbound TCP connection. It always sniffs the initial
// bytes to detect HTTP or TLS, then delegates to the protocol-specific
// sniffer. Non-HTTP/non-TLS traffic is silently dropped.
//
// Handle implements [handler.Handler].
func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
defer conn.Close()
start := time.Now()
ro := &xrecorder.HandlerRecorderObject{
Network: "tcp",
Service: h.options.Service,
RemoteAddr: conn.RemoteAddr().String(),
LocalAddr: conn.LocalAddr().String(),
SID: xctx.SidFromContext(ctx).String(),
Time: start,
}
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
ro.ClientAddr = srcAddr.String()
}
ro := h.newRecorderObject(ctx, conn, start)
log := h.options.Logger.WithFields(map[string]any{
"network": ro.Network,
@@ -120,10 +166,17 @@ func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
return rate_limiter.ErrRateLimit
}
if h.options.Router == nil {
err := errRouterNotAvailable
log.Error(err)
return err
}
if h.md.readTimeout > 0 {
conn.SetReadDeadline(time.Now().Add(h.md.readTimeout))
defer conn.SetReadDeadline(time.Time{})
}
br := bufio.NewReader(conn)
proto, sniffErr := sniffing.Sniff(ctx, br)
if sniffErr != nil {
@@ -131,75 +184,12 @@ func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
}
ro.Proto = proto
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
if h.options.Router == nil {
return nil, errors.New("sni: router not available")
}
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
ro.Route = buf.String()
if cc != nil {
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
}
return cc, err
}
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
cc, err := dial(ctx, network, address)
if err != nil {
return nil, err
}
cc = tls.Client(cc, cfg)
return cc, nil
}
sniffer := &sniffing.Sniffer{
Websocket: h.md.sniffingWebsocket,
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
Recorder: h.recorder.Recorder,
RecorderOptions: h.recorder.Options,
Certificate: h.md.certificate,
PrivateKey: h.md.privateKey,
NegotiatedProtocol: h.md.alpn,
CertPool: h.certPool,
MitmBypass: h.md.mitmBypass,
ReadTimeout: h.md.readTimeout,
}
conn = xnet.NewReadWriteConn(br, conn, conn)
switch proto {
case sniffing.ProtoHTTP:
return sniffer.HandleHTTP(ctx, "tcp", conn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
case sniffing.ProtoTLS:
return sniffer.HandleTLS(ctx, "tcp", conn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
default:
log.Debugf("unrecognized traffic from %s", conn.RemoteAddr())
return nil
}
}
func (h *sniHandler) checkRateLimit(addr net.Addr) bool {
if h.options.RateLimiter == nil {
return true
}
host, _, _ := net.SplitHostPort(addr.String())
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
return limiter.Allow(1)
handled, sniffErr := h.handleSniffedProtocol(ctx, conn, ro, log, proto)
if handled {
return sniffErr
}
return true
log.Debugf("unrecognized traffic from %s", conn.RemoteAddr())
return nil
}
+244
View File
@@ -0,0 +1,244 @@
package sni
import (
"context"
"errors"
"net"
"testing"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/limiter/rate"
"github.com/go-gost/core/recorder"
xmd "github.com/go-gost/x/metadata"
xrecorder "github.com/go-gost/x/recorder"
)
// ---------------------------------------------------------------------------
// NewHandler
// ---------------------------------------------------------------------------
func TestNewHandler(t *testing.T) {
h := NewHandler(func(o *handler.Options) {
o.Service = "test-svc"
})
if h == nil {
t.Fatal("expected non-nil handler")
}
sh, ok := h.(*sniHandler)
if !ok {
t.Fatal("expected *sniHandler")
}
if sh.options.Service != "test-svc" {
t.Errorf("expected Service 'test-svc', got %s", sh.options.Service)
}
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
func TestInit_Defaults(t *testing.T) {
h := newTestHandler()
err := h.Init(xmd.NewMetadata(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.readTimeout <= 0 {
t.Error("expected positive default readTimeout")
}
if h.sniffer == nil {
t.Error("expected sniffer to be initialised")
}
}
func TestInit_SelectsRecorder(t *testing.T) {
h := newTestHandler(withRecorder(recorder.RecorderObject{
Record: xrecorder.RecorderServiceHandler,
Recorder: &mockRecorder{},
}))
err := h.Init(xmd.NewMetadata(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.recorder.Recorder == nil {
t.Error("expected recorder to be set")
}
}
func TestInit_CertPool(t *testing.T) {
cert, key := generateTestCertKey(t)
h := newTestHandler()
h.md.certificate = cert
h.md.privateKey = key
err := h.Init(xmd.NewMetadata(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.certPool == nil {
t.Error("expected certPool to be created")
}
}
func TestInit_ParseMetadataError(t *testing.T) {
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"mitm.certFile": "/nonexistent/cert.pem",
"mitm.keyFile": "/nonexistent/key.pem",
})
err := h.Init(md)
if err == nil {
t.Fatal("expected error from Init with bad cert files")
}
}
// ---------------------------------------------------------------------------
// Handle — error paths
// ---------------------------------------------------------------------------
func TestHandle_NoRouter(t *testing.T) {
h := newTestHandler(withRouter(nil))
conn := newStringConn(nil)
err := h.Handle(context.Background(), conn)
if err == nil {
t.Fatal("expected error when router is nil")
}
if !errors.Is(err, errRouterNotAvailable) {
t.Errorf("expected errRouterNotAvailable, got %v", err)
}
}
func TestHandle_RateLimited(t *testing.T) {
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
limiterFn: func(key string) rate.Limiter {
return &stubLimiter{allowFn: func(n int) bool { return false }}
},
}))
conn := newStringConn(nil)
err := h.Handle(context.Background(), conn)
if err == nil {
t.Fatal("expected rate limit error")
}
}
// ---------------------------------------------------------------------------
// Handle — sniffing (protocol detection)
// ---------------------------------------------------------------------------
func TestHandle_SniffError(t *testing.T) {
// An empty connection causes sniffing.Sniff to return an error because
// there are no bytes to peek. The handler should log the error and fall
// through to the unrecognised path (silent drop).
h := newInitdHandler()
conn := newStringConn(nil)
err := h.Handle(context.Background(), conn)
if err != nil {
t.Fatalf("expected nil error for unrecognised traffic, got %v", err)
}
}
func TestHandle_UnrecognizedProto(t *testing.T) {
h := newInitdHandler()
// Non-HTTP/non-TLS bytes won't match any sniffer protocol.
conn := newStringConn([]byte("ssh-2.0-OpenSSH"))
err := h.Handle(context.Background(), conn)
if err != nil {
t.Fatalf("expected nil error for unrecognised traffic, got %v", err)
}
}
func TestHandle_HTTP(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
go func() {
buf := make([]byte, 4096)
n, err := client.Read(buf)
if err == nil && n > 0 {
client.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"))
}
}()
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return server, nil
},
}))
conn := newStringConn([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
err := h.Handle(context.Background(), conn)
if err != nil {
t.Logf("sniffed HTTP returned: %v", err)
}
}
func TestHandle_TLS(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return server, nil
},
}))
conn := newStringConn([]byte{
0x16, 0x03, 0x01, 0x00, 0x06, 0x01, 0x00, 0x00, 0x02, 0x03, 0x01,
})
err := h.Handle(context.Background(), conn)
if err == nil {
t.Log("sniffed TLS ClientHello without error")
}
}
func TestHandle_RecorderError(t *testing.T) {
// The deferred recorder error should not affect the return value.
h := newTestHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, nil
},
}))
h.recorder = recorder.RecorderObject{Recorder: &errorRecorder{}}
conn := newStringConn(nil)
err := h.Handle(context.Background(), conn)
if err != nil {
t.Fatalf("expected nil from unrecognised traffic, got %v", err)
}
}
func TestHandle_WithReadTimeout(t *testing.T) {
h := newTestHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
}))
h.md.readTimeout = 0 // zero disables the deadline
h.recorder = recorder.RecorderObject{Recorder: &mockRecorder{}}
conn := newStringConn([]byte("ssh"))
err := h.Handle(context.Background(), conn)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
func TestHandle_ConnClosed(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
err := h.Handle(context.Background(), conn)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if !conn.closed {
t.Error("expected connection to be closed after Handle returns")
}
}
+238
View File
@@ -0,0 +1,238 @@
package sni
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
"sync"
"testing"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/limiter/rate"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/recorder"
xlogger "github.com/go-gost/x/logger"
xmd "github.com/go-gost/x/metadata"
)
// ---------------------------------------------------------------------------
// Mock implementations
// ---------------------------------------------------------------------------
// mockRouter implements chain.Router for unit tests.
type mockRouter struct {
opts *chain.RouterOptions
dialFn func(ctx context.Context, network, address string) (net.Conn, error)
}
func (m *mockRouter) Options() *chain.RouterOptions { return m.opts }
func (m *mockRouter) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (net.Conn, error) {
if m.dialFn != nil {
return m.dialFn(ctx, network, address)
}
return nil, nil
}
func (m *mockRouter) Bind(ctx context.Context, network, address string, opts ...chain.BindOption) (net.Listener, error) {
return nil, nil
}
// stubRateLimiter implements rate.RateLimiter for tests.
type stubRateLimiter struct {
limiterFn func(key string) rate.Limiter
}
func (m *stubRateLimiter) Limiter(key string) rate.Limiter {
if m.limiterFn != nil {
return m.limiterFn(key)
}
return nil
}
// stubLimiter implements rate.Limiter for tests.
type stubLimiter struct {
allowFn func(n int) bool
}
func (m *stubLimiter) Allow(n int) bool {
if m.allowFn != nil {
return m.allowFn(n)
}
return true
}
func (m *stubLimiter) Limit() float64 { return 0 }
// mockRecorder implements recorder.Recorder for tests.
type mockRecorder struct{}
func (m *mockRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
return nil
}
func (m *mockRecorder) Close() error { return nil }
// errorRecorder always returns an error on Record for testing error paths.
type errorRecorder struct{}
func (e *errorRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
return errors.New("record error")
}
func (e *errorRecorder) Close() error { return nil }
// ---------------------------------------------------------------------------
// stringConn — a net.Conn backed by bytes.Buffer
// ---------------------------------------------------------------------------
// stringConn is a net.Conn backed by bytes.Buffer, useful for testing
// handlers without real network I/O.
type stringConn struct {
readBuf *bytes.Buffer
writeBuf *bytes.Buffer
local net.Addr
remote net.Addr
closed bool
mu sync.Mutex
}
func newStringConn(data []byte) *stringConn {
return &stringConn{
readBuf: bytes.NewBuffer(data),
writeBuf: new(bytes.Buffer),
local: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080},
remote: &net.TCPAddr{IP: net.IPv4(10, 0, 0, 1), Port: 12345},
}
}
func (c *stringConn) Read(b []byte) (int, error) { return c.readBuf.Read(b) }
func (c *stringConn) Write(b []byte) (int, error) { return c.writeBuf.Write(b) }
func (c *stringConn) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
return nil
}
func (c *stringConn) LocalAddr() net.Addr { return c.local }
func (c *stringConn) RemoteAddr() net.Addr { return c.remote }
func (c *stringConn) SetDeadline(t time.Time) error { return nil }
func (c *stringConn) SetReadDeadline(t time.Time) error { return nil }
func (c *stringConn) SetWriteDeadline(t time.Time) error { return nil }
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
func nopLog() logger.Logger { return xlogger.Nop() }
// newTestHandler creates an uninitialised sniHandler with defaults suitable
// for testing. The caller is responsible for calling Init if metadata parsing
// is needed.
func newTestHandler(opts ...handler.Option) *sniHandler {
options := handler.Options{
Logger: nopLog(),
Router: &mockRouter{opts: &chain.RouterOptions{}},
}
for _, opt := range opts {
opt(&options)
}
return &sniHandler{
options: options,
sniffer: &SnifferBuilder{},
}
}
// newInitdHandler creates a fully initialised sniHandler with default
// metadata. Use when the test does not depend on specific metadata values.
func newInitdHandler(opts ...handler.Option) *sniHandler {
h := newTestHandler(opts...)
_ = h.Init(xmd.NewMetadata(nil))
return h
}
// withRateLimiter is a helper to set the RateLimiter option on a handler.
func withRateLimiter(rl rate.RateLimiter) handler.Option {
return func(o *handler.Options) {
o.RateLimiter = rl
}
}
// withRouter is a helper to set the Router option on a handler.
func withRouter(r chain.Router) handler.Option {
return func(o *handler.Options) {
o.Router = r
}
}
// withRecorder is a helper to append a RecorderObject to the Recorders slice.
func withRecorder(ro recorder.RecorderObject) handler.Option {
return func(o *handler.Options) {
o.Recorders = append(o.Recorders, ro)
}
}
// ---------------------------------------------------------------------------
// Certificate helpers
// ---------------------------------------------------------------------------
// generateTestCertKey creates a self-signed test certificate and private key.
func generateTestCertKey(t *testing.T) (*x509.Certificate, crypto.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
return cert, key
}
// generateTestCertPEM creates PEM-encoded test certificate and private key.
func generateTestCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
cert, key := generateTestCertKey(t)
certPEM = pemEncode("CERTIFICATE", cert.Raw)
keyBytes := x509.MarshalPKCS1PrivateKey(key.(*rsa.PrivateKey))
keyPEM = pemEncode("RSA PRIVATE KEY", keyBytes)
return
}
func pemEncode(blockType string, der []byte) []byte {
block := &pem.Block{Type: blockType, Bytes: der}
return pem.EncodeToMemory(block)
}
func writeTempFile(t *testing.T, pattern string, data []byte) string {
t.Helper()
f, err := os.CreateTemp("", pattern)
if err != nil {
t.Fatalf("create temp file: %v", err)
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(f.Name())
t.Fatalf("write temp file: %v", err)
}
f.Close()
return f.Name()
}
+1 -1
View File
@@ -16,7 +16,7 @@ type metadata struct {
// readTimeout controls two behaviors:
// 1. A read deadline set on the client connection before sniffing the
// TLS ClientHello / HTTP request (see handler.go Handle).
// 2. Passed to SnifferBuilder as the timeout for reading upstream
// 2. Passed to [sniffing.Sniffer] as the timeout for reading upstream
// response headers during HTTP/TLS sniffing.
// 0 or negative defaults to 15s.
readTimeout time.Duration
+195
View File
@@ -0,0 +1,195 @@
package sni
import (
"os"
"testing"
"time"
xmd "github.com/go-gost/x/metadata"
)
// ---------------------------------------------------------------------------
// parseMetadata — defaults
// ---------------------------------------------------------------------------
func TestParseMetadata_Defaults(t *testing.T) {
h := newTestHandler()
err := h.parseMetadata(xmd.NewMetadata(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.readTimeout <= 0 {
t.Error("expected positive default readTimeout")
}
if h.md.sniffingWebsocket {
t.Error("expected sniffingWebsocket disabled by default")
}
}
// ---------------------------------------------------------------------------
// parseMetadata — custom values
// ---------------------------------------------------------------------------
func TestParseMetadata_CustomValues(t *testing.T) {
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"readTimeout": "60s",
"hash": "host",
"sniffing.websocket": true,
"sniffing.websocket.sampleRate": 0.75,
})
err := h.parseMetadata(md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.readTimeout != 60*time.Second {
t.Errorf("expected 60s, got %v", h.md.readTimeout)
}
if h.md.hash != "host" {
t.Errorf("expected hash 'host', got '%s'", h.md.hash)
}
if !h.md.sniffingWebsocket {
t.Error("expected sniffingWebsocket true")
}
if h.md.sniffingWebsocketSampleRate != 0.75 {
t.Errorf("expected 0.75, got %f", h.md.sniffingWebsocketSampleRate)
}
}
func TestParseMetadata_ReadTimeoutDefault(t *testing.T) {
h := newTestHandler()
// A non-positive value should fall back to the 15s default.
md := xmd.NewMetadata(map[string]any{
"readTimeout": "0s",
})
err := h.parseMetadata(md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.readTimeout != 15*time.Second {
t.Errorf("expected 15s default, got %v", h.md.readTimeout)
}
}
func TestParseMetadata_ReadTimeoutNegative(t *testing.T) {
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"readTimeout": "-5s",
})
err := h.parseMetadata(md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.readTimeout != 15*time.Second {
t.Errorf("expected 15s default for negative value, got %v", h.md.readTimeout)
}
}
// ---------------------------------------------------------------------------
// parseMetadata — MITM cert/key
// ---------------------------------------------------------------------------
func TestParseMetadata_MITMCertError(t *testing.T) {
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"mitm.certFile": "/nonexistent/cert.pem",
"mitm.keyFile": "/nonexistent/key.pem",
})
err := h.parseMetadata(md)
if err == nil {
t.Fatal("expected error for non-existent cert file")
}
}
func TestParseMetadata_MITMCertKeyMismatch(t *testing.T) {
// Only certFile set, not keyFile — keys should not be loaded.
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"mitm.certFile": "/some/cert.pem",
})
err := h.parseMetadata(md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.certificate != nil {
t.Error("expected certificate to remain nil when keyFile not set")
}
}
func TestParseMetadata_MITMCert(t *testing.T) {
certPEM, keyPEM := generateTestCertPEM(t)
certFile := writeTempFile(t, "cert-*.pem", certPEM)
defer os.Remove(certFile)
keyFile := writeTempFile(t, "key-*.pem", keyPEM)
defer os.Remove(keyFile)
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"mitm.certFile": certFile,
"mitm.keyFile": keyFile,
"mitm.alpn": "h2",
})
err := h.parseMetadata(md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.certificate == nil {
t.Error("expected certificate to be loaded")
}
if h.md.privateKey == nil {
t.Error("expected privateKey to be loaded")
}
if h.md.alpn != "h2" {
t.Errorf("expected alpn 'h2', got %s", h.md.alpn)
}
}
func TestParseMetadata_MITMCertCACertFile(t *testing.T) {
certPEM, keyPEM := generateTestCertPEM(t)
certFile := writeTempFile(t, "cert-*.pem", certPEM)
defer os.Remove(certFile)
keyFile := writeTempFile(t, "key-*.pem", keyPEM)
defer os.Remove(keyFile)
// Use the alternate "caCertFile"/"caKeyFile" key names.
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"mitm.caCertFile": certFile,
"mitm.caKeyFile": keyFile,
})
err := h.parseMetadata(md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.md.certificate == nil {
t.Error("expected certificate to be loaded via caCertFile")
}
if h.md.privateKey == nil {
t.Error("expected privateKey to be loaded via caKeyFile")
}
}
func TestParseMetadata_MITMBypass(t *testing.T) {
// Verify that mitm.bypass is parsed without error even when the named
// bypass does not exist. The exact value depends on the global registry
// state (other packages may have registered bypasses via init()).
h := newTestHandler()
md := xmd.NewMetadata(map[string]any{
"mitm.bypass": "nonexistent-1769845",
})
err := h.parseMetadata(md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
+126
View File
@@ -0,0 +1,126 @@
package sni
import (
"bytes"
"context"
"crypto"
"crypto/tls"
"crypto/x509"
"net"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/recorder"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
"github.com/go-gost/x/internal/util/sniffing"
tls_util "github.com/go-gost/x/internal/util/tls"
xrecorder "github.com/go-gost/x/recorder"
)
// SnifferBuilder holds configuration for creating per-connection protocol
// sniffers. It is populated once during Init and reused via [SnifferBuilder.Build]
// for each connection to avoid repeated allocation.
type SnifferBuilder struct {
Websocket bool
WebsocketSampleRate float64
Recorder recorder.Recorder
RecorderOptions *recorder.Options
Certificate *x509.Certificate
PrivateKey crypto.PrivateKey
ALPN string
CertPool tls_util.CertPool
MitmBypass bypass.Bypass
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to [sniffing.Sniffer].
// See sniffing.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration
}
// Build creates a new [sniffing.Sniffer] from the builder's configuration.
// Each call produces a fresh instance, safe for per-connection use.
func (b *SnifferBuilder) Build() *sniffing.Sniffer {
return &sniffing.Sniffer{
Websocket: b.Websocket,
WebsocketSampleRate: b.WebsocketSampleRate,
Recorder: b.Recorder,
RecorderOptions: b.RecorderOptions,
Certificate: b.Certificate,
PrivateKey: b.PrivateKey,
NegotiatedProtocol: b.ALPN,
CertPool: b.CertPool,
MitmBypass: b.MitmBypass,
ReadTimeout: b.ReadTimeout,
}
}
// sniffingDial creates a dial function for the sniffing branch that wraps
// [chain.Router.Dial] with context-based hash selection, route recording,
// and source/destination address population for the recorder.
func (h *sniHandler) sniffingDial(ctx context.Context, network, address string, ro *xrecorder.HandlerRecorderObject) (net.Conn, error) {
switch h.md.hash {
case "host":
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: address})
}
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
ro.Route = buf.String()
if cc != nil {
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
}
return cc, err
}
// sniffingDialTLS creates a TLS-aware dial function for the sniffing branch.
// It dials via [sniffingDial] and wraps the connection with [tls.Client].
func (h *sniHandler) sniffingDialTLS(ctx context.Context, network, address string, ro *xrecorder.HandlerRecorderObject, cfg *tls.Config) (net.Conn, error) {
cc, err := h.sniffingDial(ctx, network, address, ro)
if err != nil {
return nil, err
}
return tls.Client(cc, cfg), nil
}
// handleSniffedProtocol dispatches a sniffed connection to the
// protocol-specific sniffer ([sniffing.Sniffer.HandleHTTP] or
// [sniffing.Sniffer.HandleTLS]).
//
// It returns (true, err) when the protocol was handled and (false, nil)
// when the protocol is unrecognised and the caller should handle the
// connection directly (which for the SNI handler means silently dropping it).
func (h *sniHandler) handleSniffedProtocol(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, proto string) (handled bool, err error) {
switch proto {
case sniffing.ProtoHTTP, sniffing.ProtoTLS:
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
return h.sniffingDial(ctx, network, address, ro)
}
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
return h.sniffingDialTLS(ctx, network, address, ro, cfg)
}
sniffer := h.sniffer.Build()
if proto == sniffing.ProtoHTTP {
return true, sniffer.HandleHTTP(ctx, "tcp", conn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
}
return true, sniffer.HandleTLS(ctx, "tcp", conn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
default:
return false, nil
}
}
+249
View File
@@ -0,0 +1,249 @@
package sni
import (
"context"
"errors"
"net"
"testing"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/recorder"
"github.com/go-gost/x/internal/util/sniffing"
xrecorder "github.com/go-gost/x/recorder"
)
// ---------------------------------------------------------------------------
// sniffingDial
// ---------------------------------------------------------------------------
func TestSniffingDial_Success(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return server, nil
},
}))
ro := &xrecorder.HandlerRecorderObject{}
cc, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer cc.Close()
if ro.Route == "" {
t.Log("route is empty (mock does not populate context buffer)")
}
if ro.SrcAddr != "" {
t.Log("src addr is set")
}
if ro.DstAddr != "" {
t.Log("dst addr is set")
}
}
func TestSniffingDial_RouterError(t *testing.T) {
expectedErr := errors.New("dial failed")
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, expectedErr
},
}))
ro := &xrecorder.HandlerRecorderObject{}
_, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, expectedErr) {
t.Errorf("expected %v, got %v", expectedErr, err)
}
}
func TestSniffingDial_HashHost(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
h := newTestHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return server, nil
},
}))
h.md.hash = "host"
h.recorder = recorder.RecorderObject{Recorder: &mockRecorder{}}
ro := &xrecorder.HandlerRecorderObject{}
cc, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer cc.Close()
}
// ---------------------------------------------------------------------------
// sniffingDialTLS
// ---------------------------------------------------------------------------
func TestSniffingDialTLS_Success(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return server, nil
},
}))
ro := &xrecorder.HandlerRecorderObject{}
// sniffingDialTLS wraps the raw connection in tls.Client,
// so the TLS handshake will likely fail against a raw net.Pipe,
// but that's expected — we're testing the plumbing, not the TLS handshake.
cc, err := h.sniffingDialTLS(context.Background(), "tcp", "example.com:443", ro, nil)
if err == nil {
defer cc.Close()
}
// Even with a nil tls.Config, tls.Client still attempts a handshake
// which will fail against the pipe. We accept any outcome here
// as long as sniffingDial was invoked correctly.
}
func TestSniffingDialTLS_DialError(t *testing.T) {
expectedErr := errors.New("dial failed")
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, expectedErr
},
}))
ro := &xrecorder.HandlerRecorderObject{}
_, err := h.sniffingDialTLS(context.Background(), "tcp", "example.com:443", ro, nil)
if err == nil {
t.Fatal("expected error from failed dial")
}
if !errors.Is(err, expectedErr) {
t.Errorf("expected %v, got %v", expectedErr, err)
}
}
// ---------------------------------------------------------------------------
// SnifferBuilder
// ---------------------------------------------------------------------------
func TestSnifferBuilder(t *testing.T) {
h := newTestHandler()
h.sniffer.Websocket = true
h.sniffer.WebsocketSampleRate = 0.5
h.sniffer.ReadTimeout = 30 * time.Second
sniffer := h.sniffer.Build()
if !sniffer.Websocket {
t.Error("expected Websocket to be true")
}
if sniffer.WebsocketSampleRate != 0.5 {
t.Errorf("expected 0.5, got %f", sniffer.WebsocketSampleRate)
}
if sniffer.ReadTimeout != 30*time.Second {
t.Errorf("expected 30s, got %v", sniffer.ReadTimeout)
}
}
func TestSnifferBuilder_ZeroValue(t *testing.T) {
h := &sniHandler{sniffer: &SnifferBuilder{}}
sniffer := h.sniffer.Build()
if sniffer == nil {
t.Fatal("expected non-nil sniffer")
}
if sniffer.Websocket {
t.Error("expected Websocket false by default")
}
if sniffer.WebsocketSampleRate != 0 {
t.Errorf("expected 0, got %f", sniffer.WebsocketSampleRate)
}
}
// ---------------------------------------------------------------------------
// handleSniffedProtocol
// ---------------------------------------------------------------------------
func TestHandleSniffedProtocol_UnknownProto(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
ro := &xrecorder.HandlerRecorderObject{}
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), "ssh")
if handled {
t.Error("expected handled=false for unknown proto")
}
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
func TestHandleSniffedProtocol_EmptyProto(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
ro := &xrecorder.HandlerRecorderObject{}
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), "")
if handled {
t.Error("expected handled=false for empty proto")
}
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
func TestHandleSniffedProtocol_HTTP(t *testing.T) {
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, errors.New("dial not configured in test")
},
}))
conn := newStringConn([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
ro := &xrecorder.HandlerRecorderObject{}
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), sniffing.ProtoHTTP)
if !handled {
t.Error("expected handled=true for HTTP proto")
}
if err == nil {
t.Log("sniffer processed HTTP request without error")
}
}
func TestHandleSniffedProtocol_TLS(t *testing.T) {
h := newInitdHandler(withRouter(&mockRouter{
opts: &chain.RouterOptions{},
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, errors.New("dial not configured in test")
},
}))
conn := newStringConn([]byte{
0x16, 0x03, 0x01, 0x00, 0x06, 0x01, 0x00, 0x00, 0x02, 0x03, 0x01,
})
ro := &xrecorder.HandlerRecorderObject{}
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), sniffing.ProtoTLS)
if !handled {
t.Error("expected handled=true for TLS proto")
}
if err == nil {
t.Log("sniffer processed TLS ClientHello without error")
}
}
+49
View File
@@ -0,0 +1,49 @@
package sni
import (
"context"
"errors"
"net"
"time"
xctx "github.com/go-gost/x/ctx"
xrecorder "github.com/go-gost/x/recorder"
)
var (
errRouterNotAvailable = errors.New("sni: router not available")
)
// newRecorderObject creates a HandlerRecorderObject populated with connection
// metadata (service, addresses, network type, session ID, client address).
func (h *sniHandler) newRecorderObject(ctx context.Context, conn net.Conn, start time.Time) *xrecorder.HandlerRecorderObject {
ro := &xrecorder.HandlerRecorderObject{
Service: h.options.Service,
RemoteAddr: conn.RemoteAddr().String(),
LocalAddr: conn.LocalAddr().String(),
Network: "tcp",
Time: start,
SID: xctx.SidFromContext(ctx).String(),
}
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
ro.ClientAddr = srcAddr.String()
}
return ro
}
// checkRateLimit verifies that the remote address has not exceeded the
// configured connection rate limit. Returns true if allowed, or if no rate
// limiter is configured.
func (h *sniHandler) checkRateLimit(addr net.Addr) bool {
if h.options.RateLimiter == nil {
return true
}
if addr == nil {
return true
}
host, _, _ := net.SplitHostPort(addr.String())
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
return limiter.Allow(1)
}
return true
}
+148
View File
@@ -0,0 +1,148 @@
package sni
import (
"context"
"net"
"testing"
"time"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/limiter/rate"
xctx "github.com/go-gost/x/ctx"
)
// ---------------------------------------------------------------------------
// newRecorderObject
// ---------------------------------------------------------------------------
func TestNewRecorderObject_TCP(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
start := time.Now()
ro := h.newRecorderObject(context.Background(), conn, start)
if ro.Network != "tcp" {
t.Errorf("expected network tcp, got %s", ro.Network)
}
if ro.RemoteAddr != "10.0.0.1:12345" {
t.Errorf("expected remote 10.0.0.1:12345, got %s", ro.RemoteAddr)
}
if ro.LocalAddr != "127.0.0.1:8080" {
t.Errorf("expected local 127.0.0.1:8080, got %s", ro.LocalAddr)
}
if !ro.Time.Equal(start) {
t.Errorf("expected time %v, got %v", start, ro.Time)
}
}
func TestNewRecorderObject_SID(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
ctx := xctx.ContextWithSid(context.Background(), xctx.Sid("test-sid"))
ro := h.newRecorderObject(ctx, conn, time.Now())
if ro.SID != "test-sid" {
t.Errorf("expected SID 'test-sid', got '%s'", ro.SID)
}
}
func TestNewRecorderObject_NilSrcAddr(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
ro := h.newRecorderObject(context.Background(), conn, time.Now())
if ro.ClientAddr != "" {
t.Errorf("expected empty ClientAddr, got %s", ro.ClientAddr)
}
}
func TestNewRecorderObject_SrcAddr(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
srcAddr := &net.TCPAddr{IP: net.IPv4(192, 168, 1, 1), Port: 54321}
ctx := xctx.ContextWithSrcAddr(context.Background(), srcAddr)
ro := h.newRecorderObject(ctx, conn, time.Now())
if ro.ClientAddr != "192.168.1.1:54321" {
t.Errorf("expected ClientAddr '192.168.1.1:54321', got '%s'", ro.ClientAddr)
}
}
func TestNewRecorderObject_Service(t *testing.T) {
h := newTestHandler(func(o *handler.Options) {
o.Service = "my-service"
})
conn := newStringConn(nil)
ro := h.newRecorderObject(context.Background(), conn, time.Now())
if ro.Service != "my-service" {
t.Errorf("expected Service 'my-service', got '%s'", ro.Service)
}
}
// ---------------------------------------------------------------------------
// checkRateLimit
// ---------------------------------------------------------------------------
func TestCheckRateLimit_NilLimiter(t *testing.T) {
h := newInitdHandler()
conn := newStringConn(nil)
if !h.checkRateLimit(conn.RemoteAddr()) {
t.Error("expected true when RateLimiter is nil")
}
}
func TestCheckRateLimit_NilAddr(t *testing.T) {
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
limiterFn: func(key string) rate.Limiter {
return &stubLimiter{allowFn: func(n int) bool { return false }}
},
}))
if !h.checkRateLimit(nil) {
t.Error("expected true when addr is nil")
}
}
func TestCheckRateLimit_Allowed(t *testing.T) {
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
limiterFn: func(key string) rate.Limiter {
return &stubLimiter{allowFn: func(n int) bool { return true }}
},
}))
conn := newStringConn(nil)
if !h.checkRateLimit(conn.RemoteAddr()) {
t.Error("expected true when limiter allows")
}
}
func TestCheckRateLimit_Blocked(t *testing.T) {
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
limiterFn: func(key string) rate.Limiter {
return &stubLimiter{allowFn: func(n int) bool { return false }}
},
}))
conn := newStringConn(nil)
if h.checkRateLimit(conn.RemoteAddr()) {
t.Error("expected false when limiter blocks")
}
}
func TestCheckRateLimit_NoLimiterForKey(t *testing.T) {
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
limiterFn: func(key string) rate.Limiter { return nil },
}))
conn := newStringConn(nil)
if !h.checkRateLimit(conn.RemoteAddr()) {
t.Error("expected true when no limiter found for key")
}
}
+2
View File
@@ -310,6 +310,7 @@ func (h *socks4Handler) handleConnect(ctx context.Context, conn net.Conn, req *g
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -318,6 +319,7 @@ func (h *socks4Handler) handleConnect(ctx context.Context, conn net.Conn, req *g
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
+6 -1
View File
@@ -82,7 +82,10 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
resp := gosocks5.NewReply(gosocks5.Succeeded, nil)
// Report the actual bound address (BND.ADDR) per RFC 1928 §6.
bndAddr := gosocks5.Addr{}
bndAddr.ParseFrom(cc.LocalAddr().String())
resp := gosocks5.NewReply(gosocks5.Succeeded, &bndAddr)
log.Trace(resp)
if err := resp.Write(conn); err != nil {
log.Error(err)
@@ -128,6 +131,7 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -136,6 +140,7 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
+23 -3
View File
@@ -100,6 +100,26 @@ func (h *socks5Handler) Init(md md.Metadata) (err error) {
return
}
// networkAddr derives the network string from the SOCKS5 address type.
// For IPv4 addresses it returns "tcp4"/"udp4" to prevent dual-stack
// (IPv4-mapped-IPv6) socket creation on Linux, which would cause the
// listener to bind on [::]:port instead of 0.0.0.0:port.
// For IPv6 addresses it returns "tcp6"/"udp6".
// For domain addresses it returns the unmodified network.
func networkAddr(network string, addr *gosocks5.Addr) string {
if addr == nil {
return network
}
switch addr.Type {
case gosocks5.AddrIPv4:
return network + "4"
case gosocks5.AddrIPv6:
return network + "6"
default:
return network
}
}
func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
defer conn.Close()
@@ -179,15 +199,15 @@ func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handl
case gosocks5.CmdConnect:
return h.handleConnect(ctx, conn, "tcp", address, ro, log)
case gosocks5.CmdBind:
return h.handleBind(ctx, conn, "tcp", address, ro, log)
return h.handleBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
case socks.CmdMuxBind:
return h.handleMuxBind(ctx, conn, "tcp", address, ro, log)
return h.handleMuxBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
case gosocks5.CmdUdp:
ro.Network = "udp"
return h.handleUDP(ctx, conn, "udp", ro, log)
case socks.CmdUDPTun:
ro.Network = "udp"
return h.handleUDPTun(ctx, conn, "udp", address, ro, log)
return h.handleUDPTun(ctx, conn, networkAddr("udp", req.Addr), address, ro, log)
default:
err = ErrUnknownCmd
log.Error(err)
+6
View File
@@ -26,6 +26,9 @@ type metadata struct {
enableBind bool
enableUDP bool
udpBufferSize int
udpResolveDomain bool
udpBindMin int
udpBindMax int
compatibilityMode bool
hash string
muxCfg *mux.Config
@@ -58,6 +61,9 @@ func (h *socks5Handler) parseMetadata(md mdata.Metadata) (err error) {
h.md.enableBind = mdutil.GetBool(md, "bind")
h.md.enableUDP = mdutil.GetBool(md, "udp")
h.md.udpBufferSize = mdutil.GetInt(md, "udp.bufferSize", "udpBufferSize")
h.md.udpResolveDomain = mdutil.GetBool(md, "udp.resolveDomain", "udpResolveDomain")
h.md.udpBindMin = mdutil.GetInt(md, "udp.bindRange.min", "udp.minPort")
h.md.udpBindMax = mdutil.GetInt(md, "udp.bindRange.max", "udp.maxPort")
h.md.compatibilityMode = mdutil.GetBool(md, "comp")
h.md.hash = mdutil.GetString(md, "hash")
+68
View File
@@ -0,0 +1,68 @@
package v5
import (
"testing"
"github.com/go-gost/gosocks5"
)
func TestNetworkAddr(t *testing.T) {
tests := []struct {
name string
network string
addr *gosocks5.Addr
want string
}{
{
name: "nil addr returns unmodified",
network: "tcp",
addr: nil,
want: "tcp",
},
{
name: "IPv4 tcp returns tcp4",
network: "tcp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
want: "tcp4",
},
{
name: "IPv6 tcp returns tcp6",
network: "tcp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
want: "tcp6",
},
{
name: "IPv4 udp returns udp4",
network: "udp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
want: "udp4",
},
{
name: "IPv6 udp returns udp6",
network: "udp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
want: "udp6",
},
{
name: "domain addr returns unmodified",
network: "tcp",
addr: &gosocks5.Addr{Type: gosocks5.AddrDomain},
want: "tcp",
},
{
name: "unknown addr type returns unmodified",
network: "udp",
addr: &gosocks5.Addr{Type: 99},
want: "udp",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := networkAddr(tt.network, tt.addr)
if got != tt.want {
t.Errorf("networkAddr(%q, %v) = %q, want %q", tt.network, tt.addr, got, tt.want)
}
})
}
}
+27 -10
View File
@@ -27,25 +27,42 @@ func (selector *serverSelector) Methods() []uint8 {
func (s *serverSelector) Select(methods ...uint8) (method uint8) {
s.logger.Debugf("%d %d %v", gosocks5.Ver5, len(methods), methods)
method = gosocks5.MethodNoAuth
// Determine which methods the client offered.
var hasNoAuth, hasUserPass, hasTLS, hasTLSAuth bool
for _, m := range methods {
if m == socks.MethodTLS && !s.noTLS && s.TLSConfig != nil {
method = m
break
switch m {
case gosocks5.MethodNoAuth:
hasNoAuth = true
case gosocks5.MethodUserPass:
hasUserPass = true
case socks.MethodTLS:
hasTLS = true
case socks.MethodTLSAuth:
hasTLSAuth = true
}
}
// when Authenticator is set, auth is mandatory
// When Authenticator is set, authentication is mandatory.
if s.Authenticator != nil {
if method == gosocks5.MethodNoAuth {
method = gosocks5.MethodUserPass
if hasTLSAuth && !s.noTLS && s.TLSConfig != nil {
return socks.MethodTLSAuth
}
if method == socks.MethodTLS && !s.noTLS {
method = socks.MethodTLSAuth
if hasUserPass {
return gosocks5.MethodUserPass
}
return gosocks5.MethodNoAcceptable
}
return
// No authenticator — select the best unauthenticated method.
if hasTLS && !s.noTLS && s.TLSConfig != nil {
return socks.MethodTLS
}
if hasNoAuth {
return gosocks5.MethodNoAuth
}
return gosocks5.MethodNoAcceptable
}
func (s *serverSelector) OnSelected(method uint8, conn net.Conn) (string, net.Conn, error) {
+213 -22
View File
@@ -4,13 +4,19 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math/rand/v2"
"net"
"strconv"
"syscall"
"time"
"github.com/go-gost/core/hosts"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/resolver"
"github.com/go-gost/gosocks5"
ctxvalue "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
@@ -49,7 +55,7 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
Netns: h.options.Netns,
}
cc, err := lc.ListenPacket(ctx, network, net.JoinHostPort(host, "0"))
cc, err := listenPacketInRange(ctx, &lc, network, host, h.md.udpBindMin, h.md.udpBindMax)
if err != nil {
log.Error(err)
reply := gosocks5.NewReply(gosocks5.Failure, nil)
@@ -65,6 +71,45 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
})
ro.SrcAddr = cc.LocalAddr().String()
// Verify the upstream chain is reachable before replying Success,
// per RFC 1928 §7.
var buf bytes.Buffer
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), network, "") // UDP association
ro.Route = buf.String()
if err != nil {
log.Error(err)
reply := gosocks5.NewReply(gosocks5.Failure, nil)
log.Trace(reply)
reply.Write(conn)
return err
}
defer c.Close()
pc, ok := c.(net.PacketConn)
if !ok {
err := errors.New("socks5: wrong connection type")
log.Error(err)
reply := gosocks5.NewReply(gosocks5.Failure, nil)
log.Trace(reply)
reply.Write(conn)
return err
}
// A direct (no-chain) UDP association yields a raw *net.UDPConn, which
// cannot consume the domainAddr returned by udpConn.ReadFrom for
// ATYP=DOMAINNAME datagrams. Wrap it so domains are resolved through the
// configured resolver (hostMapper → resolver → system DNS) instead of the
// previous hardcoded net.ResolveUDPAddr in the SOCKS5 decode path.
// Chain-backed PacketConns (udpTunConn, udpRelayConn, ...) encode domains
// as ATYP=Domain themselves and are left untouched.
if _, isDirect := pc.(*net.UDPConn); isDirect {
pc = &resolvePacketConn{
PacketConn: pc,
resolver: h.options.Router.Options().Resolver,
hostMapper: h.options.Router.Options().HostMapper,
}
}
saddr := gosocks5.Addr{}
saddr.ParseFrom(cc.LocalAddr().String())
@@ -81,23 +126,6 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
}
log.Debugf("bind on %s OK", cc.LocalAddr())
// obtain a udp connection
var buf bytes.Buffer
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), network, "") // UDP association
ro.Route = buf.String()
if err != nil {
log.Error(err)
return err
}
defer c.Close()
pc, ok := c.(net.PacketConn)
if !ok {
err := errors.New("socks5: wrong connection type")
log.Error(err)
return err
}
pc = metrics.WrapPacketConn(ro.Service, pc)
{
@@ -129,12 +157,29 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
}
}
r := udp.NewRelay(socks.UDPConn(
tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
if !ok {
err := fmt.Errorf("socks5 udp: unexpected remote address type %T", conn.RemoteAddr())
log.Error(err)
return err
}
pc1 := socks.UDPConn(
&filteredPacketConn{
PacketConn: cc,
filterIP: conn.RemoteAddr().(*net.TCPAddr).IP,
filterIP: tcpAddr.IP,
},
h.md.udpBufferSize), pc).
h.md.udpBufferSize)
if h.md.udpResolveDomain {
pc1 = &domainResolvePacketConn{
PacketConn: pc1,
resolver: h.options.Router.Options().Resolver,
hostMapper: h.options.Router.Options().HostMapper,
log: log,
}
}
r := udp.NewRelay(pc1, pc).
WithService(h.options.Service).
WithBypass(h.options.Bypass).
WithBufferSize(h.md.udpBufferSize).
@@ -151,6 +196,37 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
return nil
}
// listenPacketInRange binds the client-facing UDP relay socket. When lo..hi is
// a valid range (lo > 0, hi >= lo) it scans ports from a random offset within
// [lo, hi] and retries on EADDRINUSE, so a NAT router can forward a small,
// fixed port set to the SOCKS server. The random offset avoids every
// association hammering port lo under concurrency; the rotation visits distinct
// ports, so a free port is always found when the range fits the attempt cap.
// RFC 1928 leaves relay-port selection to the server; the caller still reports
// the actually-bound port via BND.PORT. With no range, the OS chooses (port 0).
// A non-EADDRINUSE error fails fast.
func listenPacketInRange(ctx context.Context, lc *xnet.ListenConfig, network, host string, lo, hi int) (net.PacketConn, error) {
if lo > 0 && hi >= lo {
span := hi - lo + 1
attempts := min(span, 64)
offset := rand.IntN(span)
var lastErr error
for i := range attempts {
port := lo + (offset+i)%span
cc, err := lc.ListenPacket(ctx, network, net.JoinHostPort(host, strconv.Itoa(port)))
if err == nil {
return cc, nil
}
lastErr = err
if !errors.Is(err, syscall.EADDRINUSE) {
return nil, err
}
}
return nil, lastErr
}
return lc.ListenPacket(ctx, network, net.JoinHostPort(host, "0"))
}
// filteredPacketConn implements SOCKS5 RFC 1928 UDP relay security by filtering
// incoming packets to only accept those from the client IP that established the TCP control connection.
type filteredPacketConn struct {
@@ -165,8 +241,123 @@ func (f *filteredPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error
return
}
if udpAddr := addr.(*net.UDPAddr); udpAddr.IP.Equal(f.filterIP) {
udpAddr, ok := addr.(*net.UDPAddr)
if !ok {
continue
}
if udpAddr.IP.Equal(f.filterIP) {
return
}
}
}
// resolvePacketConn wraps the upstream net.PacketConn for a direct (no-chain)
// UDP association and resolves domain addresses to IPs in WriteTo calls.
//
// udpConn.ReadFrom now returns a domainAddr for ATYP=DOMAINNAME datagrams so
// the domain survives to the chain; a raw *net.UDPConn cannot consume a
// domainAddr (WriteTo needs *net.UDPAddr), so direct connections are wrapped
// here. Resolution order is hostMapper → configured resolver → system DNS, so a
// configured resolver (e.g. resolver=1.1.1.1) is honored instead of leaking
// through the system resolver.
type resolvePacketConn struct {
net.PacketConn
resolver resolver.Resolver
hostMapper hosts.HostMapper
}
func (c *resolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
host, portStr, err := net.SplitHostPort(addr.String())
if err != nil {
return c.PacketConn.WriteTo(b, addr)
}
if net.ParseIP(host) != nil {
return c.PacketConn.WriteTo(b, addr)
}
var ips []net.IP
if c.hostMapper != nil {
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
}
if len(ips) == 0 && c.resolver != nil {
ips, _ = c.resolver.Resolve(context.Background(), "ip", host)
}
if len(ips) == 0 {
ips, _ = net.LookupIP(host)
}
if len(ips) == 0 {
return 0, fmt.Errorf("socks5 udp: cannot resolve %s", host)
}
ip := ips[0]
for _, candidate := range ips {
if candidate.To4() != nil {
ip = candidate
break
}
}
port, _ := strconv.Atoi(portStr)
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
}
// domainResolvePacketConn wraps a net.PacketConn and resolves domain
// addresses to IPs in WriteTo calls. This guarantees that SOCKS5 UDP
// response datagrams never carry ATYP=Domain (0x03), which some clients
// (e.g., tun2proxy, Surge) cannot parse.
type domainResolvePacketConn struct {
net.PacketConn
resolver resolver.Resolver
hostMapper hosts.HostMapper
log logger.Logger
}
func (c *domainResolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
host, portStr, err := net.SplitHostPort(addr.String())
if err != nil {
return c.PacketConn.WriteTo(b, addr)
}
if net.ParseIP(host) != nil {
// Already an IP address — pass through unchanged.
return c.PacketConn.WriteTo(b, addr)
}
// Resolve the domain using the router's infrastructure
// (host mapper → resolver → system fallback) so the SOCKS5 encoder
// will use AddrIPv4 or AddrIPv6 instead of AddrDomain.
var ips []net.IP
if c.hostMapper != nil {
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
}
if len(ips) == 0 && c.resolver != nil {
ips, err = c.resolver.Resolve(context.Background(), "ip", host)
if err != nil && c.log != nil {
c.log.Warnf("socks5 udp: resolve %s: %v", host, err)
}
}
if len(ips) == 0 {
ips, err = net.LookupIP(host)
}
if err != nil || len(ips) == 0 {
// Cannot resolve the domain — drop the datagram rather than
// forwarding an ATYP=Domain response the client cannot parse.
if c.log != nil {
c.log.Warnf("socks5 udp: dropping datagram to %s (DNS resolution failed)", host)
}
if err == nil {
err = fmt.Errorf("socks5 udp: DNS resolution failed for %s", host)
}
return 0, err
}
// Pick the first IPv4 address from the result set, falling back to
// the first entry when no IPv4 is available.
ip := ips[0]
for _, candidate := range ips {
if candidate.To4() != nil {
ip = candidate
break
}
}
port, _ := strconv.Atoi(portStr)
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
}
+55
View File
@@ -0,0 +1,55 @@
package v5
import (
"context"
"net"
"strconv"
"testing"
xnet "github.com/go-gost/x/internal/net"
)
func TestListenPacketInRange(t *testing.T) {
ctx := context.Background()
// No range configured: OS picks (port 0), always succeeds.
lc := &xnet.ListenConfig{}
cc, err := listenPacketInRange(ctx, lc, "udp", "127.0.0.1", 0, 0)
if err != nil {
t.Fatalf("port-0 bind: %v", err)
}
cc.Close()
// Range configured: bound port must fall within [lo, hi]. With 51
// candidates and attempts = min(span, 64), only a fully-occupied range
// fails — extremely unlikely on a dev/CI host.
const lo, hi = 35000, 35050
cc, err = listenPacketInRange(ctx, lc, "udp", "127.0.0.1", lo, hi)
if err != nil {
t.Fatalf("range bind [%d,%d]: %v", lo, hi, err)
}
defer cc.Close()
_, portStr, _ := net.SplitHostPort(cc.LocalAddr().String())
port, _ := strconv.Atoi(portStr)
if port < lo || port > hi {
t.Errorf("bound port %d outside [%d, %d]", port, lo, hi)
}
}
// A single-port range whose only port is already held must surface the
// EADDRINUSE error (so the caller replies REP 0x01) — no hang, no silent miss.
func TestListenPacketInRange_BusyPort(t *testing.T) {
blocker, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("blocker bind: %v", err)
}
defer blocker.Close()
_, portStr, _ := net.SplitHostPort(blocker.LocalAddr().String())
port, _ := strconv.Atoi(portStr)
lc := &xnet.ListenConfig{}
if _, err := listenPacketInRange(context.Background(), lc, "udp", "127.0.0.1", port, port); err == nil {
t.Fatalf("expected bind on held port %d to fail", port)
}
}
+11
View File
@@ -109,6 +109,17 @@ func (h *socks5Handler) handleUDPTun(ctx context.Context, conn net.Conn, network
}
defer pc.Close()
// If the upstream is a raw *net.UDPConn (direct dial or BIND mode),
// wrap it so domainAddr from socks.UDPTunServerConn.ReadFrom are
// resolved through the configured resolver instead of failing WriteTo.
if _, ok := pc.(*net.UDPConn); ok {
pc = &resolvePacketConn{
PacketConn: pc,
resolver: h.options.Router.Options().Resolver,
hostMapper: h.options.Router.Options().HostMapper,
}
}
{
pStats := xstats.Stats{}
pc = stats_wrapper.WrapPacketConn(pc, &pStats)
+2
View File
@@ -237,6 +237,7 @@ func (h *ssHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.H
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -245,6 +246,7 @@ func (h *ssHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.H
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
+2
View File
@@ -209,6 +209,7 @@ func (h *forwardHandler) handleDirectForward(ctx context.Context, targetAddr str
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -217,6 +218,7 @@ func (h *forwardHandler) handleDirectForward(ctx context.Context, targetAddr str
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
+21 -13
View File
@@ -45,10 +45,10 @@ func (h *tunHandler) handleClient(ctx context.Context, conn net.Conn, network st
defer cc.Close()
if network == "udp" {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
iterCtx, iterCancel := context.WithCancel(ctx)
defer iterCancel()
go h.keepalive(ctx, cc, ips)
go h.keepalive(iterCtx, cc, ips)
}
return h.transportClient(ctx, conn, cc, log)
@@ -100,11 +100,21 @@ func (h *tunHandler) keepalive(ctx context.Context, conn net.Conn, ips []net.IP)
}
func (h *tunHandler) transportClient(ctx context.Context, tun io.ReadWriter, conn net.Conn, log logger.Logger) error {
c, cancel := context.WithCancel(ctx)
defer cancel()
errc := make(chan error, 2)
go func() {
var b [MaxMessageSize]byte
for {
select {
case <-c.Done():
errc <- c.Err()
return
default:
}
err := func() error {
n, err := tun.Read(b[:])
if err != nil {
@@ -155,6 +165,13 @@ func (h *tunHandler) transportClient(ctx context.Context, tun io.ReadWriter, con
go func() {
var b [MaxMessageSize]byte
for {
select {
case <-c.Done():
errc <- c.Err()
return
default:
}
err := func() error {
n, err := conn.Read(b[:])
if err != nil {
@@ -214,14 +231,5 @@ func (h *tunHandler) transportClient(ctx context.Context, tun io.ReadWriter, con
}
}()
select {
case err := <-errc:
if err != nil && err == io.EOF {
err = nil
}
return err
case <-ctx.Done():
return ctx.Err()
}
return collectFirstError(errc, cancel)
}
+27
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
@@ -111,6 +112,32 @@ func (h *tunHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
return h.handleServer(ctx, conn, config, log)
}
// collectFirstError drains errc (whose capacity must equal the number of
// goroutines writing to it) and returns the first error that is not io.EOF,
// context.Canceled, or context.DeadlineExceeded. It cancels the derived
// context after the first error to signal sibling goroutines to exit.
// A timeout on the second read prevents deadlock when a goroutine is stuck
// in a blocking read that is not context-aware.
func collectFirstError(errc <-chan error, cancel context.CancelFunc) error {
var firstErr error
// Wait for the first goroutine to exit.
err := <-errc
cancel() // signal the sibling goroutine to exit
if err != nil && err != io.EOF && err != context.Canceled && err != context.DeadlineExceeded {
firstErr = err
}
// Wait for the sibling goroutine, but don't block forever — the
// goroutine may be stuck in a blocking read that is not context-aware.
select {
case err := <-errc:
if err != nil && firstErr == nil && err != io.EOF && err != context.Canceled && err != context.DeadlineExceeded {
firstErr = err
}
case <-time.After(5 * time.Second):
}
return firstErr
}
type tunRouteKey [16]byte
func ipToTunRouteKey(ip net.IP) (key tunRouteKey) {
+21 -7
View File
@@ -3,6 +3,7 @@ package tun
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/netip"
@@ -29,7 +30,7 @@ func (h *tunHandler) handleServer(ctx context.Context, conn net.Conn, config *tu
return h.transportServer(ctx, conn, pc, config, log)
}()
if err == ErrTun {
if errors.Is(err, ErrTun) {
return err
}
@@ -39,11 +40,21 @@ func (h *tunHandler) handleServer(ctx context.Context, conn net.Conn, config *tu
}
func (h *tunHandler) transportServer(ctx context.Context, tun io.ReadWriter, conn net.PacketConn, config *tun_util.Config, log logger.Logger) error {
errc := make(chan error, 1)
c, cancel := context.WithCancel(ctx)
defer cancel()
errc := make(chan error, 2)
go func() {
var b [MaxMessageSize]byte
for {
select {
case <-c.Done():
errc <- c.Err()
return
default:
}
err := func() error {
n, err := tun.Read(b[:])
if err != nil {
@@ -110,6 +121,13 @@ func (h *tunHandler) transportServer(ctx context.Context, tun io.ReadWriter, con
go func() {
var b [MaxMessageSize]byte
for {
select {
case <-c.Done():
errc <- c.Err()
return
default:
}
err := func() error {
n, addr, err := conn.ReadFrom(b[:])
if err != nil {
@@ -231,11 +249,7 @@ func (h *tunHandler) transportServer(ctx context.Context, tun io.ReadWriter, con
}
}()
err := <-errc
if err != nil && err == io.EOF {
err = nil
}
return err
return collectFirstError(errc, cancel)
}
func (h *tunHandler) updateRoute(ip net.IP, addr net.Addr, log logger.Logger) {
+2
View File
@@ -180,6 +180,7 @@ func (h *unixHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
@@ -188,6 +189,7 @@ func (h *unixHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
+8 -3
View File
@@ -176,6 +176,7 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
if matcher := node.Options().Matcher; matcher != nil {
req := routing.Request{
ClientIP: options.ClientIP,
Network: options.Network,
Host: options.Host,
Protocol: options.Protocol,
Method: options.Method,
@@ -207,13 +208,17 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
})
if nodes[0].Options().Priority > 0 &&
!anyBackupNode(nodes) {
!anyBackupNode(nodes) &&
nodes[0].Options().Priority > nodes[1].Options().Priority {
// Priority short-circuit: highest-priority non-backup node wins.
// Conditions: (1) top priority > 0 means a matcher indicated routing
// specificity, so the top node is authoritative for this request;
// (2) no backup node is present, otherwise BackupFilter would be
// silently bypassed. When both hold the selector (FailFilter,
// BackupFilter, strategy) is skipped and the best node wins directly.
// silently bypassed; (3) the top priority is strictly higher than
// the second-highest — when multiple nodes share the same matcher
// rule they have equal priority and the selector (FailFilter,
// BackupFilter, strategy) should still apply for load balancing.
// When all three hold the selector is skipped and the best node wins directly.
p.logger.Debugf("priority shortcut: node %s selected", nodes[0].Name)
return nodes[0]
}
+62
View File
@@ -422,6 +422,43 @@ func TestSelect_MatcherDoesNotMatch(t *testing.T) {
}
}
func TestSelect_MatcherReceivesNetwork(t *testing.T) {
var capturedReq *routing.Request
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&requestCapturingMatcher{capture: &capturedReq, match: true}),
)
h := newTestHop(NodeOption(n1))
defer h.Close()
h.Select(context.Background(), hop.NetworkSelectOption("tcp"))
if capturedReq == nil {
t.Fatal("request was not passed to matcher")
}
if capturedReq.Network != "tcp" {
t.Errorf("matcher saw Network=%q, want %q", capturedReq.Network, "tcp")
}
}
func TestSelect_MatcherMatchNetwork(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: false}),
)
n2 := chain.NewNode("n2", "127.0.0.1:9090",
chain.MatcherNodeOption(&testMatcher{match: true}),
)
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background(), hop.NetworkSelectOption("udp"))
if node == nil {
t.Fatal("expected selected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (match=true), got %q", node.Name)
}
}
func TestSelect_Priority_HighestWins(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(5))
n2 := chain.NewNode("n2", "127.0.0.1:9090", chain.PriorityNodeOption(10))
@@ -485,6 +522,31 @@ func TestSelect_WithSelector(t *testing.T) {
}
}
func TestSelect_EqualPriorityDoesNotShortcut(t *testing.T) {
// Two nodes with identical matchers get the same default priority.
// The priority shortcut must NOT trigger when multiple nodes share
// the highest priority — the selector should still apply.
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: true}),
chain.PriorityNodeOption(50),
)
n2 := chain.NewNode("n2", "127.0.0.1:9090",
chain.MatcherNodeOption(&testMatcher{match: true}),
chain.PriorityNodeOption(50),
)
sel := &testNodeSelector{selectedIdx: 1} // picks n2
h := newTestHop(NodeOption(n1, n2), SelectorOption(sel))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (selected by selector, not shortcut), got %q", node.Name)
}
}
func TestSelect_NilNodesSkipped(t *testing.T) {
h := newTestHop()
defer h.Close()
+4 -1
View File
@@ -137,7 +137,10 @@ func (h *hostMapper) Lookup(ctx context.Context, network, host string, opts ...h
}
if ips == nil {
return
ips = h.lookup(".")
if ips == nil {
return
}
}
switch network {
+98 -4
View File
@@ -14,12 +14,26 @@ import (
// newTestMapper creates a hostMapper for testing with a nop logger to avoid nil dereference
// when Lookup uses h.options.logger directly.
// It synchronously loads the initial mappings to avoid race conditions with the background
// reload goroutine started by NewHostMapper.
// It synchronously loads the initial mappings without starting the background reload goroutine
// to prevent data races between NewHostMapper's goroutine and test lookups.
func newTestMapper(opts ...Option) *hostMapper {
opts = append(opts, LoggerOption(xlogger.Nop()))
h := NewHostMapper(opts...).(*hostMapper)
_ = h.reload(context.Background())
var options options
for _, opt := range opts {
opt(&options)
}
ctx, cancel := context.WithCancel(context.TODO())
h := &hostMapper{
mappings: make(map[string][]net.IP),
cancelFunc: cancel,
options: options,
logger: options.logger,
}
if h.logger == nil {
h.logger = xlogger.Nop()
}
_ = h.reload(ctx)
cancel()
return h
}
@@ -567,6 +581,86 @@ func TestLookupIPv4MappedIPv6(t *testing.T) {
}
}
func TestLookupCatchAll(t *testing.T) {
h := newTestMapper(MappingsOption([]Mapping{
{Hostname: ".", IP: net.ParseIP("10.0.0.1")},
}))
if err := h.Close(); err != nil {
t.Fatal(err)
}
tests := []struct {
host string
ok bool
}{
{"example.com", true},
{"anything.else.com", true},
{"deep.sub.domain.org", true},
{"singleword", true},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
_, ok := h.Lookup(context.Background(), "ip", tt.host)
if ok != tt.ok {
t.Errorf("Lookup(%q) ok = %v, want %v", tt.host, ok, tt.ok)
}
})
}
}
func TestLookupCatchAllWithSpecific(t *testing.T) {
h := newTestMapper(MappingsOption([]Mapping{
{Hostname: ".example.com", IP: net.ParseIP("10.0.0.1")},
{Hostname: ".", IP: net.ParseIP("10.0.0.2")},
}))
if err := h.Close(); err != nil {
t.Fatal(err)
}
// Specific .suffix should take priority over catch-all
ips, ok := h.Lookup(context.Background(), "ip", "example.com")
if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) {
t.Fatalf("expected [10.0.0.1] from .example.com, got %v (ok=%v)", ips, ok)
}
ips, ok = h.Lookup(context.Background(), "ip", "sub.example.com")
if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) {
t.Fatalf("expected [10.0.0.1] from .example.com, got %v (ok=%v)", ips, ok)
}
// Unmatched hostname falls through to catch-all
ips, ok = h.Lookup(context.Background(), "ip", "other.com")
if !ok || !ips[0].Equal(net.ParseIP("10.0.0.2")) {
t.Fatalf("expected [10.0.0.2] from catch-all, got %v (ok=%v)", ips, ok)
}
}
func TestLookupCatchAllIP4IP6(t *testing.T) {
h := newTestMapper(MappingsOption([]Mapping{
{Hostname: ".", IP: net.ParseIP("10.0.0.1")},
{Hostname: ".", IP: net.ParseIP("::1")},
}))
if err := h.Close(); err != nil {
t.Fatal(err)
}
ips, ok := h.Lookup(context.Background(), "ip4", "anyhost.com")
if !ok || len(ips) != 1 || !ips[0].Equal(net.ParseIP("10.0.0.1")) {
t.Fatalf("expected [10.0.0.1] via ip4 catch-all, got %v (ok=%v)", ips, ok)
}
ips, ok = h.Lookup(context.Background(), "ip6", "anyhost.com")
if !ok || len(ips) != 1 || !ips[0].Equal(net.ParseIP("::1")) {
t.Fatalf("expected [::1] via ip6 catch-all, got %v (ok=%v)", ips, ok)
}
ips, ok = h.Lookup(context.Background(), "ip", "anyhost.com")
if !ok || len(ips) != 2 {
t.Fatalf("expected 2 IPs via catch-all, got %d (ok=%v)", len(ips), ok)
}
}
func TestLoaderWithListerPattern(t *testing.T) {
var _ loader.Loader = (*testLister)(nil)
var _ loader.Lister = (*testLister)(nil)
+7 -1
View File
@@ -14,7 +14,11 @@ import (
// addresses assigned to that interface. The network parameter determines the address
// type (TCPAddr for "tcp"/"tcp4"/"tcp6", UDPAddr for "udp"/"udp4"/"udp6", IPAddr
// otherwise).
func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr, err error) {
//
// isIP reports whether ifceName was parsed as an IP address (true) rather than an
// interface name (false). When isIP is true, callers should skip SO_BINDTODEVICE —
// the user's intent is source IP binding for policy routing, not device binding.
func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr, isIP bool, err error) {
if ifceName == "" {
addr = append(addr, nil)
return
@@ -43,6 +47,8 @@ func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr,
}
}
} else {
// ifceName is an IP address — skip SO_BINDTODEVICE, use LocalAddr only
isIP = true
ifce, err = findInterfaceByIP(ip)
if err != nil {
return
+19 -7
View File
@@ -202,39 +202,48 @@ func Test_findInterfaceByIP(t *testing.T) {
func TestParseInterfaceAddr(t *testing.T) {
// Empty interface name
ifce, addrs, err := ParseInterfaceAddr("", "tcp")
ifce, addrs, isIP, err := ParseInterfaceAddr("", "tcp")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if ifce != "" {
t.Errorf("expected empty ifce, got %s", ifce)
}
if isIP {
t.Errorf("expected isIP=false for empty string, got true")
}
if len(addrs) != 1 || addrs[0] != nil {
t.Errorf("expected [nil], got %v", addrs)
}
// Non-existent interface
_, _, err = ParseInterfaceAddr("nonexistent_interface_xyz", "tcp")
_, _, _, err = ParseInterfaceAddr("nonexistent_interface_xyz", "tcp")
if err == nil {
t.Error("expected error for non-existent interface")
}
// IP string as interface name
ifce, addrs, err = ParseInterfaceAddr("127.0.0.1", "tcp")
// IP string as interface name — isIP should be true
ifce, addrs, isIP, err = ParseInterfaceAddr("127.0.0.1", "tcp")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !isIP {
t.Errorf("expected isIP=true for IP input, got false")
}
if ifce == "" {
t.Log("127.0.0.1 not found on any interface (may happen in containers)")
} else if len(addrs) != 1 {
t.Errorf("expected 1 addr, got %d", len(addrs))
}
// IP that doesn't exist on any interface
ifce, addrs, err = ParseInterfaceAddr("203.0.113.1", "udp")
// IP that doesn't exist on any interface — should still have isIP=true
ifce, addrs, isIP, err = ParseInterfaceAddr("203.0.113.1", "udp")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !isIP {
t.Errorf("expected isIP=true for IP input, got false")
}
if ifce != "" {
t.Errorf("expected empty ifce for unassigned IP, got %s", ifce)
}
@@ -243,10 +252,13 @@ func TestParseInterfaceAddr(t *testing.T) {
}
// Test UDP network specific returns
ifce, addrs, err = ParseInterfaceAddr("127.0.0.1", "udp")
ifce, addrs, isIP, err = ParseInterfaceAddr("127.0.0.1", "udp")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !isIP {
t.Errorf("expected isIP=true for IP input, got false")
}
if ifce != "" {
if len(addrs) != 1 {
t.Errorf("expected 1 addr, got %d", len(addrs))
+12 -9
View File
@@ -76,13 +76,14 @@ func (d *Dialer) Dial(ctx context.Context, network, addr string) (conn net.Conn,
ifce = strings.TrimSuffix(ifce, "!")
var ifceName string
var ifAddrs []net.Addr
ifceName, ifAddrs, err = xnet.ParseInterfaceAddr(ifce, network)
var isIP bool
ifceName, ifAddrs, isIP, err = xnet.ParseInterfaceAddr(ifce, network)
if err != nil && strict {
return
}
for _, ifAddr := range ifAddrs {
conn, err = d.dialOnce(ctx, network, addr, ifceName, ifAddr, log)
conn, err = d.dialOnce(ctx, network, addr, ifceName, ifAddr, !isIP, log)
if err == nil {
return
}
@@ -100,7 +101,7 @@ func (d *Dialer) Dial(ctx context.Context, network, addr string) (conn net.Conn,
return
}
func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, ifAddr net.Addr, log logger.Logger) (net.Conn, error) {
func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, ifAddr net.Addr, bindToDevice bool, log logger.Logger) (net.Conn, error) {
if ifceName != "" {
log.Debugf("dial %s/%s via interface %s@%s", addr, network, ifceName, ifAddr)
}
@@ -123,11 +124,12 @@ func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, i
return nil, err
}
err = sc.Control(func(fd uintptr) {
if ifceName != "" {
if err := bindDevice(network, addr, fd, ifceName); err != nil {
log.Warnf("bind device: %v", err)
}
}
// NOTE: bindDevice is intentionally skipped for empty-addr UDP
// (relay/listener sockets). The ListenUDP laddr binding above
// is sufficient to pin the source IP. SO_BINDTODEVICE would
// force all outbound datagrams through the named interface,
// which may conflict with the kernel routing table and cause
// silent packet drops — breaking UDP associate (issue #287).
if d.Mark != 0 {
if err := setMark(fd, d.Mark); err != nil {
log.Warnf("set mark: %v", err)
@@ -144,10 +146,11 @@ func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, i
return nil, fmt.Errorf("dial: unsupported network %s", network)
}
netd := net.Dialer{
Resolver: &net.Resolver{PreferGo: true},
LocalAddr: ifAddr,
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
if ifceName != "" {
if ifceName != "" && bindToDevice {
if err := bindDevice(network, address, fd, ifceName); err != nil {
log.Warnf("%s/%s bind device: %v", address, network, err)
}
+5
View File
@@ -35,3 +35,8 @@ func bindDevice(network, address string, fd uintptr, ifceName string) error {
func setMark(fd uintptr, mark int) error {
return nil
}
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
func switchNetns(name string) (restore func(), err error) {
return nil, nil
}
+5
View File
@@ -14,3 +14,8 @@ func setMark(fd uintptr, mark int) error {
}
return unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_USER_COOKIE, mark)
}
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
func switchNetns(name string) (restore func(), err error) {
return nil, nil
}
-8
View File
@@ -11,14 +11,6 @@ func bindDevice(network, address string, fd uintptr, ifceName string) error {
return nil
}
// Skip SO_BINDTODEVICE for loopback interface — it prevents
// outbound traffic from reaching non-local destinations.
// Source IP binding via LocalAddr is sufficient for policy routing
// when using IP aliases on loopback (e.g. ip addr add 10.1.1.1/32 dev lo).
if ifce, err := net.InterfaceByName(ifceName); err == nil && ifce.Flags&net.FlagLoopback != 0 {
return nil
}
host, _, _ := net.SplitHostPort(address)
if ip := net.ParseIP(host); ip != nil && !ip.IsGlobalUnicast() {
return nil
+5
View File
@@ -14,3 +14,8 @@ func setMark(fd uintptr, mark int) error {
}
return unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RTABLE, mark)
}
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
func switchNetns(name string) (restore func(), err error) {
return nil, nil
}
+5
View File
@@ -9,3 +9,8 @@ func bindDevice(network, address string, fd uintptr, ifceName string) error {
func setMark(fd uintptr, mark int) error {
return nil
}
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
func switchNetns(name string) (restore func(), err error) {
return nil, nil
}
+5
View File
@@ -49,6 +49,11 @@ func setMark(fd uintptr, mark int) error {
return nil
}
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
func switchNetns(name string) (restore func(), err error) {
return nil, nil
}
func bindSocketToInterface4(handle windows.Handle, index uint32) error {
// For IPv4, this parameter must be an interface index in network byte order.
// Ref: https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options
@@ -13,7 +13,10 @@ import (
// Resolve resolves addr to a concrete IP address. If hosts is non-nil, it is
// consulted first. If r is non-nil and no host mapping matched, the resolver
// is used. If neither is available, addr is returned unchanged.
// is used. If neither is available, the address is returned unchanged so that
// the original hostname is preserved for downstream connectors (e.g., SOCKS5
// ATYP=domain). DNS resolution is deferred to the final TCP dialer, which uses
// a pure-Go resolver to avoid cgo thread exhaustion.
func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hosts hosts.HostMapper, log logger.Logger) (string, error) {
if addr == "" {
return addr, nil
@@ -51,5 +54,10 @@ func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hos
}
return net.JoinHostPort(ips[0].String(), port), nil
}
// Return the address unchanged so that the original hostname is
// preserved through the proxy chain. DNS resolution happens in the
// final TCP dialer (dialer.go) which uses net.Resolver{PreferGo: true}
// to avoid cgo-based lookups that block OS threads.
return addr, nil
}
@@ -115,8 +115,19 @@ func TestResolve_ResolverEmptyResult(t *testing.T) {
}
func TestResolve_NoResolverNoHosts(t *testing.T) {
// When no resolver and no host mapper are configured, the address is
// returned unchanged so that the original hostname is preserved
// through the proxy chain (e.g., SOCKS5 ATYP=domain).
// DNS resolution is deferred to the final TCP dialer.
ctx := context.Background()
addr, err := Resolve(ctx, "tcp", "example.com:8080", nil, nil, nil)
addr, err := Resolve(ctx, "tcp", "1.2.3.4:8080", nil, nil, nil)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if addr != "1.2.3.4:8080" {
t.Errorf("expected 1.2.3.4:8080, got %s", addr)
}
addr, err = Resolve(ctx, "tcp", "example.com:8080", nil, nil, nil)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
+42 -3
View File
@@ -81,7 +81,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
if err != nil {
return err
}
defer cc.Close()
defer func() { cc.Close() }()
upstreamHost := normalizeHost(ro.HTTP.Host, "80")
ho.log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
log = ho.log
@@ -112,6 +114,41 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
log.Trace(string(dump))
}
// When DNS override directs multiple domains to the same proxy IP,
// the browser may reuse a keep-alive connection for a different host.
// Re-dial to ensure requests reach the correct upstream.
if reqHost := normalizeHost(req.Host, "80"); reqHost != "" && reqHost != upstreamHost {
cc.Close()
newNode, res, resolveErr := resolveHTTPNode(ctx, reqHost, req, &ho)
if resolveErr != nil {
ro.HTTP.StatusCode = res.StatusCode
res.Write(conn)
return resolveErr
}
dial := ho.dial
if dial == nil {
dial = (&net.Dialer{}).DialContext
}
newCC, dialErr := dial(ctx, "tcp", newNode.Addr)
if dialErr != nil {
return dialErr
}
newCC = tlsWrapConn(newCC, newNode.Options().TLS)
upstreamHost = reqHost
node = newNode
cc = newCC
ro.Host = reqHost
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
log = log.WithFields(map[string]any{
"host": reqHost,
"node": node.Name,
"dst": node.Addr,
"src": cc.LocalAddr().String(),
})
ho.log = log
}
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, node, req, &pStats, &ho); err != nil || shouldClose {
return err
}
@@ -319,6 +356,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
if re.Pattern.MatchString(req.URL.Path) {
if s := re.Pattern.ReplaceAllString(req.URL.Path, re.Replacement); s != "" {
req.URL.Path = s
ro.HTTP.URI = req.URL.RequestURI()
break
}
}
@@ -331,7 +369,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
// Rewrite request body before wrapping for recording,
// so the recorder sees the rewritten content.
if err = rewriteReqBody(req, reqBodyRewrites...); err != nil {
if err = rewriteReqBody(ctx, req, reqBodyRewrites...); err != nil {
log.Errorf("rewrite request body: %v", err)
return
}
@@ -408,7 +446,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
resp.Header.Set("Connection", "close")
}
if err = rewriteRespBody(resp, respBodyRewrites...); err != nil {
if err = rewriteRespBody(ctx, resp, respBodyRewrites...); err != nil {
log.Errorf("rewrite body: %v", err)
return
}
@@ -434,3 +472,4 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
return
}
+33 -53
View File
@@ -41,38 +41,30 @@ func (h *Sniffer) handleUpgradeResponse(ctx context.Context, rw, cc io.ReadWrite
return xnet.Pipe(ctx, rw, cc)
}
func rewriteRespBody(resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
if resp == nil || len(rewrites) == 0 || resp.ContentLength <= 0 {
func rewriteRespBody(ctx context.Context, resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
if resp == nil {
return nil
}
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
return nil
uri := ""
if resp.Request != nil {
uri = resp.Request.RequestURI
}
body, err := drainBody(resp.Body)
if err != nil || body == nil {
rb, err := newRewriteBody(ctx, resp.Body, rewrites,
resp.Header.Get("Content-Type"),
resp.Header.Get("Content-Encoding"),
resp.ContentLength, "response", uri)
if err != nil {
return err
}
contentType, _, _ := strings.Cut(resp.Header.Get("Content-Type"), ";")
for _, rewrite := range rewrites {
rewriteType := rewrite.Type
if rewriteType == "" {
rewriteType = "text/html"
}
if rewriteType != "*" && !strings.Contains(rewriteType, contentType) {
continue
}
if rewrite.Pattern != nil {
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
}
if rb == nil {
return nil
}
resp.Body = rb
if !rb.streaming && rb.contentLength >= 0 {
resp.ContentLength = rb.contentLength
resp.TransferEncoding = nil
resp.Header.Del("Transfer-Encoding")
}
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
return nil
}
@@ -90,38 +82,26 @@ func drainBody(b io.ReadCloser) (body []byte, err error) {
return buf.Bytes(), nil
}
func rewriteReqBody(req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error {
if req == nil || len(rewrites) == 0 || req.Body == nil || req.ContentLength <= 0 {
func rewriteReqBody(ctx context.Context, req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error {
if req == nil {
return nil
}
if encoding := req.Header.Get("Content-Encoding"); encoding != "" {
return nil
}
body, err := drainBody(req.Body)
if err != nil || body == nil {
rb, err := newRewriteBody(ctx, req.Body, rewrites,
req.Header.Get("Content-Type"),
req.Header.Get("Content-Encoding"),
req.ContentLength, "request", req.RequestURI)
if err != nil {
return err
}
contentType, _, _ := strings.Cut(req.Header.Get("Content-Type"), ";")
for _, rewrite := range rewrites {
rewriteType := rewrite.Type
if rewriteType == "" {
rewriteType = "text/html"
}
if rewriteType != "*" && !strings.Contains(rewriteType, contentType) {
continue
}
if rewrite.Pattern != nil {
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
}
if rb == nil {
return nil
}
req.Body = rb
if !rb.streaming && rb.contentLength >= 0 {
req.ContentLength = rb.contentLength
req.TransferEncoding = nil
req.Header.Del("Transfer-Encoding")
}
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
return nil
}
+526
View File
@@ -0,0 +1,526 @@
package forwarder
import (
"bufio"
"bytes"
"compress/gzip"
"compress/zlib"
"context"
"io"
"strings"
"github.com/andybalholm/brotli"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/rewriter"
xctx "github.com/go-gost/x/ctx"
"github.com/klauspost/compress/zstd"
)
// scanSSEEvents is a bufio.SplitFunc that splits SSE events on \n\n (or \r\n\r\n).
// The delimiter is consumed but not included in the token.
func scanSSEEvents(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i := 0; i < len(data)-1; i++ {
if data[i] == '\n' && data[i+1] == '\n' {
return i + 2, data[:i], nil
}
}
for i := 0; i < len(data)-3; i++ {
if data[i] == '\r' && data[i+1] == '\n' && data[i+2] == '\r' && data[i+3] == '\n' {
return i + 4, data[:i], nil
}
}
for i := 0; i < len(data)-1; i++ {
if data[i] == '\r' && data[i+1] == '\r' {
return i + 2, data[:i], nil
}
}
if atEOF && len(data) > 0 {
return len(data), data, bufio.ErrFinalToken
}
return 0, nil, nil
}
// rewriteBody wraps an io.ReadCloser and applies the rewrite chain.
//
// Three processing modes:
//
// 1. Non-streaming (plain): eagerly buffer the entire body, rewrite once,
// expose the final ContentLength.
//
// 2. Non-streaming (compressed): decompress, rewrite, recompress, expose
// the final ContentLength.
//
// 3. SSE streaming: split on \n\n via bufio.Scanner, rewrite each event
// independently. The first event carries sse_phase:"start", subsequent
// events carry sse_phase:"event", and when the scanner hits EOF an
// empty-body apply(nil, sse_phase:"end") is emitted so Rewriter plugins
// can append a trailing event (e.g. message_stop for LLM API conversion).
type rewriteBody struct {
ctx context.Context
src io.ReadCloser
rewrites []chain.HTTPBodyRewriteSettings
contentType string
streaming bool
scanner *bufio.Scanner
buf bytes.Buffer
contentLength int64 // >= 0 for non-streaming bodies; -1 for streaming
// SSE stream lifecycle state.
eventIndex int // incremented per SSE event for the "event" phase
ended bool // true after the stream-end phase has been emitted
scannerErr error // non-nil when scanner terminated with an error
sid string // session ID, cached from context at construction time
// Protocol detection metadata for Rewriter plugins.
direction string // "request" or "response"
uri string // HTTP request path (e.g. "/v1/messages")
}
// multiReadCloser combines an io.Reader with an io.Closer, used when
// replaying buffered prefix data followed by the original source.
type multiReadCloser struct {
r io.Reader
c io.Closer
}
func (m *multiReadCloser) Read(p []byte) (int, error) { return m.r.Read(p) }
func (m *multiReadCloser) Close() error { return m.c.Close() }
// defaultMaxChunkSize is the default MaxChunkSize for chunked non-streaming
// bodies when no rewrite rule explicitly sets one. 1MB covers typical LLM
// responses without risking OOM on unbounded bodies.
const defaultMaxChunkSize = 1 << 20 // 1MB
// newRewriteBody returns an io.ReadCloser that rewrites data read from src
// through the given rewrite chain. Returns nil, nil when no rewrites are
// configured, or for chunked/unknown-length non-streaming bodies.
// For compressed bodies (Content-Encoding set), the body is decompressed,
// rewritten, and recompressed transparently. Returns nil, err when the
// upstream body cannot be read or the rewrite chain fails.
func newRewriteBody(ctx context.Context, src io.ReadCloser, rewrites []chain.HTTPBodyRewriteSettings, contentType, contentEncoding string, contentLength int64, direction, uri string) (*rewriteBody, error) {
if len(rewrites) == 0 {
return nil, nil
}
ct, _, _ := strings.Cut(contentType, ";")
ct = strings.TrimSpace(ct)
streaming := strings.HasPrefix(ct, "text/event-stream")
// Bounded buffering for chunked/unknown-length non-streaming bodies.
if !streaming && contentLength < 0 {
var maxChunkSize int
for _, rw := range rewrites {
if rw.MaxChunkSize > maxChunkSize {
maxChunkSize = rw.MaxChunkSize
}
}
if maxChunkSize == 0 {
maxChunkSize = defaultMaxChunkSize
}
limited := io.LimitReader(src, int64(maxChunkSize+1))
buf, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if len(buf) > maxChunkSize {
// Overflow: passthrough without rewriting.
// ContentLength stays -1 so Go re-chunk-encodes on Write.
rb := &rewriteBody{
ctx: ctx,
src: &multiReadCloser{r: io.MultiReader(bytes.NewReader(buf), src), c: src},
contentType: ct,
streaming: false,
contentLength: -1,
}
rb.sid = xctx.SidFromContext(ctx).String()
rb.direction = direction
rb.uri = uri
return rb, nil
}
// Fits: rewrite and set ContentLength.
rb := &rewriteBody{
ctx: ctx,
rewrites: rewrites,
contentType: ct,
streaming: false,
contentLength: -1,
}
rb.sid = xctx.SidFromContext(ctx).String()
rb.direction = direction
rb.uri = uri
if contentEncoding != "" {
// Compressed: decompress → rewrite → recompress.
var hasTypeMatch bool
for _, rw := range rewrites {
rt := rw.Type
if rt == "" {
if rw.Rewriter != nil {
rt = "*"
} else {
rt = "text/html"
}
}
if rt == "*" || strings.Contains(rt, ct) {
hasTypeMatch = true
break
}
}
if !hasTypeMatch {
rb.src = io.NopCloser(bytes.NewReader(buf))
rb.contentLength = int64(len(buf))
return rb, nil
}
decoded, err := decompressBody(buf, contentEncoding)
if err != nil {
return nil, err
}
rewritten, err := rb.apply(decoded, rb.baseMetadataOpt())
if err != nil {
return nil, err
}
recompressed, err := compressBody(rewritten, contentEncoding)
if err != nil {
return nil, err
}
rb.src = io.NopCloser(bytes.NewReader(recompressed))
rb.contentLength = int64(len(recompressed))
} else {
rewritten, err := rb.apply(buf, rb.baseMetadataOpt())
if err != nil {
return nil, err
}
rb.src = io.NopCloser(bytes.NewReader(rewritten))
rb.contentLength = int64(len(rewritten))
}
return rb, nil
}
rb := &rewriteBody{
ctx: ctx,
src: src,
rewrites: rewrites,
contentType: ct,
streaming: streaming,
contentLength: -1,
}
rb.sid = xctx.SidFromContext(ctx).String()
rb.direction = direction
rb.uri = uri
mdOpts := []rewriter.RewriteOption{rb.baseMetadataOpt()}
if contentEncoding != "" {
if streaming {
return nil, nil
}
// Early exit if no rewrite rule matches this content type
// (avoids unnecessary decompress-recompress cycle).
var hasTypeMatch bool
for _, rw := range rewrites {
rt := rw.Type
if rt == "" {
if rw.Rewriter != nil {
rt = "*"
} else {
rt = "text/html"
}
}
if rt == "*" || strings.Contains(rt, ct) {
hasTypeMatch = true
break
}
}
if !hasTypeMatch {
return nil, nil
}
// Compressed non-streaming: decompress, rewrite, recompress.
body, err := drainBody(src)
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, nil
}
decoded, err := decompressBody(body, contentEncoding)
if err != nil {
return nil, err
}
rewritten, err := rb.apply(decoded, mdOpts...)
if err != nil {
return nil, err
}
recompressed, err := compressBody(rewritten, contentEncoding)
if err != nil {
return nil, err
}
rb.src = io.NopCloser(bytes.NewReader(recompressed))
rb.contentLength = int64(len(recompressed))
return rb, nil
}
if !streaming {
// Non-streaming: eagerly read and rewrite so the caller knows the
// final content length.
body, err := drainBody(src)
if err != nil {
return nil, err
}
rewritten, err := rb.apply(body, mdOpts...)
if err != nil {
return nil, err
}
rb.src = io.NopCloser(bytes.NewReader(rewritten))
rb.contentLength = int64(len(rewritten))
} else {
// SSE streaming: scan events split on \n\n and rewrite each one
// independently on Read. Lifecycle phases (first event = start,
// subsequent = event, EOF = end) are assigned in Read().
scanner := bufio.NewScanner(src)
scanner.Split(scanSSEEvents)
scanner.Buffer(make([]byte, 64*1024), 64*1024*1024)
rb.scanner = scanner
}
return rb, nil
}
func (b *rewriteBody) Read(p []byte) (n int, err error) {
for {
if !b.streaming {
return b.src.Read(p)
}
// SSE streaming: flush buffered rewritten event first.
if b.buf.Len() > 0 {
return b.buf.Read(p)
}
// Scan the next SSE event.
if !b.scanner.Scan() {
err = b.scanner.Err()
if err == nil {
if b.ended {
if b.scannerErr != nil {
return 0, b.scannerErr
}
return 0, io.EOF
}
// End of upstream SSE events — emit stream end phase.
// This empty-body call lets Rewriter plugins append a
// trailing event (e.g. message_stop for LLM conversion).
b.ended = true
md := map[string]any{
"sid": b.sid,
"direction": b.direction,
"uri": b.uri,
"sse_phase": "end",
"event_index": b.eventIndex,
}
rewritten, endErr := b.apply(nil, rewriter.MetadataRewriteOption(md))
if endErr != nil {
return 0, endErr
}
if len(rewritten) > 0 {
b.buf.Write(rewritten)
writeSSEStreamDelim(&b.buf, rewritten)
}
return b.buf.Read(p)
}
// Scanner error (e.g. upstream connection reset): emit the end
// phase so Rewriter plugins can send a terminal event
// (response.failed / message_stop), then return the error
// after the buffer drains.
if !b.ended {
b.ended = true
b.scannerErr = err
md := map[string]any{
"sid": b.sid,
"direction": b.direction,
"uri": b.uri,
"sse_phase": "end",
"event_index": b.eventIndex,
"stream_error": err.Error(),
}
rewritten, _ := b.apply(nil, rewriter.MetadataRewriteOption(md))
if len(rewritten) > 0 {
b.buf.Write(rewritten)
writeSSEStreamDelim(&b.buf, rewritten)
return b.buf.Read(p)
}
}
return 0, err
}
// Copy token (scanner.Bytes() is only valid until next Scan()).
event := make([]byte, len(b.scanner.Bytes()))
copy(event, b.scanner.Bytes())
// SSE lifecycle phases — assigned to real events so the Rewriter
// can distinguish first (start), middle (event), and last event
// without synthetic signals. On EOF, apply(nil, end) produces any
// trailing event (e.g. message_stop for LLM API conversion).
phase := "event"
if b.eventIndex == 0 {
phase = "start"
}
md := map[string]any{
"sid": b.sid,
"direction": b.direction,
"uri": b.uri,
"sse_phase": phase,
"event_index": b.eventIndex,
}
b.eventIndex++
rewritten, err := b.apply(event, rewriter.MetadataRewriteOption(md))
if err != nil {
return 0, err
}
if len(rewritten) > 0 {
b.buf.Write(rewritten)
writeSSEStreamDelim(&b.buf, rewritten)
}
// Loop to flush the buffer or scan the next event.
}
}
func (b *rewriteBody) Close() error {
return b.src.Close()
}
// baseMetadata returns rewriter metadata with all available fields.
func (b *rewriteBody) baseMetadata() map[string]any {
md := map[string]any{}
if b.sid != "" {
md["sid"] = b.sid
}
if b.direction != "" {
md["direction"] = b.direction
}
if b.uri != "" {
md["uri"] = b.uri
}
return md
}
// baseMetadataOpt returns a RewriteOption wrapping baseMetadata.
func (b *rewriteBody) baseMetadataOpt() rewriter.RewriteOption {
return rewriter.MetadataRewriteOption(b.baseMetadata())
}
// ponytail: guard against plugin already having added \n\n delimiter
func writeSSEStreamDelim(buf *bytes.Buffer, data []byte) {
if len(data) < 2 || data[len(data)-1] != '\n' || data[len(data)-2] != '\n' {
buf.Write([]byte("\n\n"))
}
}
func decompressBody(data []byte, encoding string) ([]byte, error) {
switch encoding {
case "gzip":
r, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "deflate":
r, err := zlib.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "br":
return io.ReadAll(brotli.NewReader(bytes.NewReader(data)))
case "zstd":
r, err := zstd.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
default:
// identity or unknown — pass through.
return data, nil
}
}
func compressBody(data []byte, encoding string) ([]byte, error) {
var buf bytes.Buffer
switch encoding {
case "gzip":
w := gzip.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
case "deflate":
w := zlib.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
case "br":
w := brotli.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
case "zstd":
w, err := zstd.NewWriter(&buf)
if err != nil {
return nil, err
}
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
default:
return data, nil
}
return buf.Bytes(), nil
}
// apply runs the rewrite chain on body bytes.
// Returns the (possibly unchanged) body and any error from a plugin Rewriter.
func (b *rewriteBody) apply(body []byte, opts ...rewriter.RewriteOption) ([]byte, error) {
if len(body) == 0 && len(opts) == 0 {
return body, nil
}
for _, rw := range b.rewrites {
rewriteType := rw.Type
if rewriteType == "" {
if rw.Rewriter != nil {
rewriteType = "*"
} else {
rewriteType = "text/html"
}
}
if rewriteType != "*" && !strings.Contains(rewriteType, b.contentType) {
continue
}
if rw.Rewriter != nil {
if body == nil || rw.Pattern == nil || rw.Pattern.Match(body) {
rewritten, err := rw.Rewriter.Rewrite(b.ctx, body, opts...)
if err != nil {
return body, err
}
body = rewritten
}
} else if rw.Pattern != nil {
body = rw.Pattern.ReplaceAll(body, rw.Replacement)
}
}
return body, nil
}
+659 -48
View File
@@ -3,6 +3,8 @@ package forwarder
import (
"bufio"
"bytes"
"compress/gzip"
"compress/zlib"
"context"
"io"
"net"
@@ -13,10 +15,13 @@ import (
"testing"
"time"
"github.com/andybalholm/brotli"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/recorder"
"github.com/go-gost/core/rewriter"
"github.com/klauspost/compress/zstd"
xlogger "github.com/go-gost/x/logger"
xrecorder "github.com/go-gost/x/recorder"
)
@@ -43,6 +48,32 @@ func (m *mockBypass) Contains(_ context.Context, _, _ string, _ ...bypass.Option
func (m *mockBypass) IsWhitelist() bool { return m.whitelist }
// compressTestData compresses data with the given encoding for test purposes.
func compressTestData(data []byte, encoding string) []byte {
var buf bytes.Buffer
switch encoding {
case "gzip":
w := gzip.NewWriter(&buf)
w.Write(data)
w.Close()
case "deflate":
w := zlib.NewWriter(&buf)
w.Write(data)
w.Close()
case "br":
w := brotli.NewWriter(&buf)
w.Write(data)
w.Close()
case "zstd":
w, _ := zstd.NewWriter(&buf)
w.Write(data)
w.Close()
default:
return data
}
return buf.Bytes()
}
// =============================================================================
// Pure Function Tests
// =============================================================================
@@ -70,7 +101,7 @@ func TestRewriteReqBody(t *testing.T) {
wantCL: 11,
},
{
name: "nil body skipped",
name: "nil body",
req: &http.Request{
Body: nil,
ContentLength: 11,
@@ -79,10 +110,10 @@ func TestRewriteReqBody(t *testing.T) {
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
},
wantBody: "",
wantCL: 11,
wantCL: 0,
},
{
name: "zero content length skipped",
name: "zero content length",
req: &http.Request{
Body: io.NopCloser(bytes.NewReader(hello)),
ContentLength: 0,
@@ -90,21 +121,8 @@ func TestRewriteReqBody(t *testing.T) {
rewrites: []chain.HTTPBodyRewriteSettings{
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
},
wantBody: "hello world",
wantCL: 0,
},
{
name: "content-encoding skips rewrite",
req: &http.Request{
Body: io.NopCloser(bytes.NewReader(hello)),
ContentLength: int64(len(hello)),
Header: http.Header{"Content-Encoding": {"gzip"}},
},
rewrites: []chain.HTTPBodyRewriteSettings{
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
},
wantBody: "hello world",
wantCL: 11,
wantBody: "hi world",
wantCL: 8,
},
{
name: "content type does not match default text/html",
@@ -164,7 +182,7 @@ func TestRewriteReqBody(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.req != nil {
_ = rewriteReqBody(tt.req, tt.rewrites...)
_ = rewriteReqBody(context.Background(), tt.req, tt.rewrites...)
if tt.req.Body != nil {
body, _ := io.ReadAll(tt.req.Body)
tt.req.Body.Close()
@@ -178,11 +196,40 @@ func TestRewriteReqBody(t *testing.T) {
t.Errorf("ContentLength = %d, want %d", tt.req.ContentLength, tt.wantCL)
}
} else {
_ = rewriteReqBody(nil) // should not panic
_ = rewriteReqBody(context.Background(), nil) // should not panic
}
})
}
}
func TestRewriteReqBody_Gzip(t *testing.T) {
t.Run("gzip content-encoding rewrite", func(t *testing.T) {
hello := []byte("hello world")
gzipHello := compressTestData(hello, "gzip")
req := &http.Request{
Body: io.NopCloser(bytes.NewReader(gzipHello)),
ContentLength: int64(len(gzipHello)),
Header: http.Header{"Content-Encoding": {"gzip"}, "Content-Type": {"text/html"}},
}
_ = rewriteReqBody(context.Background(), req, []chain.HTTPBodyRewriteSettings{
{Type: "*", Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
}...)
body, _ := io.ReadAll(req.Body)
req.Body.Close()
r, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("expected valid gzip output: %v", err)
}
decoded, _ := io.ReadAll(r)
r.Close()
if string(decoded) != "hi world" {
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
}
if req.ContentLength != int64(len(body)) {
t.Errorf("ContentLength = %d, want %d", req.ContentLength, len(body))
}
})
}
func TestClampBodySize(t *testing.T) {
tests := []struct {
@@ -344,8 +391,8 @@ func TestDrainBody(t *testing.T) {
func TestRewriteRespBody(t *testing.T) {
t.Run("nil response", func(t *testing.T) {
if err := rewriteRespBody(nil); err != nil {
t.Errorf("rewriteRespBody(nil) = %v, want nil", err)
if err := rewriteRespBody(context.Background(), nil); err != nil {
t.Errorf("rewriteRespBody(context.Background(), nil) = %v, want nil", err)
}
})
@@ -354,7 +401,7 @@ func TestRewriteRespBody(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp)
_ = rewriteRespBody(context.Background(), resp)
// Body unchanged
got, _ := io.ReadAll(resp.Body)
if string(got) != "original" {
@@ -365,34 +412,43 @@ func TestRewriteRespBody(t *testing.T) {
t.Run("zero content length", func(t *testing.T) {
resp := &http.Response{
ContentLength: 0,
Header: http.Header{"Content-Type": {"text/plain"}},
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("."),
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"),
Type: "*",
Replacement: []byte("replaced"),
})
got, _ := io.ReadAll(resp.Body)
// Content-Length was 0 (unknown), so no rewrite applied
if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
if string(got) != "replaced" {
t.Errorf("body = %q, want %q", string(got), "replaced")
}
})
t.Run("with content encoding", func(t *testing.T) {
original := []byte("original")
compressed := compressTestData(original, "gzip")
resp := &http.Response{
Header: http.Header{"Content-Encoding": {"gzip"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
ContentLength: int64(len(compressed)),
Body: io.NopCloser(bytes.NewReader(compressed)),
}
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Pattern: regexp.MustCompile(".*"),
Replacement: []byte("replaced"),
})
got, _ := io.ReadAll(resp.Body)
// Content-Encoding present, rewrite skipped
if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
// Output is gzip-compressed, decompress to verify.
r, err := gzip.NewReader(bytes.NewReader(got))
if err != nil {
t.Fatalf("expected valid gzip output: %v", err)
}
decoded, _ := io.ReadAll(r)
r.Close()
if string(decoded) != "replaced" {
t.Errorf("decompressed body = %q, want %q", string(decoded), "replaced")
}
})
}
@@ -413,7 +469,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced"))
_ = rewriteRespBody(context.Background(), resp, makeRewrite("text/html", "replaced"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "replaced" {
t.Errorf("body = %q, want %q", string(got), "replaced")
@@ -426,7 +482,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")),
}
_ = rewriteRespBody(resp, makeRewrite("*", "world"))
_ = rewriteRespBody(context.Background(), resp, makeRewrite("*", "world"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "world" {
t.Errorf("body = %q, want %q", string(got), "world")
@@ -439,7 +495,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced"))
_ = rewriteRespBody(context.Background(), resp, makeRewrite("text/html", "replaced"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
@@ -454,7 +510,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
}
// Empty type defaults to "text/html", and response has no Content-Type -> "" containing "text/html"? No.
// strings.Contains("text/html", "") is always true, so rewrite should happen.
_ = rewriteRespBody(resp, makeRewrite("", "replaced"))
_ = rewriteRespBody(context.Background(), resp, makeRewrite("", "replaced"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "replaced" {
t.Errorf("body = %q, want %q (should be rewritten)", string(got), "replaced")
@@ -1146,7 +1202,7 @@ func TestRewriteRespBody_PatternReplacement(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("<title>Old Title</title>")),
}
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("Old"),
Type: "text/html",
Replacement: []byte("New"),
@@ -1169,7 +1225,7 @@ func TestRewriteRespBody_MultipleRewrites(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")),
}
err := rewriteRespBody(resp,
err := rewriteRespBody(context.Background(), resp,
chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("h.*o"),
Type: "text/html",
@@ -1196,7 +1252,7 @@ func TestRewriteRespBody_NilPattern(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: nil,
Type: "*",
Replacement: []byte("replaced"),
@@ -1215,7 +1271,7 @@ func TestRewriteRespBody_EmptyContentTypeUsesDefault(t *testing.T) {
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"),
Type: "",
Replacement: []byte("rewritten"),
@@ -1229,30 +1285,320 @@ func TestRewriteRespBody_EmptyContentTypeUsesDefault(t *testing.T) {
}
}
func TestRewriteRespBody_NegativeContentLength(t *testing.T) {
func TestRewriteRespBody_DefaultMaxChunkSize(t *testing.T) {
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: -1,
Body: io.NopCloser(strings.NewReader("original")),
}
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"),
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("original"),
Type: "*",
Replacement: []byte("replaced"),
Replacement: []byte("rewritten"),
})
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(resp.Body)
if string(got) != "rewritten" {
t.Errorf("body = %q, want %q (default 1MB maxChunkSize should buffer and rewrite)", string(got), "rewritten")
}
if resp.ContentLength != int64(len("rewritten")) {
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
}
}
func TestRewriteRespBody_MaxChunkSize_Fits(t *testing.T) {
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: -1,
Body: io.NopCloser(strings.NewReader("original")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"),
Type: "*",
Replacement: []byte("rewritten"),
MaxChunkSize: 1024,
})
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(resp.Body)
if string(got) != "rewritten" {
t.Errorf("body = %q, want %q", string(got), "rewritten")
}
if resp.ContentLength != int64(len("rewritten")) {
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
}
if resp.TransferEncoding != nil {
t.Errorf("TransferEncoding = %v, want nil", resp.TransferEncoding)
}
}
func TestRewriteRespBody_MaxChunkSize_Overflow(t *testing.T) {
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: -1,
Body: io.NopCloser(strings.NewReader("original")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"),
Type: "*",
Replacement: []byte("rewritten"),
MaxChunkSize: 4,
})
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(resp.Body)
if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
t.Errorf("body = %q, want %q (unchanged, overflow)", string(got), "original")
}
if resp.ContentLength != -1 {
t.Errorf("ContentLength = %d, want -1", resp.ContentLength)
}
}
func TestRewriteRespBody_MaxChunkSize_Compressed_Fits(t *testing.T) {
original := []byte("hello world")
compressed := compressTestData(original, "gzip")
resp := &http.Response{
Header: http.Header{"Content-Encoding": {"gzip"}, "Content-Type": {"text/plain"}},
ContentLength: -1,
Body: io.NopCloser(bytes.NewReader(compressed)),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("hello"),
Type: "*",
Replacement: []byte("hi"),
MaxChunkSize: 1024,
})
if err != nil {
t.Fatal(err)
}
// Body should be decompressed → rewritten → recompressed.
got, _ := io.ReadAll(resp.Body)
gr, err := gzip.NewReader(bytes.NewReader(got))
if err != nil {
t.Fatalf("expected valid gzip output: %v", err)
}
decoded, _ := io.ReadAll(gr)
gr.Close()
if string(decoded) != "hi world" {
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
}
if resp.ContentLength != int64(len(got)) {
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len(got))
}
if resp.TransferEncoding != nil {
t.Errorf("TransferEncoding = %v, want nil", resp.TransferEncoding)
}
}
// =============================================================================
// copyWebsocketFrame Additional Tests
// Rewriter Plugin Tests
// =============================================================================
// mockRewriter is a simple rewriter.Rewriter for testing.
type mockRewriter struct {
cb func(b []byte) []byte
called bool
}
func (m *mockRewriter) Rewrite(_ context.Context, b []byte, _ ...rewriter.RewriteOption) ([]byte, error) {
m.called = true
if m.cb != nil {
return m.cb(b), nil
}
return b, nil
}
func TestRewriteRespBody_RewriterNilPattern(t *testing.T) {
rw := &mockRewriter{}
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Rewriter: rw,
})
if err != nil {
t.Fatal(err)
}
if !rw.called {
t.Error("rewriter was not called")
}
}
func TestRewriteRespBody_RewriterMatchPass(t *testing.T) {
rw := &mockRewriter{
cb: func(b []byte) []byte {
return []byte("rewritten")
},
}
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello world")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Pattern: regexp.MustCompile("hello"),
Rewriter: rw,
})
if err != nil {
t.Fatal(err)
}
if !rw.called {
t.Fatal("rewriter was not called")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "rewritten" {
t.Errorf("body = %q, want %q", string(body), "rewritten")
}
if resp.ContentLength != int64(len("rewritten")) {
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
}
}
func TestRewriteRespBody_RewriterMatchSkip(t *testing.T) {
rw := &mockRewriter{}
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello world")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Pattern: regexp.MustCompile("xyz"),
Rewriter: rw,
})
if err != nil {
t.Fatal(err)
}
if rw.called {
t.Fatal("rewriter should not have been called: body did not match pattern")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "hello world" {
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello world")
}
}
func TestRewriteRespBody_RewriterContentTypeFilter(t *testing.T) {
rw := &mockRewriter{
cb: func(b []byte) []byte { return []byte("rewritten") },
}
t.Run("type matches calls rewriter", func(t *testing.T) {
rw.called = false
resp := &http.Response{
Header: http.Header{"Content-Type": {"application/json"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader(`{"key":"value"}`)),
}
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "application/json",
Rewriter: rw,
})
if !rw.called {
t.Error("rewriter should have been called")
}
})
t.Run("type mismatch skips rewriter", func(t *testing.T) {
rw.called = false
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/plain"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")),
}
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "application/json",
Rewriter: rw,
})
if rw.called {
t.Error("rewriter should not have been called: content type mismatch")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "hello" {
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello")
}
})
}
func TestRewriteReqBody_Rewriter(t *testing.T) {
rw := &mockRewriter{
cb: func(b []byte) []byte {
return []byte("rewritten")
},
}
t.Run("nil pattern calls rewriter", func(t *testing.T) {
rw.called = false
req := &http.Request{
Body: io.NopCloser(strings.NewReader("hello")),
ContentLength: 5,
Header: http.Header{"Content-Type": {"text/plain"}},
}
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
Type: "*",
Rewriter: rw,
})
if !rw.called {
t.Fatal("rewriter was not called")
}
body, _ := io.ReadAll(req.Body)
if string(body) != "rewritten" {
t.Errorf("body = %q, want %q", string(body), "rewritten")
}
})
t.Run("match passes through to rewriter", func(t *testing.T) {
rw.called = false
req := &http.Request{
Body: io.NopCloser(strings.NewReader("hello world")),
ContentLength: 11,
Header: http.Header{"Content-Type": {"text/html"}},
}
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
Type: "text/html",
Pattern: regexp.MustCompile("hello"),
Rewriter: rw,
})
if !rw.called {
t.Fatal("rewriter was not called")
}
body, _ := io.ReadAll(req.Body)
if string(body) != "rewritten" {
t.Errorf("body = %q, want %q", string(body), "rewritten")
}
})
t.Run("non-match skips rewriter", func(t *testing.T) {
rw.called = false
req := &http.Request{
Body: io.NopCloser(strings.NewReader("hello world")),
ContentLength: 11,
Header: http.Header{"Content-Type": {"text/html"}},
}
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
Type: "text/html",
Pattern: regexp.MustCompile("xyz"),
Rewriter: rw,
})
if rw.called {
t.Fatal("rewriter should not have been called: body did not match pattern")
}
body, _ := io.ReadAll(req.Body)
if string(body) != "hello world" {
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello world")
}
})
}
func TestCopyWebsocketFrame_EmptyPayload(t *testing.T) {
h := &Sniffer{
Recorder: &noopRecorder{},
@@ -1393,3 +1739,268 @@ func TestServeH2_InvalidPreface(t *testing.T) {
t.Error("expected error from invalid h2 preface, got nil")
}
}
// =============================================================================
// SSE (Server-Sent Events) Tests
// =============================================================================
func TestRewriteRespBody_SSE_ContentType(t *testing.T) {
// rewriteRespBody now rewrites SSE per-event via the universal wrapper.
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/event-stream"}},
ContentLength: -1,
Body: io.NopCloser(strings.NewReader("data: hello\n\n")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("data: hello"),
Type: "*",
Replacement: []byte("data: world"),
})
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(resp.Body)
if string(got) != "data: world\n\n" {
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
}
}
func TestNewRewriteBody(t *testing.T) {
makeRewrite := func(rewriteType, match, replace string) chain.HTTPBodyRewriteSettings {
return chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(match),
Type: rewriteType,
Replacement: []byte(replace),
}
}
t.Run("regex replacement streaming", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
makeRewrite("*", "hello", "world"),
}, "text/event-stream", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body")
}
got, _ := io.ReadAll(body)
body.Close()
if string(got) != "data: world\n\n" {
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
}
})
t.Run("multiple events streaming", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("data: a\n\ndata: b\n\ndata: c\n\n"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
makeRewrite("*", "data: [ab]$", "data: x"),
}, "text/event-stream", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body")
}
got, _ := io.ReadAll(body)
body.Close()
expected := "data: x\n\ndata: x\n\ndata: c\n\n"
if string(got) != expected {
t.Errorf("body = %q, want %q", string(got), expected)
}
})
t.Run("multiple rewrite rules streaming", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
makeRewrite("*", "hello", "world"),
makeRewrite("*", "world", "there"),
}, "text/event-stream", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body")
}
got, _ := io.ReadAll(body)
body.Close()
if string(got) != "data: there\n\n" {
t.Errorf("body = %q, want %q", string(got), "data: there\n\n")
}
})
t.Run("content type filter streaming", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
// text/html type rule — won't match text/event-stream.
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
{Type: "text/html", Pattern: regexp.MustCompile("hello"), Replacement: []byte("world")},
}, "text/event-stream", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body")
}
got, _ := io.ReadAll(body)
body.Close()
if string(got) != "data: hello\n\n" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "data: hello\n\n")
}
})
t.Run("no rewrites returns nil", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
body, err := newRewriteBody(context.Background(), src, nil, "text/event-stream", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body != nil {
t.Error("expected nil for no rewrites")
}
})
t.Run("content encoding returns nil", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
makeRewrite("*", "hello", "world"),
}, "text/event-stream", "gzip", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body != nil {
t.Error("expected nil for Content-Encoding")
}
})
t.Run("plugin rewriter streaming", func(t *testing.T) {
rw := &mockRewriter{
cb: func(b []byte) []byte { return []byte("data: rewritten") },
}
src := io.NopCloser(strings.NewReader("data: original\n\n"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
{Type: "*", Rewriter: rw},
}, "text/event-stream", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body")
}
got, _ := io.ReadAll(body)
body.Close()
if string(got) != "data: rewritten\n\ndata: rewritten\n\n" {
t.Errorf("body = %q, want %q", string(got), "data: rewritten\n\ndata: rewritten\n\n")
}
})
t.Run("incomplete final event flushed", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("data: hello"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
makeRewrite("*", "hello", "world"),
}, "text/event-stream", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body")
}
got, _ := io.ReadAll(body)
body.Close()
if string(got) != "data: world\n\n" {
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
}
})
t.Run("non-streaming body rewritten", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("hello world"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
makeRewrite("*", "hello", "hi"),
}, "text/plain", "", 11, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body")
}
got, _ := io.ReadAll(body)
body.Close()
if string(got) != "hi world" {
t.Errorf("body = %q, want %q", string(got), "hi world")
}
if body.contentLength != int64(len("hi world")) {
t.Errorf("contentLength = %d, want %d", body.contentLength, len("hi world"))
}
})
t.Run("chunked non-streaming uses default maxChunkSize", func(t *testing.T) {
src := io.NopCloser(strings.NewReader("hello world"))
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
makeRewrite("*", "hello", "hi"),
}, "text/plain", "", -1, "", "")
if err != nil {
t.Fatal(err)
}
if body == nil {
t.Fatal("expected non-nil body (default 1MB maxChunkSize)")
}
got, _ := io.ReadAll(body)
body.Close()
if string(got) != "hi world" {
t.Errorf("body = %q, want %q", string(got), "hi world")
}
})
}
func TestRewriteRespBody_CompressedEncodings(t *testing.T) {
tests := []struct {
name string
encoding string
}{
{"gzip", "gzip"},
{"deflate", "deflate"},
{"br", "br"},
{"zstd", "zstd"},
}
input := []byte("hello world")
rewrites := []chain.HTTPBodyRewriteSettings{
{
Type: "*",
Pattern: regexp.MustCompile("hello"),
Replacement: []byte("hi"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compressed := compressTestData(input, tt.encoding)
resp := &http.Response{
Header: http.Header{"Content-Encoding": {tt.encoding}, "Content-Type": {"text/plain"}},
ContentLength: int64(len(compressed)),
Body: io.NopCloser(bytes.NewReader(compressed)),
}
if err := rewriteRespBody(context.Background(), resp, rewrites...); err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// Output is recompressed; decompress to verify rewrite.
decoded, err := decompressBody(got, tt.encoding)
if err != nil {
t.Fatalf("decompress: %v", err)
}
if string(decoded) != "hi world" {
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
}
})
}
}
+2 -1
View File
@@ -54,7 +54,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, conn net.Conn, opts ...HandleOp
if ho.log != nil {
ho.log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
}
return nil
return errors.New("tls: sni is empty, closing connection")
}
ro.Host = host
@@ -321,6 +321,7 @@ func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHel
}),
WithHTTPKeepalive(true),
WithNode(ho.node),
WithBypass(ho.bypass),
WithRecorderObject(ro),
WithLog(log),
}
+19 -1
View File
@@ -2,6 +2,7 @@ package relay
import (
"net"
"strconv"
"github.com/go-gost/gosocks5"
"github.com/go-gost/relay"
@@ -54,6 +55,19 @@ func UDPTunServerConn(c net.Conn) net.PacketConn {
}
}
// domainAddr is a net.Addr that preserves a domain name without resolving it.
// Returned by udpTunConn.ReadFrom when a relay UDP datagram carries
// ATYP=DOMAINNAME, so the relay handler can resolve it through the configured
// resolver instead of leaking the query through the system resolver.
type domainAddr struct {
network string
host string
port int
}
func (a *domainAddr) Network() string { return a.network }
func (a *domainAddr) String() string { return net.JoinHostPort(a.host, strconv.Itoa(a.port)) }
func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
socksAddr := gosocks5.Addr{}
header := gosocks5.UDPHeader{
@@ -72,7 +86,11 @@ func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
if n > len(b) {
n = copy(b, dgram.Data)
}
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
if net.ParseIP(socksAddr.Host) != nil {
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
} else {
addr = &domainAddr{network: "udp", host: socksAddr.Host, port: int(socksAddr.Port)}
}
return
}
+76
View File
@@ -0,0 +1,76 @@
package relay
import (
"net"
"testing"
)
// pipeConn is a net.Conn backed by bytes.Buffer — Write writes to buf, Read reads from buf.
type pipeConn struct {
net.Conn
buf []byte
}
func newPipeConn() *pipeConn {
return &pipeConn{}
}
func (c *pipeConn) Read(b []byte) (int, error) {
n := copy(b, c.buf)
c.buf = c.buf[n:]
return n, nil
}
func (c *pipeConn) Write(b []byte) (int, error) {
c.buf = append(c.buf, b...)
return len(b), nil
}
func (c *pipeConn) Close() error { return nil }
func Test_udpTunConn_domainRoundTrip(t *testing.T) {
pc1 := newPipeConn()
server := UDPTunServerConn(pc1)
domain := &domainAddr{network: "udp", host: "dns.google", port: 53}
_, err := server.WriteTo([]byte("hello"), domain)
if err != nil {
t.Fatal(err)
}
pc2 := newPipeConn()
pc2.Write(pc1.buf)
_, addr, err := UDPTunServerConn(pc2).ReadFrom(make([]byte, 1500))
if err != nil {
t.Fatal(err)
}
da, ok := addr.(*domainAddr)
if !ok {
t.Fatalf("expected *domainAddr, got %T: %s", addr, addr)
}
if da.host != "dns.google" || da.port != 53 {
t.Fatalf("expected dns.google:53, got %s:%d", da.host, da.port)
}
}
func Test_udpTunConn_ipRoundTrip(t *testing.T) {
pc1 := newPipeConn()
server := UDPTunServerConn(pc1)
ipAddr := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}
_, err := server.WriteTo([]byte("hello"), ipAddr)
if err != nil {
t.Fatal(err)
}
pc2 := newPipeConn()
pc2.Write(pc1.buf)
_, addr, err := UDPTunServerConn(pc2).ReadFrom(make([]byte, 1500))
if err != nil {
t.Fatal(err)
}
if _, ok := addr.(*net.UDPAddr); !ok {
t.Fatalf("expected *net.UDPAddr, got %T: %s", addr, addr)
}
}
+23 -1
View File
@@ -93,7 +93,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
if err != nil {
return err
}
defer cc.Close()
defer func() { cc.Close() }()
upstreamHost := host
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
@@ -122,6 +124,26 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
log.Trace(string(dump))
}
// When DNS override directs multiple domains to the same proxy IP,
// the browser may reuse a keep-alive connection for a different host.
// Re-dial to ensure requests reach the correct upstream.
if reqHost := normalizeHost(req.Host, "80"); reqHost != upstreamHost {
cc.Close()
cc, err = dial(ctx, network, reqHost)
if err != nil {
return err
}
upstreamHost = reqHost
ro.Host = reqHost
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
log = log.WithFields(map[string]any{
"host": reqHost,
"src": cc.LocalAddr().String(),
"dst": cc.RemoteAddr().String(),
})
}
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, req, readTimeout, ro, &pStats, log); err != nil || shouldClose {
return err
}
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"io"
"net"
"time"
@@ -52,7 +53,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, network string, conn net.Conn,
if log != nil {
log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
}
return nil
return errors.New("tls: sni is empty, closing connection")
}
ro.Host = host
@@ -234,6 +235,7 @@ func (h *Sniffer) terminateTLS(ctx context.Context, network string, conn, cc net
WithDialTLS(func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
return clientConn, nil
}),
WithBypass(ho.bypass),
WithRecorderObject(ro),
WithLog(log),
}
+25 -2
View File
@@ -3,6 +3,7 @@ package socks
import (
"bytes"
"net"
"strconv"
"github.com/go-gost/core/common/bufpool"
"github.com/go-gost/gosocks5"
@@ -50,7 +51,11 @@ func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
if n > len(b) {
n = copy(b, dgram.Data)
}
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
if net.ParseIP(socksAddr.Host) != nil {
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
} else {
addr = &domainAddr{network: "udp", host: socksAddr.Host, port: int(socksAddr.Port)}
}
return
}
@@ -106,6 +111,20 @@ func UDPConn(c net.PacketConn, bufferSize int) net.PacketConn {
}
}
// domainAddr is a net.Addr that preserves a domain name without resolving it.
// Returned by udpConn.ReadFrom when a SOCKS5 UDP datagram carries
// ATYP=DOMAINNAME, so that downstream consumers (e.g. relay-chain WriteTo) can
// forward the domain verbatim instead of forcing a local DNS lookup that would
// leak the query through the system resolver and bypass any configured resolver.
type domainAddr struct {
network string
host string
port int
}
func (a *domainAddr) Network() string { return a.network }
func (a *domainAddr) String() string { return net.JoinHostPort(a.host, strconv.Itoa(a.port)) }
// ReadFrom reads an UDP datagram.
// NOTE: for server side,
// the returned addr is the target address the client want to relay to.
@@ -128,7 +147,11 @@ func (c *udpConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
}
n = copy(b, buf[hlen:n])
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
if net.ParseIP(socksAddr.Host) != nil {
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
} else {
addr = &domainAddr{network: "udp", host: socksAddr.Host, port: int(socksAddr.Port)}
}
return
}
+19
View File
@@ -114,6 +114,25 @@ func (s *Session) Keepalive(interval, timeout time.Duration, retries int) {
}
}
// Ping checks if the SSH session is alive by sending a keepalive request.
// It blocks until a response is received or the timeout expires.
// A nil error indicates the session is healthy.
func (s *Session) Ping(timeout time.Duration) error {
if timeout <= 0 {
timeout = defaultKeepaliveTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
select {
case err := <-s.ping():
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Session) ping() <-chan error {
ch := make(chan error, 1)
go func() {
+326
View File
@@ -0,0 +1,326 @@
// Package quota implements a persisted, long-term traffic-volume limiter that
// can be shared across services by name. Unlike the rate limiters in
// limiter/traffic and limiter/rate, it accumulates total bytes within a window
// [startsAt, expiresAt) and stops the referencing service(s) once a byte limit
// is reached. Enforcement is fail-open outside the window.
package quota
import (
"errors"
"sync"
"sync/atomic"
"time"
"github.com/go-gost/core/logger"
xlogger "github.com/go-gost/x/logger"
)
var ErrQuotaExceeded = errors.New("quota: traffic limit reached")
const defaultFlushInterval = 10 * time.Second
type Direction int
const (
DirectionTotal Direction = iota
DirectionIn
DirectionOut
)
func (d Direction) String() string {
switch d {
case DirectionIn:
return "in"
case DirectionOut:
return "out"
default:
return "total"
}
}
// Options seeds a Limiter. Limit and the window are config-authoritative; a
// persisted counter is restored only for a matching window (see NewLimiter).
type Options struct {
Limit uint64
StartsAt time.Time
ExpiresAt time.Time
Direction Direction
Flush time.Duration
Store Store
Logger logger.Logger
}
// Update overwrites runtime state; a nil field is left unchanged.
type Update struct {
Used *uint64
Limit *uint64
StartsAt *time.Time
ExpiresAt *time.Time
}
type Snapshot struct {
Used uint64
Limit uint64
StartsAtUnix int64
ExpiresAtUnix int64
Active bool
Expired bool
Blocked bool
Direction string
}
type Limiter struct {
name string
direction Direction
flush time.Duration
store Store
log logger.Logger
used atomic.Uint64
limit atomic.Uint64
startsAt atomic.Int64 // unixnano; 0 = unset
expiresAt atomic.Int64 // unixnano; 0 = unset
blocked atomic.Bool
dirty atomic.Bool
closed atomic.Bool
mu sync.Mutex
waitCh chan struct{} // closed+replaced to broadcast a state change
closeOnce sync.Once
done chan struct{}
}
func NewLimiter(name string, opts Options) *Limiter {
l := &Limiter{
name: name,
direction: opts.Direction,
flush: opts.Flush,
store: opts.Store,
log: opts.Logger,
waitCh: make(chan struct{}),
done: make(chan struct{}),
}
if l.flush <= 0 {
l.flush = defaultFlushInterval
}
if l.log == nil {
l.log = xlogger.Nop()
}
l.limit.Store(opts.Limit)
sa := unixNanoOrZero(opts.StartsAt)
ea := unixNanoOrZero(opts.ExpiresAt)
l.startsAt.Store(sa)
l.expiresAt.Store(ea)
// Restore the counter only within the same window: a changed window (a new
// period pushed via config) starts fresh.
if l.store != nil {
if rec, ok, err := l.store.Load(name); err != nil {
l.log.Warnf("quota: load %s: %v", name, err)
} else if ok && rec.StartsAt == sa && rec.ExpiresAt == ea {
l.used.Store(rec.Used)
}
}
l.blocked.Store(l.enforcing(time.Now()) && l.used.Load() >= l.limit.Load())
go l.run()
return l
}
func (l *Limiter) active(now time.Time) bool {
n := now.UnixNano()
if sa := l.startsAt.Load(); sa != 0 && n < sa {
return false
}
if ea := l.expiresAt.Load(); ea != 0 && n >= ea {
return false
}
return true
}
func (l *Limiter) enforcing(now time.Time) bool {
return l.limit.Load() > 0 && l.active(now)
}
func (l *Limiter) Blocked() bool {
if l.closed.Load() || !l.blocked.Load() {
return false
}
return l.enforcing(time.Now())
}
func (l *Limiter) AddIn(n int) { l.add(int64(n), DirectionIn) }
func (l *Limiter) AddOut(n int) { l.add(int64(n), DirectionOut) }
func (l *Limiter) add(n int64, dir Direction) {
if n <= 0 || l.closed.Load() {
return
}
switch l.direction {
case DirectionIn:
if dir != DirectionIn {
return
}
case DirectionOut:
if dir != DirectionOut {
return
}
}
if !l.active(time.Now()) {
return
}
used := l.used.Add(uint64(n))
l.dirty.Store(true)
if lim := l.limit.Load(); lim > 0 && used >= lim {
l.block()
}
}
// block is hot-path: no lock, no I/O, no wakeup (parking only happens on the
// next Accept).
func (l *Limiter) block() {
if l.blocked.CompareAndSwap(false, true) {
l.log.Warnf("quota: %s reached limit %d bytes, stopping", l.name, l.limit.Load())
}
}
// WaitChan must be fetched before checking Blocked to avoid a missed wakeup.
func (l *Limiter) WaitChan() <-chan struct{} {
l.mu.Lock()
defer l.mu.Unlock()
return l.waitCh
}
func (l *Limiter) notify() {
l.mu.Lock()
close(l.waitCh)
l.waitCh = make(chan struct{})
l.mu.Unlock()
}
func (l *Limiter) Update(u Update) {
if u.Used != nil {
l.used.Store(*u.Used)
}
if u.Limit != nil {
l.limit.Store(*u.Limit)
}
if u.StartsAt != nil {
l.startsAt.Store(unixNanoOrZero(*u.StartsAt))
}
if u.ExpiresAt != nil {
l.expiresAt.Store(unixNanoOrZero(*u.ExpiresAt))
}
l.reevaluate()
}
func (l *Limiter) reevaluate() {
now := time.Now()
shouldBlock := l.enforcing(now) && l.used.Load() >= l.limit.Load()
l.blocked.Store(shouldBlock)
if !shouldBlock {
l.notify()
}
l.flushNow()
}
func (l *Limiter) Snapshot() Snapshot {
now := time.Now()
ea := l.expiresAt.Load()
return Snapshot{
Used: l.used.Load(),
Limit: l.limit.Load(),
StartsAtUnix: nanoToSec(l.startsAt.Load()),
ExpiresAtUnix: nanoToSec(ea),
Active: l.active(now),
Expired: ea != 0 && now.UnixNano() >= ea,
Blocked: l.Blocked(),
Direction: l.direction.String(),
}
}
func (l *Limiter) run() {
flushT := time.NewTicker(l.flush)
defer flushT.Stop()
boundT := time.NewTicker(time.Second)
defer boundT.Stop()
wasActive := l.active(time.Now())
for {
select {
case <-l.done:
return
case <-flushT.C:
if l.dirty.Swap(false) {
l.persist()
}
case <-boundT.C:
now := time.Now()
act := l.active(now)
if act == wasActive {
continue
}
wasActive = act
if act {
l.blocked.Store(l.limit.Load() > 0 && l.used.Load() >= l.limit.Load())
} else {
l.blocked.Store(false)
}
l.notify()
l.persist()
}
}
}
func (l *Limiter) flushNow() {
l.dirty.Store(false)
l.persist()
}
func (l *Limiter) persist() {
if l.store == nil {
return
}
rec := Record{
Used: l.used.Load(),
Limit: l.limit.Load(),
StartsAt: l.startsAt.Load(),
ExpiresAt: l.expiresAt.Load(),
UpdatedAt: time.Now().UnixNano(),
}
if err := l.store.Save(l.name, rec); err != nil {
l.log.Warnf("quota: save %s: %v", l.name, err)
}
}
// Close makes the limiter inert (Blocked false, counting a no-op) so deleting a
// shared quota releases the referencing services instead of tearing them down.
// Called by the registry on Unregister.
func (l *Limiter) Close() error {
l.closeOnce.Do(func() {
l.closed.Store(true)
close(l.done)
l.flushNow()
l.notify()
})
return nil
}
func unixNanoOrZero(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UnixNano()
}
func nanoToSec(n int64) int64 {
if n == 0 {
return 0
}
return n / int64(time.Second)
}
+195
View File
@@ -0,0 +1,195 @@
package quota
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func newTestLimiter(t *testing.T, opts Options) *Limiter {
t.Helper()
l := NewLimiter("test", opts)
t.Cleanup(func() { l.Close() })
return l
}
func TestLimiterBlocksAtLimit(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100})
l.AddIn(60)
if l.Blocked() {
t.Fatalf("should not block at 60/100")
}
l.AddOut(40)
if !l.Blocked() {
t.Fatalf("should block at 100/100")
}
if s := l.Snapshot(); !s.Blocked || s.Used != 100 {
t.Fatalf("snapshot=%+v, want blocked used=100", s)
}
}
func TestLimiterUnblockOnOverwrite(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100})
l.AddIn(150)
if !l.Blocked() {
t.Fatalf("expected blocked")
}
zero := uint64(0)
l.Update(Update{Used: &zero})
if l.Blocked() {
t.Fatalf("expected unblocked after reset")
}
if got := l.Snapshot().Used; got != 0 {
t.Fatalf("used=%d, want 0", got)
}
}
func TestLimiterDirection(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100, Direction: DirectionIn})
l.AddOut(500)
if u := l.Snapshot().Used; u != 0 {
t.Fatalf("outbound counted under DirectionIn: used=%d", u)
}
l.AddIn(100)
if !l.Blocked() {
t.Fatalf("expected blocked after inbound reaches limit")
}
}
func TestLimiterWindowNotStarted(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100, StartsAt: time.Now().Add(time.Hour)})
l.AddIn(500)
s := l.Snapshot()
if s.Used != 0 {
t.Fatalf("counted before startsAt: used=%d", s.Used)
}
if s.Active {
t.Fatalf("should be inactive before startsAt")
}
if l.Blocked() {
t.Fatalf("should not block before startsAt")
}
}
func TestLimiterFailOpenAfterExpiry(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100, ExpiresAt: time.Now().Add(-time.Hour)})
l.AddIn(500)
s := l.Snapshot()
if s.Used != 0 {
t.Fatalf("counted after expiry: used=%d", s.Used)
}
if !s.Expired {
t.Fatalf("snapshot should report expired")
}
if l.Blocked() {
t.Fatalf("should be fail-open (unblocked) after expiry")
}
}
func TestLimiterPersistsToDisk(t *testing.T) {
path := filepath.Join(t.TempDir(), "quota.json")
store := NewFileStore(path)
l := NewLimiter("svc", Options{Limit: 1000, Store: store})
l.AddIn(300)
l.AddOut(200)
l.Close()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read store file: %v", err)
}
var data map[string]Record
if err := json.Unmarshal(b, &data); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if rec := data["svc"]; rec.Used != 500 {
t.Fatalf("persisted used=%d, want 500", rec.Used)
}
}
func TestLimiterRestoresFromDisk(t *testing.T) {
path := filepath.Join(t.TempDir(), "quota.json")
if err := os.WriteFile(path, []byte(`{"svc":{"used":777,"limit":1000}}`), 0o644); err != nil {
t.Fatal(err)
}
store := NewFileStore(path)
l := NewLimiter("svc", Options{Limit: 1000, Store: store})
t.Cleanup(func() { l.Close() })
if got := l.Snapshot().Used; got != 777 {
t.Fatalf("restored used=%d, want 777", got)
}
}
func TestWaitChanNotifiesOnReset(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100})
l.AddIn(200)
if !l.Blocked() {
t.Fatal("expected blocked")
}
ch := l.WaitChan()
zero := uint64(0)
l.Update(Update{Used: &zero})
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal("WaitChan not notified after reset")
}
if l.Blocked() {
t.Fatal("expected unblocked after reset")
}
}
func TestClosedLimiterIsInert(t *testing.T) {
l := NewLimiter("c", Options{Limit: 100})
l.AddIn(200)
if !l.Blocked() {
t.Fatal("expected blocked")
}
ch := l.WaitChan()
l.Close()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal("WaitChan not notified on close")
}
if l.Blocked() {
t.Fatal("closed limiter must be inert (not blocking)")
}
l.AddIn(1000)
if u := l.Snapshot().Used; u != 200 {
t.Fatalf("closed limiter counted: used=%d, want 200", u)
}
}
func TestLimiterResetsOnWindowChange(t *testing.T) {
path := filepath.Join(t.TempDir(), "quota.json")
store := NewFileStore(path)
t1 := time.Now().Add(time.Hour)
l1 := NewLimiter("svc", Options{Limit: 1000, ExpiresAt: t1, Store: store})
l1.AddIn(500)
l1.Close()
t2 := time.Now().Add(2 * time.Hour)
l2 := NewLimiter("svc", Options{Limit: 1000, ExpiresAt: t2, Store: store})
t.Cleanup(func() { l2.Close() })
if u := l2.Snapshot().Used; u != 0 {
t.Fatalf("counter not reset on window change: used=%d, want 0", u)
}
}
+16
View File
@@ -0,0 +1,16 @@
package quota
type Record struct {
Used uint64 `json:"used"`
Limit uint64 `json:"limit"`
StartsAt int64 `json:"startsAt"` // unixnano; 0 = unset
ExpiresAt int64 `json:"expiresAt"` // unixnano; 0 = unset
UpdatedAt int64 `json:"updatedAt"`
}
// Store persists per-name quota records. Implementations must be concurrency-safe.
type Store interface {
// Load returns ok=false when no record exists yet.
Load(name string) (rec Record, ok bool, err error)
Save(name string, rec Record) error
}
+64
View File
@@ -0,0 +1,64 @@
package quota
import (
"encoding/json"
"os"
"sync"
)
// fileStore persists all records into one JSON file. A single instance is shared
// per path so concurrent per-name saves merge instead of clobbering each other.
type fileStore struct {
path string
mu sync.Mutex
data map[string]Record
}
var fileStores sync.Map // path -> *fileStore
func NewFileStore(path string) Store {
if v, ok := fileStores.Load(path); ok {
return v.(*fileStore)
}
fs := &fileStore{path: path, data: make(map[string]Record)}
fs.load()
actual, _ := fileStores.LoadOrStore(path, fs)
return actual.(*fileStore)
}
func (fs *fileStore) load() {
b, err := os.ReadFile(fs.path)
if err != nil {
return
}
data := make(map[string]Record)
if json.Unmarshal(b, &data) == nil {
fs.data = data
}
}
func (fs *fileStore) Load(name string) (Record, bool, error) {
fs.mu.Lock()
defer fs.mu.Unlock()
rec, ok := fs.data[name]
return rec, ok, nil
}
func (fs *fileStore) Save(name string, rec Record) error {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.data[name] = rec
return fs.writeLocked()
}
func (fs *fileStore) writeLocked() error {
b, err := json.MarshalIndent(fs.data, "", " ")
if err != nil {
return err
}
tmp := fs.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, fs.path)
}
+31
View File
@@ -0,0 +1,31 @@
package quota
type RedisConfig struct {
Addr string
Username string
Password string
DB int
Key string
}
// redisStore is a no-op placeholder: a redis-configured quota behaves as
// in-memory only until the backend is implemented.
//
// TODO: implement using github.com/go-redis/redis/v8 (already a dependency).
// Suggested schema: HSET <Key> <name> <json(Record)> for Save, HGET for Load.
// See internal/loader/redis.go and recorder/redis.go for the patterns.
type redisStore struct {
cfg RedisConfig
}
func NewRedisStore(cfg RedisConfig) Store {
return &redisStore{cfg: cfg}
}
func (s *redisStore) Load(name string) (Record, bool, error) {
return Record{}, false, nil
}
func (s *redisStore) Save(name string, rec Record) error {
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package wrapper
import (
"context"
"net"
"syscall"
"github.com/go-gost/x/ctx"
xio "github.com/go-gost/x/internal/io"
"github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry"
)
type quotaConn struct {
net.Conn
name string
}
func WrapConn(c net.Conn, name string) net.Conn {
if c == nil || name == "" {
return c
}
return &quotaConn{Conn: c, name: name}
}
func (c *quotaConn) Read(b []byte) (n int, err error) {
lim := registry.QuotaLimiterRegistry().Get(c.name)
if lim != nil && lim.Blocked() {
return 0, quota.ErrQuotaExceeded
}
n, err = c.Conn.Read(b)
if lim != nil && n > 0 {
lim.AddIn(n)
}
return
}
func (c *quotaConn) Write(b []byte) (n int, err error) {
lim := registry.QuotaLimiterRegistry().Get(c.name)
if lim != nil && lim.Blocked() {
return 0, quota.ErrQuotaExceeded
}
n, err = c.Conn.Write(b)
if lim != nil && n > 0 {
lim.AddOut(n)
}
return
}
// Forward optional capabilities so wrapping does not hide them from handlers.
func (c *quotaConn) SyscallConn() (syscall.RawConn, error) {
if sc, ok := c.Conn.(syscall.Conn); ok {
return sc.SyscallConn()
}
return nil, xio.ErrUnsupported
}
func (c *quotaConn) CloseRead() error {
if sc, ok := c.Conn.(xio.CloseRead); ok {
return sc.CloseRead()
}
return xio.ErrUnsupported
}
func (c *quotaConn) CloseWrite() error {
if sc, ok := c.Conn.(xio.CloseWrite); ok {
return sc.CloseWrite()
}
return xio.ErrUnsupported
}
func (c *quotaConn) Context() context.Context {
if cc, ok := c.Conn.(ctx.Context); ok {
return cc.Context()
}
return nil
}
// UnwrapConn returns the underlying connection, allowing type assertions
// in handlers that iterate wrapper layers (e.g., SSH handler's unwrapConn).
func (c *quotaConn) UnwrapConn() net.Conn {
return c.Conn
}

Some files were not shown because too many files have changed in this diff Show More