Compare commits

...
97 Commits
Author SHA1 Message Date
ginuerzh db3c7a375c chore: update swagger spec 2026-07-26 17:58:12 +08:00
ginuerzh 9aab47bcd2 x/api: add cache CRUD web API endpoints 2026-07-26 17:57:11 +08:00
ginuerzh 3474501784 x: add generic cache component and HTTP response caching
Implements the cache.Cache interface with in-memory backend (TTL,
maxSize/maxBytes bounds, oldest/LRU eviction, background cleanup).
Adds httpcache layer for HTTP response policy (per-status TTL,
serve-stale, method/body filtering). Wires into config parser,
loader, registry, and all four sniffing handlers (forward/local,
forward/remote, sni, http) so HTTP responses are cached before
dialing upstream.

Bumps core dependency to v0.6.0 for the new cache.Cache interface.
2026-07-26 17:26:00 +08:00
ginuerzh b92c1b130a fix(dialer): propagate request ctx to QUIC OpenStreamSync and align keepalive/idle defaults
The issue #777 analysis was written against v3.2.4; commits 5484717 and
9fab332 already fixed the global mutex stall, the no-timeout OpenStreamSync,
and the missing IsClosed reuse check. This addresses the remaining real
problems:

- P1 (HIGH): quicDialer.Dial received the Router's 15s-timeout ctx but
  GetConn built its own context.Background()+30s, dropping request
  cancellation/deadline. Thread ctx into GetConn so OpenStreamSync honors
  the upstream deadline. Mirrored to the icmp dialer (identical structure).
- P2 (MEDIUM): listener QUIC keepalive defaulted OFF while the dialer
  defaulted ON, so a server behind NAT without an explicit keepalive=true
  could let its return-path mapping expire. Make the listener default ON
  like the dialer.
- P3 (LOW): set an explicit MaxIdleTimeout default (90s) on both dialer
  and listener so dead sessions are detected sooner; still overridable via
  maxIdleTimeout=.

Docs updated to mark the already-fixed root causes and rename the remaining
items P1-P3.

Fixes go-gost/gost#777
2026-07-26 12:51:04 +08:00
ginuerzh 431fe759dc x: matcher.bodySize defaults to DefaultMatcherBodySize (1MB) when unset
bodySize=0 now uses DefaultMatcherBodySize (1MB) instead of disabling
body reading, so BodyRegexp/BodyJSON matchers work out of the box.
MaxMatcherBodySize raised to 10MB. bodySize=-1 opt-out removed
(no real use case).
2026-07-25 21:46:48 +08:00
ginuerzh 4d15ca54af fix: rtcp listener tracks server-negotiated bind address for prefix fallback
The rtcp listener now stores the address returned by Router.Bind() separately
from the requested bind address. This allows callers to distinguish between
the requested prefix (may be rejected by the relay) and the actual server-
assigned address (used for the entrypoint URL).
2026-07-25 18:47:16 +08:00
ginuerzh 6096be44af Merge branch 'master' of github.com:go-gost/x 2026-07-25 15:58:30 +08:00
ginuerzh bf6f7713d8 chore: bump github.com/go-gost/core to v0.5.5 2026-07-25 15:55:17 +08:00
ginuerzh 6a44b6f018 fix(dialer): don't force pure-Go DNS resolver on Android 2026-07-25 15:44:21 +08:00
ginuerzh 641536de3d docs: add fail-codes design plan 2026-07-25 14:36:59 +08:00
ginuerzh edd6e5f3f2 x: add failCodes support for HTTP status-based node fail marking 2026-07-25 14:36:46 +08:00
ginuerzh 88b03e0721 x: add numeric comparison operators (gt/ge/lt/le/eq/ne) to Header/Query/BodyJSON matchers
Adds a 3-parameter form to Header(), Query(), and BodyJSON() matchers
for numeric comparisons: Header("Content-Length", "gt", "1024"). Extracts
the duplicated comparison setup and dispatch into shared compareOp and
parseCompareParams helpers. Includes tests for all six operators across
header, query, and body JSON matchers.
2026-07-25 13:43:17 +08:00
ginuerzh 7a190938aa chore: go get -u all
- tls-dissector v0.2.0 → v0.3.0
- golang.org/x/crypto v0.53.0 → v0.54.0
- golang.org/x/net v0.56.0 → v0.57.0
- golang.org/x/sys v0.46.0 → v0.47.0
- golang.org/x/text v0.38.0 → v0.40.0
- google.golang.org/grpc v1.79.3 → v1.82.1
- oTel v1.39.0 → v1.43.0
- gonum v0.16.0 → v0.17.0
2026-07-24 23:11:58 +08:00
ginuerzh bc06c7f5da x: wire MetadataFeature (relay v0.7.0) for tunnel recording mode negotiation
Tunnel connectors send record.mode via MetadataFeature in relay requests.
The handler parses MetadataFeature, stores on tunnel during bind, and
propagates to entrypoint services where recorder context applies it
(per-request Gost-Record header still takes precedence).

- connector/tunnel: send record.mode MetadataFeature in Connect/Bind
- connector/tunnel: parse record.mode from metadata config
- handler/tunnel: parse MetadataFeature from relay request features
- handler/tunnel: pass connMetadata through handleBind → pool.SetMetadata
- handler/tunnel: SetMetadata/GetMetadata on ConnectorPool + Tunnel
- handler/tunnel/entrypointsvc: apply pool metadata to recorder context
- deps: bump relay v0.6.2 → v0.7.0
- sniffer_sse.go: whitespace gofmt fixes
2026-07-24 22:19:41 +08:00
ginuerzh d0287998cf feat: record selected node name in HandlerRecorderObject.Node
Populate the existing Node field on HandlerRecorderObject after every
hop.Select() call in forwarder code paths. Previously the field was
never set, though it was already consumed by tunnel entrypoint HTTP
handler (ephttp.go:145) and tested for round-trip in recorder tests.
2026-07-24 14:20:10 +08:00
ginuerzh 8602043e8d x: add BodyJSON matcher and json: rewrite prefix
- routing: add BodyJSON(path, regexp) matcher using gjson for JSON path
  field lookup instead of regex against raw body bytes
- config: detect 'json:<path>[=regex]' prefix in Match field for
  rewriteRequestBody/rewriteResponseBody, using gjson query + sjson set
- forwarder: add shouldApply() helper for rewrite type matching; JSON
  rewrites match only application/json content type
2026-07-24 12:28:08 +08:00
ginuerzh 7952b4f55d x: add Gost-Record header for client-controlled recording level
Supports three modes via Gost-Record request header:
- (absent)/full: full recording with body (existing behavior)
- headers: metadata only, skip body/payload capture
- off: no recording at all

Header is stripped before forwarding. Per-request granularity
via HandlerRecorderObject.RecordMode field propagating through
the ro clone chain to all sub-paths (WS frames, H2, sniffing).
2026-07-17 15:39:07 +08:00
ginuerzh 7c44ba8125 hop: inline priority tracking and backup detection into Select scan
Collapse the separate anyBackupNode scan and partitionByPriority allocation
into the single candidate loop. Replace the old short-circuit + tiered logic
with four explicit stages:

  1. Single-pass candidate scan — priority stats + backup detection
  2. Priority short-circuit — top node wins when strictly higher, no backups
  3. Uniform fast path — skip sort when all priorities equal (common case)
  4. Tiered fallthrough — sort once, feed each priority tier to selector

Removes two helper functions and an extra O(n) allocation. Fixes hasBackup
to use mdutil.GetBool for consistency with BackupFilter.
2026-07-17 14:50:19 +08:00
ginuerzh 58ab312950 hop: scope selector to highest-priority tier with fallthrough
When nodes match with different priorities (more-specific vs broader
matchers), select only within the highest-priority tier first.
Round-robin / FailFilter still apply within a tier for load balancing.
If the top tier has no surviving node (all dead), fall through to the
next tier, preserving fallback semantics.
2026-07-17 14:08:09 +08:00
ginuerzh f5127f010c fix(forwarder): pass Probe config to node in parseForwarder
ForwardNodeConfig has a Probe field but parseForwarder was missing it
when converting to NodeConfig, silently dropping probe configuration
for forwarder nodes. cmd probe works regardless (no transport needed),
tcp/http probes are limited to the default tcp dialer (plain TCP).
2026-07-16 23:15:16 +08:00
ginuerzh 2edc6d045a chore: bump core to v0.5.4 (node liveness probe types) 2026-07-16 21:22:33 +08:00
ginuerzh 7b088f8f42 feat(socks5): add Tor RESOLVE/RESOLVE_PTR extension command support (gost#524)
Adds SOCKS5 handler and connector support for Tor-specific RESOLVE (0xF0)
and RESOLVE_PTR (0xF1) commands, enabling tools like torsocks and
tor-resolve to work through GOST when forwarding to a Tor SOCKS5 proxy.

- Define CmdResolve (0xF0) and CmdResolvePTR (0xF1) in x/internal/util/socks
- Add ContextWithSocks5Cmd/Socks5CmdFromContext for per-request command override
- Connector reads context command, sends it upstream, returns resolveConn
- resolveConn wraps net.Conn and exposes resolved addr via Metadata()
- Handler dispatches 0xF0/0xF1 to handleResolve/handleResolvePTR
- Gate behind enableTor metadata (tor=true), reject with CmdUnsupported by default
- 9 unit tests across ctx/, connector/, handler/ packages
2026-07-16 21:10:13 +08:00
ginuerzh f59eac64d9 probe: add cmd probe type with cross-platform shell support
- Add CmdProber to x/internal/probe/cmd.go with shellCmd() for Windows/Unix
- Add cmd probe dispatch in x/chain/node.go (early-return, no transport needed)
- Add Command field to config.ProbeConfig and parseProbeConfig mapping
- Add 5 CmdProber unit tests (success, failure, timeout, not-found, pipeline)
- Add 2 chain integration tests (cmd probe + cmd disabled)
2026-07-16 20:27:21 +08:00
ginuerzh 46fc054ad5 x: implement node-level liveness probe
- x/chain/node.go: StartNodeProbe goroutine — periodic TCP/HTTP probing
- x/internal/probe/probe.go: HTTPProber with status code validation
- x/selector/latency.go: LatencyFilter, LowestLatencyStrategy (generic)
- x/config: ProbeConfig, MaxLatency, parseProbeConfig, lowestlatency strategy
- x/hop/hop.go: Close() cascade to node.Close()
- x/chain/chain.go: Chain.Close() cascade to hop.Close()

Tests: 4 latency selector tests, 4 node probe tests, 4 HTTP prober tests,
1 hop failover integration test.
2026-07-16 20:27:21 +08:00
Denis Galeev c91a414d68 feat(metrics): add gost_service_status gauge (#116)
Expose a gost_service_status{host,state} gauge with the number of
services per state (ready/running/failed/closed), collected at scrape
time via a registered callback that aggregates over the service
registry. Lets alerting catch dead/failed services without scraping the
config API.

The callback is registered via RegisterServiceStatusFunc and wired by
the app against registry.ServiceRegistry(). The collector registers on
the custom defaultRegistry (which the metrics endpoint serves from),
not the global prometheus registry.
2026-07-16 20:26:53 +08:00
ginuerzh 84f83f6776 chore: remove obsolete utls fingerprint plan
The plan was either implemented or abandoned; the doc is no longer needed.
2026-07-13 23:17:55 +08:00
ginuerzh c16a2804e0 docs(bypass): fix inverted whitelist mode description
The whitelist comments described the opposite of actual behavior: in
whitelist mode matching addresses go through the proxy chain and others
bypass, not the other way around.
2026-07-13 23:17:32 +08:00
ginuerzh 255f9ff6ec chore: add .gitignore for build/test artifacts and local state
Excludes Go test binaries (*.test), coverage output, generated certs,
stray Node tooling, and index/IDE/agent dirs (.codegraph, .claude,
.remember) so they don't get committed.
2026-07-13 23:15:14 +08:00
ginuerzh c5c8f0903c feat(mux): make mux layer pluggable and add Yamux backend
Convert the concrete *mux.Session into a mux.Session interface backed by
smux and yamux, selected via the `mux.type` metadata key. Empty/unspecified
defaults to "smux", so existing deployments are unchanged. Yamux's stream
window is exposed via `mux.maxStreamWindow`.

Also fix TestHandler_observeStats: the retry_after_observer_error subtest
hung because stats.Events() drains (IsUpdated is Swap(false)) and no fresh
event was generated for the recovery tick's fall-through flush. Inject a real
client2 event so the recovery tick has new data to flush.

Plan: docs/plans/2026-07-13-mux-pluggable-yamux.md
2026-07-13 23:12:27 +08:00
Denis Galeev b8f7c71910 feat(redirect): add sniffing.dialOriginalDst flag
When sniffing is enabled, the redirect handler dials the sniffed hostname (HTTP Host / TLS SNI). Add an opt-in metadata flag sniffing.dialOriginalDst that dials the original transparent-redirect destination address instead, while sniffing still runs for hostname-bypass matching and recording.
2026-07-11 21:15:26 +08:00
Denis Galeev fe394a8420 fix(redirect): check bypass on dstAddr before sniffing
IP/CIDR bypass rules were silently ignored when sniffing was enabled because the sniffer's internal bypass only matches sniffed hostnames. The post-sniffing dstAddr check was unreachable since HandleHTTP/HandleTLS return early.
2026-07-11 21:01:37 +08:00
ginuerzh d12663dfe3 fix(conn-limiter): add regression test for double-close guard
Verifies the atomic.Bool CAS in serverConn.Close() releases the limiter
slot exactly once, regardless of how many times Close() is called.
2026-07-11 20:23:32 +08:00
ginuerzh 108fd6a1f5 Merge pull request #113 from iproxy-online/fix/conn-limiter-double-decrement
fix(conn-limiter): release limiter slot once on Close
2026-07-11 20:11:55 +08:00
Denis Galeev f9fb5f443d fix(conn-limiter): release limiter slot once on Close
serverConn.Close() called limiter.Allow(-1) on every invocation. Close
is routinely called more than once (deferred + explicit teardown paths),
so each extra call leaked a limiter slot, progressively under-counting
the current connection count until the climiter cap admitted more
connections than configured.

Guard the release behind an atomic.Bool CAS so the slot is returned
exactly once, regardless of how many times Close is called.
2026-07-11 13:38:37 +03:00
ginuerzh 32ebe6550c fix(utls): map curve prefs to utls-supported set, drop PQC hybrids (#112)
Go's crypto/tls (1.24+) appends post-quantum hybrid curves
(X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024) to the
default CurvePreferences. toUTLSCurveIDs blindly cast each
crypto/tls.CurveID to utls.CurveID, but the two enums diverge:
utls v1.8.x does not define the two SecP* hybrids, so the cast
produces unsupported curve IDs and the handshake aborts with
"tls: CurvePreferences includes unsupported curve".

Translate only the four curves utls understands and drop the rest;
return nil when none survive so utls falls back to the fingerprint's
defaults. This is the curve half of #887 (the unsafe cast that
dropped ServerName/InsecureSkipVerify was the first half).
2026-07-11 15:35:21 +08:00
ginuerzh 5810404829 Fix utls dialer dropping TLS config (invalid crypto/tls→utls cast)
The utls dialer built its config with
(*utls.Config)(unsafe.Pointer(d.options.TLSConfig)), but crypto/tls.Config
and utls.Config are distinct types whose field offsets diverge from
ServerName onward (ServerName: 128 vs 136, InsecureSkipVerify: 160 vs 168).
The cast read garbage for ServerName/InsecureSkipVerify, so
InsecureSkipVerify set by secure:false came back false and the handshake
failed with 'at least one of ServerName, InsecureSkipVerify or
InsecureServerNameToVerify must be specified'.

Replace the cast with toUTLSConfig, which copies fields explicitly and
converts the types that differ between the two packages (Certificate,
ClientAuth, CurveID, RenegotiationSupport, and the VerifyConnection
callback's ConnectionState).

Refs go-gost/gost#887.
2026-07-11 15:20:25 +08:00
ginuerzh a2e2810669 fix(pht): eliminate authorize round-trip, fix close notification and race
perf(pht): eliminate /authorize HTTP round-trip by self-generating CID
  Client now uses xid to generate connection IDs, removing one RTT per
  tunnel setup. Server lazily creates pipe pairs in handlePush/handlePull
  via getOrCreateConn helper, keeping backward compat for old clients
  that still call /authorize.

fix: send close notification before canceling context
  Close() was canceling c.ctx before calling write(nil). With a canceled
  context, http.Client.Do returns immediately without network I/O, so
  the close notification POST never reached the server. Swap the order.

fix: add Flush() after heartbeat write in handlePull
  w.Write() on http.ResponseWriter goes to a 2k bufio.Writer. Without a
  Flush(), the keepalive newline never reaches the wire, causing the
  client long-poll GET to time out.

fix: use LoadOrStore in createPipe to prevent concurrent push/pull race
  When push and pull HTTP requests arrive concurrently for a new CID,
  both saw !ok from Load() and both called createPipe(), overwriting
  the first conns entry. The losing pipe pair leaked and the listener
  accepted a phantom connection. LoadOrStore makes the first Store win;
  the second caller gets the existing pipe.

fix: close conn and delete entry on empty-data POST in handlePush
  A POST body of "\n" produced empty data after TrimSuffix, leaking the
  pipe pair and conns entry. Now always cleans up before returning.

fix: use caller ctx instead of context.Background() in Dial
  Dial(ctx, addr) was discarding the caller context, so deadlines and
  cancellation did not propagate to the HTTP long-poll requests.

fix: detect ListenAndServe failure in Init() and clean up goroutine
  Non-blocking select on errChan after starting the server goroutine
  surfaces immediate failures (port conflict, TLS error) instead of
  leaking a goroutine blocked on server.Accept().

chore: remove dead AuthorizePath from Client struct and dialer wiring
  Client no longer calls /authorize; the field and its metadata parsing
  in dialer/pht, dialer/http3 are dead code.

test: add unit tests for createPipe race, close notification,
  empty-data cleanup, and queue-full fallback
2026-07-06 22:03:34 +08:00
ginuerzh ca767cc102 fix(tunnel): handle unspecified IP (0.0.0.0, ::) in tunnel bind
- connector: pass correct port to JoinHostPort when host is empty
- handler: treat unspecified IPs like empty host for ingress routing
2026-07-06 15:28:32 +08:00
ginuerzh 2baf376c87 fix(forwarder): apply responseHeader after body rewrite to fix Content-Type precedence
Response header overrides were applied before rewriteRespBody, so overriding
Content-Type caused the body rewriter to see the override instead of the
original upstream value, breaking the streaming-vs-non-streaming decision.
Move responseHeader and ro.HTTP.Response capture after the body rewrite.
2026-07-05 19:57:54 +08:00
ginuerzh 19be0882db fix(forwarder): handle ? in rewriteURL replacement and empty requestHeader for deletion
fix(hop): document Select() stages with inline comments
build(deps): bump plugin v0.4.0 → v0.5.0

- rewriteURL: split replacement at '?' so query portion goes to RawQuery
  instead of being percent-encoded as %3F in Path
- requestHeader: empty value now deletes the header instead of setting empty
- hop: add stage-by-stage doc comments to Select() method
2026-07-05 14:34:21 +08:00
ginuerzh 1e6c0851e6 build(deps): bump gin, compress, crypto, net, sys, text, protobuf and other deps
Updates several direct and indirect dependencies across go.mod to latest
versions. No code changes needed.
2026-07-04 19:06:01 +08:00
ginuerzh 0ca1e7621a feat(handler): forward mTLS client cert identity to auth plugins
Extracts the verified mTLS peer certificate (CN, SANs, SHA-256
fingerprint) from the TLS connection in the HTTP handler after the
handshake completes, stores it in the request context, and forwards
it to both gRPC and HTTP auth plugins via new AuthenticateRequest
fields (client_cn, client_san, client_cert_fingerprint).
2026-07-04 17:30:36 +08:00
ginuerzh 4bc3886825 feat(forwarder): record pre-rewrite HTTP originals in recorder object
Capture client-sent Host/URI/headers/body before node HTTP rewrites are applied, so the recorder sees both the original and forwarded values. Adds a borrowBodyPrefix helper that inspects a body prefix without consuming the stream. Also serializes file recorder writes unconditionally (the underlying io.WriteCloser is not safe for concurrent Write) and drops a debug log from the bodyRegexp matcher.
2026-07-03 20:13:11 +08:00
ginuerzh 52b80b257f feat(forwarder): decode compressed body prefix for BodyRegexp matching
The forwarded body keeps its original encoding; only the prefix shown to
body matchers is decoded so BodyRegexp sees plaintext regardless of
Content-Encoding. Best-effort: a truncated compressed stream yields
whatever decoded so far, where leading JSON fields live.
2026-07-03 17:56:18 +08:00
ginuerzh 7b5a92817f feat(routing): add BodyRegexp matcher for HTTP request body matching
Lets a node match routing rules against a size-capped prefix of the
HTTP request body. The forwarder HTTP sniffer reads the prefix before
node selection when any node in the hop opts in via matcher.bodySize
(capped at MaxMatcherBodySize = 1MB), restores the stream, and passes
the prefix through hop.Select to the matcher.

- routing: BodyRegexp predicate; Body field on routing.Request
- chain/hop: plumb Body through SelectOptions
- config: matcher.bodySize on NodeFilterConfig
- sniffer_http: aggregate max body size across hop nodes, read+restore
- registry: NodeList wrapper forwards Nodes() so body-size is visible
  for named hops
- bump go-gost/core to v0.5.3
2026-07-03 17:22:10 +08:00
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
208 changed files with 11903 additions and 1439 deletions
+22
View File
@@ -0,0 +1,22 @@
# Build & test artifacts
*.test
coverage.out
coverage*.out
coverage*.tmp
# Generated local TLS cert
cert.pem
# Stray Node tooling in a Go module
package.json
package-lock.json
# Index / IDE / agent state
.codegraph/
.claude/
.remember/
.build/
__pycache__/
# Local test configs
play/
+6
View File
@@ -163,4 +163,10 @@ func Register(r *gin.Engine, opts *Options) {
config.POST("/rlimiters", createRateLimiter)
config.PUT("/rlimiters/:limiter", updateRateLimiter)
config.DELETE("/rlimiters/:limiter", deleteRateLimiter)
config.GET("/caches", getCacheList)
config.GET("/caches/:cache", getCache)
config.POST("/caches", createCache)
config.PUT("/caches/:cache", updateCache)
config.DELETE("/caches/:cache", deleteCache)
}
+273
View File
@@ -0,0 +1,273 @@
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/cache"
"github.com/go-gost/x/registry"
)
// swagger:parameters getCacheListRequest
type getCacheListRequest struct{}
// successful operation.
// swagger:response getCacheListResponse
type getCacheListResponse struct {
// in: body
Data cacheList
}
type cacheList struct {
Count int `json:"count"`
List []*config.CacheConfig `json:"list"`
}
func getCacheList(ctx *gin.Context) {
// swagger:route GET /config/caches Cache getCacheListRequest
//
// Get cache list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getCacheListResponse
var req getCacheListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Caches
ctx.JSON(http.StatusOK, Response{
Data: cacheList{
Count: len(list),
List: list,
},
})
}
// swagger:parameters getCacheRequest
type getCacheRequest struct {
// in: path
// required: true
Cache string `uri:"cache" json:"cache"`
}
// successful operation.
// swagger:response getCacheResponse
type getCacheResponse struct {
// in: body
Data *config.CacheConfig
}
func getCache(ctx *gin.Context) {
// swagger:route GET /config/caches/{cache} Cache getCacheRequest
//
// Get cache.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getCacheResponse
var req getCacheRequest
ctx.ShouldBindUri(&req)
var resp getCacheResponse
for _, c := range config.Global().Caches {
if c == nil {
continue
}
if c.Name == req.Cache {
resp.Data = c
break
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createCacheRequest
type createCacheRequest struct {
// in: body
Data config.CacheConfig `json:"data"`
}
// successful operation.
// swagger:response createCacheResponse
type createCacheResponse struct {
Data Response
}
func createCache(ctx *gin.Context) {
// swagger:route POST /config/caches Cache createCacheRequest
//
// Create a new cache, the name of the cache must be unique in cache list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: createCacheResponse
var req createCacheRequest
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Data.Name)
if name == "" {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache name is required"))
return
}
req.Data.Name = name
if registry.CacheRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
return
}
v := parser.ParseCache(&req.Data)
if v == nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
return
}
if err := registry.CacheRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
c.Caches = append(c.Caches, &req.Data)
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters updateCacheRequest
type updateCacheRequest struct {
// in: path
// required: true
Cache string `uri:"cache" json:"cache"`
// in: body
Data config.CacheConfig `json:"data"`
}
// successful operation.
// swagger:response updateCacheResponse
type updateCacheResponse struct {
Data Response
}
func updateCache(ctx *gin.Context) {
// swagger:route PUT /config/caches/{cache} Cache updateCacheRequest
//
// Update cache by name, the cache must already exist.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: updateCacheResponse
var req updateCacheRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Cache)
if !registry.CacheRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
return
}
req.Data.Name = name
v := parser.ParseCache(&req.Data)
if v == nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
return
}
registry.CacheRegistry().Unregister(name)
if err := registry.CacheRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
for i := range c.Caches {
if c.Caches[i].Name == name {
c.Caches[i] = &req.Data
break
}
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters deleteCacheRequest
type deleteCacheRequest struct {
// in: path
// required: true
Cache string `uri:"cache" json:"cache"`
}
// successful operation.
// swagger:response deleteCacheResponse
type deleteCacheResponse struct {
Data Response
}
func deleteCache(ctx *gin.Context) {
// swagger:route DELETE /config/caches/{cache} Cache deleteCacheRequest
//
// Delete cache by name.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: deleteCacheResponse
var req deleteCacheRequest
ctx.ShouldBindUri(&req)
name := strings.TrimSpace(req.Cache)
if !registry.CacheRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
return
}
registry.CacheRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
caches := c.Caches
c.Caches = nil
for _, s := range caches {
if s.Name == name {
continue
}
c.Caches = append(c.Caches, s)
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
+633 -437
View File
File diff suppressed because it is too large Load Diff
+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)
}
}
+12 -7
View File
@@ -60,13 +60,18 @@ func (p *grpcPlugin) Authenticate(ctx context.Context, user, password string, op
if v := xctx.SrcAddrFromContext(ctx); v != nil {
clientAddr = v.String()
}
r, err := p.client.Authenticate(ctx,
&proto.AuthenticateRequest{
Service: options.Service,
Username: user,
Password: password,
Client: clientAddr,
})
req := &proto.AuthenticateRequest{
Service: options.Service,
Username: user,
Password: password,
Client: clientAddr,
}
if v := xctx.PeerCertFromContext(ctx); v != nil {
req.ClientCn = v.CN
req.ClientSan = v.SANs
req.ClientCertFingerprint = v.Fingerprint
}
r, err := p.client.Authenticate(ctx, req)
if err != nil {
p.log.Error(err)
return "", false
+12 -4
View File
@@ -14,10 +14,13 @@ import (
)
type httpPluginRequest struct {
Service string `json:"service"`
Username string `json:"username"`
Password string `json:"password"`
Client string `json:"client"`
Service string `json:"service"`
Username string `json:"username"`
Password string `json:"password"`
Client string `json:"client"`
ClientCn string `json:"clientCn,omitempty"`
ClientSan []string `json:"clientSan,omitempty"`
ClientCertFingerprint string `json:"clientCertFingerprint,omitempty"`
}
type httpPluginResponse struct {
@@ -71,6 +74,11 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
Password: password,
Client: clientAddr,
}
if v := xctx.PeerCertFromContext(ctx); v != nil {
rb.ClientCn = v.CN
rb.ClientSan = v.SANs
rb.ClientCertFingerprint = v.Fingerprint
}
v, err := json.Marshal(&rb)
if err != nil {
return
+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
}
+6 -6
View File
@@ -12,8 +12,8 @@
// Matching modes:
// - Blacklist (whitelist=false): matching addresses bypass the proxy.
// This is the default.
// - Whitelist (whitelist=true): only matching addresses bypass the proxy;
// all other addresses go through the proxy chain.
// - Whitelist (whitelist=true): matching addresses go through the proxy
// chain; all other addresses bypass the proxy.
package bypass
import (
@@ -80,8 +80,8 @@ type options struct {
type Option func(opts *options)
// WhitelistOption sets whether the bypass operates in whitelist mode.
// In whitelist mode, only matching addresses bypass the proxy;
// all others go through the proxy chain.
// In whitelist mode, matching addresses go through the proxy chain;
// all others bypass the proxy.
func WhitelistOption(whitelist bool) Option {
return func(opts *options) {
opts.whitelist = whitelist
@@ -214,8 +214,8 @@ type localBypass struct {
// NewBypass creates and initializes a local Bypass instance.
//
// In blacklist mode (the default), addresses matching any pattern bypass the
// proxy. In whitelist mode (set via WhitelistOption(true)), only matching
// addresses bypass the proxy and all others go through the chain.
// proxy. In whitelist mode (set via WhitelistOption(true)), matching addresses
// go through the proxy chain and all others bypass the proxy.
//
// If a reload period is configured, patterns from external loaders are
// refreshed automatically in the background.
+241
View File
@@ -0,0 +1,241 @@
// Package cache provides in-memory implementations of the core cache.Cache
// interface.
package cache
import (
"context"
"sync"
"time"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/logger"
)
// EvictionPolicy selects which entry to remove when the cache is full.
type EvictionPolicy string
const (
// EvictOldest removes the entry inserted least recently (by insertion time).
EvictOldest EvictionPolicy = "oldest"
// EvictLRU removes the entry accessed least recently.
EvictLRU EvictionPolicy = "lru"
)
// Options configures a memory cache.
type Options struct {
// DefaultTTL is applied to entries stored without an explicit TTL.
// A non-positive value means entries never expire by default.
DefaultTTL time.Duration
// MaxSize bounds the number of entries. 0 means unbounded.
MaxSize int
// MaxBytes bounds the total size of stored data in bytes. 0 means unbounded.
MaxBytes int64
// CleanupInterval sets the period for the background expiry sweep.
// A non-positive value disables the background goroutine.
CleanupInterval time.Duration
// Eviction is the policy used when MaxSize or MaxBytes is exceeded.
// Defaults to EvictOldest.
Eviction EvictionPolicy
// Logger is used for debug logging. Optional.
Logger logger.Logger
}
type entry struct {
data []byte
expiration time.Time // zero = never expires
created time.Time
lastAccess time.Time
}
// memoryCache is a concurrency-safe in-memory cache.Cache implementation.
type memoryCache struct {
mu sync.RWMutex
entries map[string]*entry
currentBytes int64
defaultTTL time.Duration
maxSize int
maxBytes int64
eviction EvictionPolicy
logger logger.Logger
stopCleanup chan struct{}
stopOnce sync.Once
}
// NewMemoryCache creates an in-memory cache. If opts.CleanupInterval is
// positive, a background goroutine periodically removes expired entries; it is
// stopped by Close.
func NewMemoryCache(opts Options) cache.Cache {
eviction := opts.Eviction
if eviction != EvictLRU {
eviction = EvictOldest
}
c := &memoryCache{
entries: make(map[string]*entry),
defaultTTL: opts.DefaultTTL,
maxSize: opts.MaxSize,
maxBytes: opts.MaxBytes,
eviction: eviction,
logger: opts.Logger,
stopCleanup: make(chan struct{}),
}
if opts.CleanupInterval > 0 {
go c.cleanupLoop(opts.CleanupInterval)
}
return c
}
// Get returns the entry for key. An expired-but-present entry is returned with
// cache.ErrNotFound reserved for absent keys, so callers can serve stale.
func (c *memoryCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
c.mu.Lock()
e, ok := c.entries[key]
if ok {
e.lastAccess = time.Now()
}
c.mu.Unlock()
if !ok {
return nil, cache.ErrNotFound
}
// Return a copy so callers can't mutate the stored buffer.
data := make([]byte, len(e.data))
copy(data, e.data)
return &cache.Entry{Data: data, Expiration: e.expiration}, nil
}
// Set stores data under key. A non-positive TTL uses the cache default; a
// non-positive default means the entry never expires.
func (c *memoryCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
var so cache.SetOptions
for _, opt := range opts {
opt(&so)
}
ttl := so.TTL
if ttl <= 0 {
ttl = c.defaultTTL
}
var expiration time.Time
if ttl > 0 {
expiration = time.Now().Add(ttl)
}
cp := make([]byte, len(data))
copy(cp, data)
now := time.Now()
e := &entry{
data: cp,
expiration: expiration,
created: now,
lastAccess: now,
}
c.mu.Lock()
defer c.mu.Unlock()
// Replace: account for the size delta of an existing key.
if old, ok := c.entries[key]; ok {
c.currentBytes -= int64(len(old.data))
}
c.entries[key] = e
c.currentBytes += int64(len(cp))
c.evictLocked()
return nil
}
// Delete removes key from the cache.
func (c *memoryCache) Delete(ctx context.Context, key string) error {
c.mu.Lock()
if e, ok := c.entries[key]; ok {
c.currentBytes -= int64(len(e.data))
delete(c.entries, key)
}
c.mu.Unlock()
return nil
}
// Close stops the background cleanup goroutine. Safe to call more than once.
// Implements io.Closer so the registry stops the goroutine on hot reload.
func (c *memoryCache) Close() error {
c.stopOnce.Do(func() { close(c.stopCleanup) })
return nil
}
func (c *memoryCache) cleanupLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.stopCleanup:
return
case <-ticker.C:
c.mu.Lock()
now := time.Now()
for k, e := range c.entries {
if !e.expiration.IsZero() && now.After(e.expiration) {
c.currentBytes -= int64(len(e.data))
delete(c.entries, k)
}
}
c.mu.Unlock()
}
}
}
// evictLocked enforces the size/byte bounds. It first drops expired entries,
// then evicts by policy until within bounds. Caller must hold c.mu.
func (c *memoryCache) evictLocked() {
if !c.overCapacityLocked() {
return
}
// First pass: drop expired entries — they're free wins.
now := time.Now()
for k, e := range c.entries {
if !e.expiration.IsZero() && now.After(e.expiration) {
c.currentBytes -= int64(len(e.data))
delete(c.entries, k)
}
}
// Second pass: evict by policy until within bounds.
for c.overCapacityLocked() && len(c.entries) > 0 {
var victim string
var victimTS time.Time
first := true
for k, e := range c.entries {
ts := e.created
if c.eviction == EvictLRU {
ts = e.lastAccess
}
if first || ts.Before(victimTS) {
victim = k
victimTS = ts
first = false
}
}
if e, ok := c.entries[victim]; ok {
c.currentBytes -= int64(len(e.data))
delete(c.entries, victim)
}
}
}
func (c *memoryCache) overCapacityLocked() bool {
if c.maxSize > 0 && len(c.entries) > c.maxSize {
return true
}
if c.maxBytes > 0 && c.currentBytes > c.maxBytes {
return true
}
return false
}
+205
View File
@@ -0,0 +1,205 @@
package cache
import (
"context"
"io"
"testing"
"time"
"github.com/go-gost/core/cache"
)
func TestSetGet(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
ctx := context.Background()
err := c.Set(ctx, "k", []byte("hello"))
if err != nil {
t.Fatalf("Set: %v", err)
}
e, err := c.Get(ctx, "k")
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(e.Data) != "hello" {
t.Fatalf("data = %q, want %q", e.Data, "hello")
}
if e.Expired() {
t.Fatal("fresh entry reported expired")
}
if e.Expiration.IsZero() {
t.Fatal("expiration is zero")
}
}
func TestGetNotFound(t *testing.T) {
c := NewMemoryCache(Options{})
_, err := c.Get(context.Background(), "nosuch")
if err != cache.ErrNotFound {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestGetExpiredStillReturned(t *testing.T) {
c := NewMemoryCache(Options{})
ctx := context.Background()
if err := c.Set(ctx, "k", []byte("data"), cache.WithTTL(1*time.Nanosecond)); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
e, err := c.Get(ctx, "k")
if err != nil {
t.Fatal("expired entry should still be returned for serve-stale")
}
if !e.Expired() {
t.Fatal("expected expired")
}
}
func TestWithTTLOverride(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
ctx := context.Background()
if err := c.Set(ctx, "k", []byte("x"), cache.WithTTL(50*time.Millisecond)); err != nil {
t.Fatal(err)
}
time.Sleep(60 * time.Millisecond)
e, _ := c.Get(ctx, "k")
if !e.Expired() {
t.Fatal("WithTTL should override default")
}
}
func TestDelete(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
ctx := context.Background()
c.Set(ctx, "k", []byte("x"))
if err := c.Delete(ctx, "k"); err != nil {
t.Fatal(err)
}
if _, err := c.Get(ctx, "k"); err != cache.ErrNotFound {
t.Fatalf("err = %v, want ErrNotFound after delete", err)
}
}
func TestDeleteAbsentKey(t *testing.T) {
c := NewMemoryCache(Options{})
if err := c.Delete(context.Background(), "nosuch"); err != nil {
t.Fatalf("Delete absent key: %v", err)
}
}
func TestNeverExpires(t *testing.T) {
c := NewMemoryCache(Options{}) // no default TTL
ctx := context.Background()
c.Set(ctx, "k", []byte("data"))
time.Sleep(10 * time.Millisecond)
e, _ := c.Get(ctx, "k")
if e.Expired() {
t.Fatal("entry with no TTL should never expire")
}
if e.TTL() != -1 {
t.Fatalf("TTL = %v, want -1", e.TTL())
}
}
func TestMaxSizeEviction(t *testing.T) {
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictOldest})
ctx := context.Background()
c.Set(ctx, "a", []byte("1"))
c.Set(ctx, "b", []byte("2"))
c.Set(ctx, "c", []byte("3")) // evicts oldest (a)
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
t.Fatal("a should have been evicted")
}
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
t.Fatal("b should remain")
}
if _, err := c.Get(ctx, "c"); err == cache.ErrNotFound {
t.Fatal("c should remain")
}
}
func TestMaxSizeLRU(t *testing.T) {
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictLRU})
ctx := context.Background()
c.Set(ctx, "a", []byte("1"))
c.Set(ctx, "b", []byte("2"))
c.Get(ctx, "a") // touch a
c.Set(ctx, "c", []byte("3")) // LRU-evicts b (a's lastAccess is newer)
if _, err := c.Get(ctx, "b"); err != cache.ErrNotFound {
t.Fatal("b should have been evicted (LRU)")
}
if _, err := c.Get(ctx, "a"); err == cache.ErrNotFound {
t.Fatal("a should remain (touched)")
}
}
func TestMaxBytesEviction(t *testing.T) {
c := NewMemoryCache(Options{MaxBytes: 4})
ctx := context.Background()
c.Set(ctx, "a", []byte{1, 2}) // 2 bytes, total=2
c.Set(ctx, "b", []byte{3, 4}) // 2 bytes, total=4
c.Set(ctx, "c", []byte{5}) // 1 byte, total=5 > 4 → evicts oldest (a)
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
t.Fatal("a should have been evicted (maxBytes)")
}
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
t.Fatal("b should remain")
}
}
func TestClose(t *testing.T) {
c := NewMemoryCache(Options{CleanupInterval: 10 * time.Millisecond})
closer := c.(io.Closer)
if err := closer.Close(); err != nil {
t.Fatal(err)
}
if err := closer.Close(); err != nil {
t.Fatal("second Close should be a no-op")
}
}
func TestCloseStopsBackgroundScan(t *testing.T) {
c := NewMemoryCache(Options{
DefaultTTL: 5 * time.Millisecond,
CleanupInterval: 10 * time.Millisecond,
})
ctx := context.Background()
c.Set(ctx, "k", []byte("data"))
time.Sleep(20 * time.Millisecond)
e, _ := c.Get(ctx, "k")
// Without Close, background scanner cleared expired entries. With Close
// before sleep, the entry stays. We test that Close means the goroutine
// stops and does NOT panic another background prune. Just exercising the
// code path.
if e.Expired() {
t.Log("expired (expected if scanner ran)")
}
c.(io.Closer).Close()
time.Sleep(30 * time.Millisecond) // goroutine should have stopped
// No panic = pass
}
func TestDataCopied(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
ctx := context.Background()
data := []byte("mutable")
c.Set(ctx, "k", data)
data[0] = 'X'
e, _ := c.Get(ctx, "k")
if string(e.Data) != "mutable" {
t.Fatal("Get returns internal data, not a copy")
}
// Mutate Get's returned slice — original should be unchanged
e.Data[0] = 'Y'
e2, _ := c.Get(ctx, "k")
if string(e2.Data) != "mutable" {
t.Fatal("Get returns shared slice")
}
}
+12
View File
@@ -30,6 +30,7 @@ package chain
import (
"context"
"io"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
@@ -111,6 +112,17 @@ func (c *Chain) Name() string {
return c.name
}
// Close stops all probe goroutines by cascading to each hop's Close,
// which in turn cascades to node.Close() for every node.
func (c *Chain) Close() error {
for _, h := range c.hops {
if closer, ok := h.(io.Closer); ok {
closer.Close()
}
}
return nil
}
// Route builds a route by selecting one node from each hop. If a node
// supports multiplexing, the route is split — nodes before it form a
// sub-route that is copied into the transport for reuse.
+121
View File
@@ -0,0 +1,121 @@
package chain
import (
"context"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/internal/probe"
)
// StartNodeProbe starts a background goroutine that periodically probes the
// node's transport. On success the node's marker is reset; on failure the
// marker is incremented. cfg may be nil (no-op).
func StartNodeProbe(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
if cfg == nil {
return
}
if cfg.Addr == "" && cfg.Type != chain.ProbeTypeCmd {
return
}
if cfg.Type == chain.ProbeTypeCmd && cfg.Command == "" {
return
}
ctx, cancel := context.WithCancel(context.Background())
node.SetProbeCancel(cancel)
go runNodeProbe(ctx, node, cfg, log)
}
func runNodeProbe(ctx context.Context, node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
interval := cfg.Interval
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
probeNode(node, cfg, log) // first probe immediately
for {
select {
case <-ticker.C:
probeNode(node, cfg, log)
case <-ctx.Done():
return
}
}
}
func probeNode(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
// cmd probe runs a shell command directly — no transport connection needed.
if cfg.Type == chain.ProbeTypeCmd {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
start := time.Now()
err := (&probe.CmdProber{Command: cfg.Command, Timeout: timeout}).Probe()
latency := time.Since(start)
if err != nil {
recordProbe(node, false, latency, err.Error(), log)
} else {
recordProbe(node, true, latency, "", log)
}
return
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
start := time.Now()
tr := node.Options().Transport
conn, err := tr.Dial(ctx, node.Addr)
if err != nil {
recordProbe(node, false, time.Since(start), err.Error(), log)
return
}
if hc, err2 := tr.Handshake(ctx, conn); err2 == nil {
conn = hc
} else {
conn.Close()
recordProbe(node, false, time.Since(start), err2.Error(), log)
return
}
latency := time.Since(start)
if cfg.Type == chain.ProbeTypeHTTP {
err = probe.NewHTTPProber(cfg).Probe(conn)
conn.Close()
if err != nil {
recordProbe(node, false, latency, err.Error(), log)
return
}
} else {
conn.Close()
}
recordProbe(node, true, latency, "", log)
}
func recordProbe(node *chain.Node, success bool, latency time.Duration, errStr string, log logger.Logger) {
result := &chain.ProbeResult{
Success: success,
Latency: latency,
Error: errStr,
Timestamp: time.Now(),
}
node.SetProbeResult(result)
if success {
node.Marker().Reset()
} else {
node.Marker().Mark()
}
log.Debugf("node probe %s: success=%v latency=%v err=%s", node.Name, success, latency, errStr)
}
+292
View File
@@ -0,0 +1,292 @@
package chain
import (
"context"
"errors"
"net"
"sync/atomic"
"testing"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
xlogger "github.com/go-gost/x/logger"
)
// testTransport implements chain.Transporter with real TCP dials.
type testTransport struct {
dialer *net.Dialer
options chain.TransportOptions
}
func (t *testTransport) Dial(ctx context.Context, addr string) (net.Conn, error) {
return t.dialer.DialContext(ctx, "tcp", addr)
}
func (t *testTransport) Handshake(ctx context.Context, conn net.Conn) (net.Conn, error) {
return conn, nil
}
func (t *testTransport) Connect(ctx context.Context, conn net.Conn, network, address string) (net.Conn, error) {
return nil, errors.New("not implemented")
}
func (t *testTransport) Bind(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) {
return nil, errors.New("not implemented")
}
func (t *testTransport) Multiplex() bool { return false }
func (t *testTransport) Options() *chain.TransportOptions { return &t.options }
func (t *testTransport) Copy() chain.Transporter { return t }
func TestNodeProbeTCP(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
// accept one connection, then close immediately (TCP probe only needs dial+handshake)
go func() {
for {
conn, _ := l.Accept()
if conn != nil {
conn.Close()
}
}
}()
addr := l.Addr().String()
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeTCP,
Addr: addr,
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
StartNodeProbe(node, cfg, xlogger.Nop())
// Wait for first probe
time.Sleep(300 * time.Millisecond)
pr := node.ProbeResult()
if pr == nil || !pr.Success {
t.Fatalf("expected successful probe, got %+v", pr)
}
// Close listener → next probe fails
l.Close()
time.Sleep(300 * time.Millisecond)
pr = node.ProbeResult()
if pr == nil || pr.Success {
t.Fatalf("expected failed probe after listener close, got %+v", pr)
}
if node.Marker().Count() == 0 {
t.Error("expected marker count > 0 after failure")
}
node.Close()
}
func TestNodeProbeClose(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go func() {
for {
conn, _ := l.Accept()
if conn != nil {
conn.Close()
}
}
}()
addr := l.Addr().String()
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeTCP,
Addr: addr,
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
StartNodeProbe(node, cfg, xlogger.Nop())
time.Sleep(150 * time.Millisecond)
// Snapshot result before close
lastSuccess := node.ProbeResult().Success
if !lastSuccess {
t.Fatal("expected initial success before close")
}
node.Close()
// Capture marker count right after close
countAfterClose := node.Marker().Count()
// Wait longer than probe interval — no more updates after close
time.Sleep(300 * time.Millisecond)
// Marker count should not increase after close (no more probes running)
if node.Marker().Count() != countAfterClose {
t.Errorf("marker changed after close: was %d, now %d", countAfterClose, node.Marker().Count())
}
}
func TestNodeProbeDisabled(t *testing.T) {
node := chain.NewNode("no-probe", "addr")
// nil config → no-op
StartNodeProbe(node, nil, xlogger.Nop())
if pr := node.ProbeResult(); pr != nil {
t.Error("expected nil probe result for disabled probe")
}
// empty addr → no-op
StartNodeProbe(node, &chain.ProbeConfig{}, xlogger.Nop())
if pr := node.ProbeResult(); pr != nil {
t.Error("expected nil probe result for empty addr")
}
}
func TestNodeProbeCmd(t *testing.T) {
// Cmd probe doesn't need a transport — StartNodeProbe skips the addr check.
node := chain.NewNode("cmd-test", "")
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "true",
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
StartNodeProbe(node, cfg, xlogger.Nop())
time.Sleep(300 * time.Millisecond)
pr := node.ProbeResult()
if pr == nil || !pr.Success {
t.Fatalf("expected cmd probe success, got %+v", pr)
}
if node.Marker().Count() != 0 {
t.Errorf("expected marker count 0 after success, got %d", node.Marker().Count())
}
// Switch to failing command
cfg2 := &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "false",
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
node.Close() // stop old probe
node2 := chain.NewNode("cmd-test-2", "")
StartNodeProbe(node2, cfg2, xlogger.Nop())
time.Sleep(300 * time.Millisecond)
pr2 := node2.ProbeResult()
if pr2 == nil || pr2.Success {
t.Fatalf("expected cmd probe failure for exit 1, got %+v", pr2)
}
if node2.Marker().Count() == 0 {
t.Error("expected marker count > 0 after cmd failure")
}
node2.Close()
}
func TestNodeProbeCmdDisabled(t *testing.T) {
// cmd probe without command → no-op
node := chain.NewNode("cmd-disabled", "")
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "",
}
StartNodeProbe(node, cfg, xlogger.Nop())
if pr := node.ProbeResult(); pr != nil {
t.Error("expected nil probe result for empty command")
}
}
func TestNodeProbeHTTP(t *testing.T) {
// Start a small HTTP server
mux := &httpTestMux{}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go mux.serve(conn)
}
}()
addr := l.Addr().String()
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
node := chain.NewNode("test-http", addr, chain.TransportNodeOption(tr))
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeHTTP,
Addr: addr,
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
HTTPPath: "/health",
ExpectedStatus: 200,
}
// Case 1: server returns 200
mux.status.Store(200)
StartNodeProbe(node, cfg, xlogger.Nop())
time.Sleep(300 * time.Millisecond)
pr := node.ProbeResult()
if pr == nil || !pr.Success {
t.Fatalf("expected HTTP 200 probe success, got %+v", pr)
}
// Case 2: server returns 500
mux.status.Store(500)
time.Sleep(300 * time.Millisecond)
pr = node.ProbeResult()
if pr == nil || pr.Success {
t.Fatalf("expected HTTP 500 probe failure, got %+v", pr)
}
node.Close()
}
// httpTestMux is a minimal HTTP server that returns a configurable status.
type httpTestMux struct {
status atomic.Int32
}
func (m *httpTestMux) serve(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 1024)
n, _ := conn.Read(buf)
// Parse just enough to construct a response
body := "HTTP/1.1 000 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
switch m.status.Load() {
case 200:
body = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
case 500:
body = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
}
_ = n
conn.Write([]byte(body))
}
+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{
+61
View File
@@ -157,6 +157,7 @@ type SelectorConfig struct {
Strategy string `json:"strategy"`
MaxFails int `yaml:"maxFails" json:"maxFails"`
FailTimeout time.Duration `yaml:"failTimeout" json:"failTimeout"`
MaxLatency time.Duration `yaml:"maxLatency,omitempty" json:"maxLatency,omitempty"`
}
type AdmissionConfig struct {
@@ -316,6 +317,27 @@ 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"`
}
// CacheConfig defines a named cache store. Only the Memory backend is
// currently supported; other backends (Redis, file, plugin) are follow-ups.
type CacheConfig struct {
Name string `json:"name"`
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
}
// MemoryCache configures the in-memory cache backend.
type MemoryCache struct {
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
MaxSize int `yaml:"maxSize,omitempty" json:"maxSize,omitempty"`
MaxBytes int64 `yaml:"maxBytes,omitempty" json:"maxBytes,omitempty"`
CleanupInterval time.Duration `yaml:"cleanupInterval,omitempty" json:"cleanupInterval,omitempty"`
Eviction string `yaml:",omitempty" json:"eviction,omitempty"` // "oldest" | "lru"
}
type LimiterConfig struct {
Name string `json:"name"`
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
@@ -384,6 +406,7 @@ type ForwardNodeConfig struct {
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
@@ -397,6 +420,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 {
@@ -405,9 +431,36 @@ 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"`
// BodySize is the max request body prefix (bytes) exposed to BodyRegexp
// and BodyJSON matchers. 0 (default) uses DefaultMatcherBodySize (1MB).
// Capped at MaxMatcherBodySize (10MB). Only takes effect under an HTTP
// sniffing handler whose sniffer reads the body prefix before node selection.
BodySize int `yaml:",omitempty" json:"bodySize,omitempty"`
}
// ProbeConfig holds the configuration for a node-level liveness probe.
type ProbeConfig struct {
Type string `json:"type"`
Addr string `json:"addr,omitempty"`
Interval time.Duration `json:"interval"`
Timeout time.Duration `json:"timeout"`
HTTPPath string `yaml:"httpPath,omitempty" json:"httpPath,omitempty"`
HTTPHost string `yaml:"httpHost,omitempty" json:"httpHost,omitempty"`
HTTPHeaders map[string]string `yaml:"httpHeaders,omitempty" json:"httpHeaders,omitempty"`
ExpectedStatus int `yaml:"expectedStatus,omitempty" json:"expectedStatus,omitempty"`
Command string `yaml:"command,omitempty" json:"command,omitempty"`
}
type HTTPNodeConfig struct {
@@ -430,6 +483,9 @@ type HTTPNodeConfig struct {
// rewrite response body
RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"`
// comma-separated response status codes marking the node failed, e.g. "429,5xx"
FailCodes string `yaml:"failCodes,omitempty" json:"failCodes,omitempty"`
// HTTP basic auth
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
}
@@ -478,7 +534,9 @@ type ServiceConfig struct {
Logger string `yaml:",omitempty" json:"logger,omitempty"`
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
Observer string `yaml:",omitempty" json:"observer,omitempty"`
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
Cache string `yaml:",omitempty" json:"cache,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
@@ -612,6 +670,7 @@ type NodeConfig struct {
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
@@ -628,6 +687,8 @@ 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"`
Caches []*CacheConfig `yaml:",omitempty" json:"caches,omitempty"`
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
+24
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-gost/core/admission"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/hosts"
@@ -22,12 +23,14 @@ 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"
admission_parser "github.com/go-gost/x/config/parsing/admission"
auth_parser "github.com/go-gost/x/config/parsing/auth"
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
cache_parser "github.com/go-gost/x/config/parsing/cache"
chain_parser "github.com/go-gost/x/config/parsing/chain"
hop_parser "github.com/go-gost/x/config/parsing/hop"
hosts_parser "github.com/go-gost/x/config/parsing/hosts"
@@ -38,6 +41,7 @@ import (
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"
@@ -244,6 +248,26 @@ 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[cache.Cache]
for _, c := range cfg.Caches {
entries = append(entries, named[cache.Cache]{c.Name, cache_parser.ParseCache(c)})
}
if err := registerGroup(entries, registry.CacheRegistry()); err != nil {
return err
}
}
{
var entries []named[traffic.TrafficLimiter]
for _, c := range cfg.Limiters {
+32
View File
@@ -0,0 +1,32 @@
package cache
import (
"strings"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/logger"
xcache "github.com/go-gost/x/cache"
"github.com/go-gost/x/config"
)
// ParseCache converts a CacheConfig into a cache.Cache. Only the memory
// backend is currently supported. Returns nil when cfg is nil or no backend
// is configured.
func ParseCache(cfg *config.CacheConfig) cache.Cache {
if cfg == nil {
return nil
}
if cfg.Memory != nil {
return xcache.NewMemoryCache(xcache.Options{
DefaultTTL: cfg.Memory.TTL,
MaxSize: cfg.Memory.MaxSize,
MaxBytes: cfg.Memory.MaxBytes,
CleanupInterval: cfg.Memory.CleanupInterval,
Eviction: xcache.EvictionPolicy(strings.ToLower(cfg.Memory.Eviction)),
Logger: logger.Default(),
})
}
return nil
}
+143 -27
View File
@@ -4,7 +4,9 @@ import (
"fmt"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
@@ -24,11 +26,67 @@ import (
"github.com/go-gost/x/routing"
)
// DefaultMatcherBodySize is the default request body prefix (in bytes) exposed
// to body matchers when a node has BodyRegexp/BodyJSON matchers but no
// explicit bodySize.
const DefaultMatcherBodySize = 1 << 20 // 1MB
// MaxMatcherBodySize is the hard cap for matcher body prefix. Values above
// this are silently clamped. Protects against unbounded in-memory buffering
// when a node opts in to body matching.
const MaxMatcherBodySize = 10 << 20 // 10MB
// ParseNode converts a NodeConfig into a *chain.Node. It resolves the
// connector and dialer from their registries, applies TLS settings, extracts
// 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 {
var pattern *regexp.Regexp
var rewriteType string
if js, ok := strings.CutPrefix(v.Match, "json:"); ok {
// json:<path> or json:<path>=<value-regex>
path, valRegex, _ := strings.Cut(js, "=")
if valRegex == "" {
valRegex = ".*"
}
var err error
pattern, err = regexp.Compile(valRegex)
if err != nil {
log.Warnf("invalid JSON value regex %q for path %q: %v", valRegex, path, err)
continue
}
rewriteType = "json:" + path
} else {
if v.Match != "" {
pattern, _ = regexp.Compile(v.Match)
}
rewriteType = v.Type
}
rw := chain.HTTPBodyRewriteSettings{
Type: rewriteType,
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 +234,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)
}
@@ -186,6 +248,14 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
}
}
bodySize := cfg.Matcher.BodySize
if bodySize <= 0 {
bodySize = DefaultMatcherBodySize
} else if bodySize > MaxMatcherBodySize {
bodySize = MaxMatcherBodySize
}
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
opts = append(opts, chain.PriorityNodeOption(priority))
}
@@ -222,33 +292,14 @@ 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)...)
if v := strings.TrimSpace(cfg.HTTP.FailCodes); v != "" {
settings.FailCodes = parseFailCodes(v, nodeLogger)
}
opts = append(opts, chain.HTTPNodeOption(settings))
}
@@ -265,5 +316,70 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
}
opts = append(opts, chain.TLSNodeOption(tlsCfg))
}
return chain.NewNode(cfg.Name, cfg.Addr, opts...), nil
node := chain.NewNode(cfg.Name, cfg.Addr, opts...)
if cfg.Probe != nil {
if pc := parseProbeConfig(cfg.Probe); pc != nil {
xchain.StartNodeProbe(node, pc, nodeLogger)
}
}
return node, nil
}
// parseProbeConfig converts a config.ProbeConfig into a chain.ProbeConfig.
// Returns nil when the config is invalid (e.g. empty addr).
func parseProbeConfig(cfg *config.ProbeConfig) *chain.ProbeConfig {
if cfg == nil || (cfg.Addr == "" && cfg.Type != "cmd") {
return nil
}
if cfg.Type == "cmd" && cfg.Command == "" {
return nil
}
pt := chain.ProbeTypeTCP
switch cfg.Type {
case "http":
pt = chain.ProbeTypeHTTP
case "cmd":
pt = chain.ProbeTypeCmd
}
interval := cfg.Interval
if interval <= 0 {
interval = 30 * time.Second
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &chain.ProbeConfig{
Type: pt,
Addr: cfg.Addr,
Interval: interval,
Timeout: timeout,
HTTPPath: cfg.HTTPPath,
HTTPHost: cfg.HTTPHost,
HTTPHeaders: cfg.HTTPHeaders,
ExpectedStatus: cfg.ExpectedStatus,
Command: cfg.Command,
}
}
// parseFailCodes parses a comma-separated status code list, e.g. "429,5xx".
// Tokens ending in "xx" become hundred-level wildcards (5xx → 5, matching
// 500-599). Invalid tokens are logged and skipped.
func parseFailCodes(s string, log logger.Logger) chain.FailCodes {
var codes chain.FailCodes
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if len(part) == 3 && strings.HasSuffix(part, "xx") {
if prefix, err := strconv.Atoi(part[:1]); err == nil && prefix > 0 {
codes = append(codes, prefix) // < 100 → wildcard
continue
}
} else if code, err := strconv.Atoi(part); err == nil && code >= 100 {
codes = append(codes, code)
continue
}
log.Warnf("failCodes: invalid token %q", part)
}
return codes
}
+45 -3
View File
@@ -132,9 +132,9 @@ func TestParseNode_ExplicitDialer(t *testing.T) {
func TestParseNode_WithBypass(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "bypass-node",
Addr: "example.com:8080",
Bypass: "bp1",
Name: "bypass-node",
Addr: "example.com:8080",
Bypass: "bp1",
Bypasses: []string{"bp2", "bp3"},
}, testLogger())
if err != nil {
@@ -291,6 +291,48 @@ func TestParseNode_WithMatcherAutoPriority(t *testing.T) {
}
}
func TestParseNode_WithMatcherBodySize(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "body-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
// Escaped predicate form; in YAML users write the backtick
// equivalent: BodyRegexp(`"model":"gpt-4"`).
Rule: `BodyRegexp("\"model\":\"gpt-4\"")`,
BodySize: 65536,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
if got := n.Options().MatcherBodySize; got != 65536 {
t.Errorf("MatcherBodySize = %d, want 65536", got)
}
}
func TestParseNode_WithMatcherBodySize_Clamped(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "body-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: `BodyRegexp("foo")`,
BodySize: 10 * 1024 * 1024, // 10MB, above the 1MB cap
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
if got := n.Options().MatcherBodySize; got != MaxMatcherBodySize {
t.Errorf("MatcherBodySize = %d, want %d (clamped)", got, MaxMatcherBodySize)
}
}
func TestParseNode_WithHTTPNode(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "http-node",
+2
View File
@@ -328,6 +328,8 @@ 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...),
Caches: append(cfg1.Caches, cfg2.Caches...),
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
+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
}
+9 -3
View File
@@ -55,15 +55,21 @@ func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node
strategy = xs.HashStrategy[*chain.Node]()
case "parallel":
strategy = xs.ParallelStrategy[*chain.Node]()
case "lowestlatency", "lowest", "ll":
strategy = xs.LowestLatencyStrategy[*chain.Node]()
default:
strategy = xs.RoundRobinStrategy[*chain.Node]()
}
return xs.NewSelector(
strategy,
filters := []selector.Filter[*chain.Node]{
xs.FailFilter[*chain.Node](cfg.MaxFails, cfg.FailTimeout),
xs.BackupFilter[*chain.Node](),
)
}
if cfg.MaxLatency > 0 {
filters = append(filters, xs.LatencyFilter[*chain.Node](cfg.MaxLatency))
}
return xs.NewSelector(strategy, filters...)
}
// DefaultNodeSelector returns a node selector using round-robin strategy with
+68 -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"
@@ -34,11 +35,27 @@ import (
"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,
@@ -88,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 {
@@ -282,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),
@@ -295,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),
@@ -327,6 +391,8 @@ 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.CacheOption(registry.CacheRegistry().Get(cfg.Cache)),
handler.RewriterOption(rew),
handler.LoggerOption(handlerLogger),
handler.ServiceOption(cfg.Name),
handler.NetnsOption(netnsIn),
@@ -422,6 +488,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
Matcher: node.Matcher,
HTTP: httpCfg,
TLS: node.TLS,
Probe: node.Probe,
Metadata: node.Metadata,
})
}
+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)
+1 -1
View File
@@ -15,7 +15,7 @@ import (
type bindListener struct {
network string
addr net.Addr
session *mux.Session
session mux.Session
logger logger.Logger
}
+3 -1
View File
@@ -4,8 +4,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
type metadata struct {
@@ -31,6 +31,8 @@ func (c *relayConnector) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
return
+10 -1
View File
@@ -137,7 +137,12 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
addr.Host = "127.0.0.1"
}
req := gosocks5.NewRequest(gosocks5.CmdConnect, &addr)
cmd := gosocks5.CmdConnect
if c, ok := ctxvalue.Socks5CmdFromContext(ctx); ok {
cmd = c
}
req := gosocks5.NewRequest(cmd, &addr)
log.Trace(req)
if err := req.Write(conn); err != nil {
log.Error(err)
@@ -157,6 +162,10 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
return nil, err
}
if cmd == socks.CmdResolve || cmd == socks.CmdResolvePTR {
return &resolveConn{Conn: conn, resolvedAddr: reply.Addr}, nil
}
return conn, nil
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (p *tcpListener) Close() error {
type tcpMuxListener struct {
addr net.Addr
session *mux.Session
session mux.Session
logger logger.Logger
}
+2
View File
@@ -39,6 +39,8 @@ func (c *socks5Connector) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
return
}
+23
View File
@@ -0,0 +1,23 @@
package v5
import (
"net"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/gosocks5"
xmd "github.com/go-gost/x/metadata"
)
// resolveConn wraps a raw net.Conn and carries the resolved address from a
// Tor RESOLVE/RESOLVE_PTR reply as metadata. It implements net.Conn (passthrough)
// and metadata.Metadatable so the handler can extract the resolved address.
type resolveConn struct {
net.Conn
resolvedAddr *gosocks5.Addr
}
func (c *resolveConn) Metadata() md.Metadata {
return xmd.NewMetadata(map[string]any{
"resolvedAddr": c.resolvedAddr,
})
}
+50
View File
@@ -0,0 +1,50 @@
package v5
import (
"net"
"testing"
"github.com/go-gost/gosocks5"
)
type dummyConn struct{ net.Conn }
func TestResolveConn_Metadata(t *testing.T) {
addr := &gosocks5.Addr{Type: gosocks5.AddrIPv4, Host: "93.184.216.34", Port: 0}
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: addr}
md := rc.Metadata()
if md == nil {
t.Fatal("Metadata() returned nil")
}
v := md.Get("resolvedAddr")
if v == nil {
t.Fatal("resolvedAddr key not present in metadata")
}
got, ok := v.(*gosocks5.Addr)
if !ok {
t.Fatalf("resolvedAddr is %T, want *gosocks5.Addr", v)
}
if got.Host != "93.184.216.34" {
t.Errorf("Host = %q, want %q", got.Host, "93.184.216.34")
}
if got.Type != gosocks5.AddrIPv4 {
t.Errorf("Type = %d, want %d", got.Type, gosocks5.AddrIPv4)
}
}
func TestResolveConn_Metadata_NilAddr(t *testing.T) {
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: nil}
md := rc.Metadata()
v := md.Get("resolvedAddr")
addr, ok := v.(*gosocks5.Addr)
if !ok {
t.Fatalf("resolvedAddr has type %T, want *gosocks5.Addr", v)
}
if addr != nil {
t.Errorf("resolvedAddr = %v, want nil", addr)
}
}
+7 -2
View File
@@ -69,8 +69,8 @@ func (c *tunnelConnector) initTunnel(conn net.Conn, network, address string) (ad
req.Features = append(req.Features, af)
af = &relay.AddrFeature{}
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
address = net.JoinHostPort("0.0.0.0", address)
if h, p, e := net.SplitHostPort(address); e == nil && h == "" {
address = net.JoinHostPort("0.0.0.0", p)
}
af.ParseFrom(address)
req.Features = append(req.Features, af) // dst address
@@ -78,6 +78,11 @@ func (c *tunnelConnector) initTunnel(conn net.Conn, network, address string) (ad
req.Features = append(req.Features, &relay.TunnelFeature{
ID: c.md.tunnelID,
})
if c.md.recordMode != "" {
req.Features = append(req.Features, &relay.MetadataFeature{
KVs: map[string]string{"record.mode": c.md.recordMode},
})
}
if _, err = req.WriteTo(conn); err != nil {
return
}
+5
View File
@@ -89,6 +89,11 @@ func (c *tunnelConnector) Connect(ctx context.Context, conn net.Conn, network, a
req.Features = append(req.Features, &relay.TunnelFeature{
ID: c.md.tunnelID,
})
if c.md.recordMode != "" {
req.Features = append(req.Features, &relay.MetadataFeature{
KVs: map[string]string{"record.mode": c.md.recordMode},
})
}
if _, err := req.WriteTo(conn); err != nil {
return nil, err
+1 -1
View File
@@ -15,7 +15,7 @@ import (
type bindListener struct {
network string
addr net.Addr
session *mux.Session
session mux.Session
logger logger.Logger
}
+5
View File
@@ -19,6 +19,7 @@ type metadata struct {
connectTimeout time.Duration
tunnelID relay.TunnelID
muxCfg *mux.Config
recordMode string
}
func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
@@ -44,6 +45,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
c.md.tunnelID = c.md.tunnelID.SetWeight(uint8(weight))
}
c.md.recordMode = mdutil.GetString(md, "record.mode")
c.md.muxCfg = &mux.Config{
Version: mdutil.GetInt(md, "mux.version"),
KeepAliveInterval: mdutil.GetDuration(md, "mux.keepaliveInterval"),
@@ -52,6 +55,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
if c.md.muxCfg.Version == 0 {
c.md.muxCfg.Version = 2
+36
View File
@@ -101,6 +101,42 @@ func ClientIDFromContext(ctx context.Context) ClientID {
return v
}
type peerCertKey struct{}
// PeerCert carries the verified mTLS client certificate identity.
type PeerCert struct {
CN string
SANs []string
Fingerprint string // SHA-256 hex of cert.Raw
}
// ContextWithPeerCert returns a copy of ctx that carries the peer cert.
func ContextWithPeerCert(ctx context.Context, cert *PeerCert) context.Context {
return context.WithValue(ctx, peerCertKey{}, cert)
}
// PeerCertFromContext returns the peer cert stored in ctx, or nil.
func PeerCertFromContext(ctx context.Context) *PeerCert {
v, _ := ctx.Value(peerCertKey{}).(*PeerCert)
return v
}
type socks5CmdKey struct{}
// ContextWithSocks5Cmd returns a copy of ctx that carries a SOCKS5 command
// override. Connectors use this to send a non-CONNECT command (e.g. Tor
// RESOLVE/RESOLVE_PTR) to the upstream proxy.
func ContextWithSocks5Cmd(ctx context.Context, cmd uint8) context.Context {
return context.WithValue(ctx, socks5CmdKey{}, cmd)
}
// Socks5CmdFromContext returns the SOCKS5 command override stored in ctx.
// The bool is false if no override is present.
func Socks5CmdFromContext(ctx context.Context) (uint8, bool) {
v, ok := ctx.Value(socks5CmdKey{}).(uint8)
return v, ok
}
// labelsKey saves the static service labels.
type labelsKey struct{}
+70
View File
@@ -150,6 +150,49 @@ func TestClientIDFromContext_WrongType(t *testing.T) {
}
}
func TestPeerCertContext(t *testing.T) {
cert := &PeerCert{
CN: "client.example.com",
SANs: []string{"san1.example.com", "san2.example.com"},
Fingerprint: "abcdef1234567890",
}
ctx := ContextWithPeerCert(context.Background(), cert)
got := PeerCertFromContext(ctx)
if got == nil {
t.Fatal("PeerCertFromContext() = nil, want non-nil")
}
if got.CN != cert.CN {
t.Errorf("PeerCertFromContext().CN = %q, want %q", got.CN, cert.CN)
}
if len(got.SANs) != len(cert.SANs) || got.SANs[0] != cert.SANs[0] {
t.Errorf("PeerCertFromContext().SANs = %v, want %v", got.SANs, cert.SANs)
}
if got.Fingerprint != cert.Fingerprint {
t.Errorf("PeerCertFromContext().Fingerprint = %q, want %q", got.Fingerprint, cert.Fingerprint)
}
}
func TestPeerCertFromContext_Empty(t *testing.T) {
if got := PeerCertFromContext(context.Background()); got != nil {
t.Errorf("PeerCertFromContext(empty) = %v, want nil", got)
}
}
func TestPeerCertFromContext_WrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), peerCertKey{}, "not-a-cert")
if got := PeerCertFromContext(ctx); got != nil {
t.Errorf("PeerCertFromContext(wrong type) = %v, want nil", got)
}
}
func TestPeerCertFromContext_Nil(t *testing.T) {
ctx := ContextWithPeerCert(context.Background(), nil)
if got := PeerCertFromContext(ctx); got != nil {
t.Errorf("PeerCertFromContext(nil) = %v, want nil", got)
}
}
func TestContextWithLabels(t *testing.T) {
labels := map[string]string{"tenant": "acme", "region": "eu"}
ctx := ContextWithLabels(context.Background(), labels)
@@ -218,6 +261,33 @@ func (c *testContextConn) Context() context.Context {
return c.ctx
}
func TestContextWithSocks5Cmd(t *testing.T) {
ctx := ContextWithSocks5Cmd(context.Background(), 0xF0)
cmd, ok := Socks5CmdFromContext(ctx)
if !ok {
t.Fatal("Socks5CmdFromContext() ok = false, want true")
}
if cmd != 0xF0 {
t.Errorf("Socks5CmdFromContext() = 0x%02X, want 0xF0", cmd)
}
}
func TestSocks5CmdFromContext_Empty(t *testing.T) {
_, ok := Socks5CmdFromContext(context.Background())
if ok {
t.Error("Socks5CmdFromContext(empty) ok = true, want false")
}
}
func TestSocks5CmdFromContext_WrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), socks5CmdKey{}, "not-a-uint8")
_, ok := Socks5CmdFromContext(ctx)
if ok {
t.Error("Socks5CmdFromContext(wrong type) ok = true, want false")
}
}
func TestContextInterface(t *testing.T) {
ctx := context.Background()
ctx = ContextWithSid(ctx, Sid("iface-test"))
+4 -5
View File
@@ -97,11 +97,10 @@ func (d *http3Dialer) Dial(ctx context.Context, addr string, opts ...dialer.Dial
},
},
},
AuthorizePath: d.md.authorizePath,
PushPath: d.md.pushPath,
PullPath: d.md.pullPath,
TLSEnabled: true,
Logger: d.options.Logger,
PushPath: d.md.pushPath,
PullPath: d.md.pullPath,
TLSEnabled: true,
Logger: d.options.Logger,
}
d.clients[addr] = client
+5 -11
View File
@@ -9,16 +9,14 @@ import (
)
const (
defaultAuthorizePath = "/authorize"
defaultPushPath = "/push"
defaultPullPath = "/pull"
defaultPushPath = "/push"
defaultPullPath = "/pull"
)
type metadata struct {
authorizePath string
pushPath string
pullPath string
host string
pushPath string
pullPath string
host string
// QUIC config options
keepAlivePeriod time.Duration
@@ -36,10 +34,6 @@ func (d *http3Dialer) parseMetadata(md mdata.Metadata) (err error) {
maxStreams = "maxStreams"
)
d.md.authorizePath = mdutil.GetString(md, "pht.authorizePath", "authorizePath")
if !strings.HasPrefix(d.md.authorizePath, "/") {
d.md.authorizePath = defaultAuthorizePath
}
d.md.pushPath = mdutil.GetString(md, "pht.pushPath", "pushPath")
if !strings.HasPrefix(d.md.pushPath, "/") {
d.md.pushPath = defaultPushPath
+5 -2
View File
@@ -3,6 +3,7 @@ package quic
import (
"context"
"net"
"time"
"github.com/quic-go/quic-go"
)
@@ -11,8 +12,10 @@ type quicSession struct {
session *quic.Conn
}
func (session *quicSession) GetConn() (*quicConn, error) {
stream, err := session.session.OpenStreamSync(context.Background())
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
return nil, err
}
+9 -3
View File
@@ -75,7 +75,6 @@ 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() {
@@ -95,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
}
@@ -109,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()
conn, err = session.GetConn(ctx)
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
}
+14 -4
View File
@@ -14,14 +14,24 @@ 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)
if d.md.maxIdleTimeout <= 0 {
d.md.maxIdleTimeout = 90 * time.Second
}
return
}
+1 -5
View File
@@ -8,7 +8,7 @@ import (
type muxSession struct {
conn net.Conn
session *mux.Session
session mux.Session
}
func (session *muxSession) GetConn() (net.Conn, error) {
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
}
return session.session.IsClosed()
}
func (session *muxSession) NumStreams() int {
return session.session.NumStreams()
}
+3 -1
View File
@@ -4,8 +4,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
type metadata struct {
@@ -34,6 +34,8 @@ func (d *mtcpDialer) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
if d.md.muxCfg.Version == 0 {
d.md.muxCfg.Version = 2
+1 -5
View File
@@ -8,7 +8,7 @@ import (
type muxSession struct {
conn net.Conn
session *mux.Session
session mux.Session
}
func (session *muxSession) GetConn() (net.Conn, error) {
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
}
return session.session.IsClosed()
}
func (session *muxSession) NumStreams() int {
return session.session.NumStreams()
}
+3 -1
View File
@@ -4,8 +4,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
type metadata struct {
@@ -34,6 +34,8 @@ func (d *mtlsDialer) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
return
}
+1 -5
View File
@@ -8,7 +8,7 @@ import (
type muxSession struct {
conn net.Conn
session *mux.Session
session mux.Session
}
func (session *muxSession) GetConn() (net.Conn, error) {
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
}
return session.session.IsClosed()
}
func (session *muxSession) NumStreams() int {
return session.session.NumStreams()
}
+3 -1
View File
@@ -5,8 +5,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
const (
@@ -49,6 +49,8 @@ func (d *mwsDialer) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
d.md.handshakeTimeout = mdutil.GetDuration(md, "ws.handshakeTimeout", "handshakeTimeout")
+5 -6
View File
@@ -105,12 +105,11 @@ func (d *phtDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
// Timeout: 60 * time.Second,
Transport: tr,
},
AuthorizePath: d.md.authorizePath,
PushPath: d.md.pushPath,
PullPath: d.md.pullPath,
TLSEnabled: d.tlsEnabled,
Header: d.md.header,
Logger: d.options.Logger,
PushPath: d.md.pushPath,
PullPath: d.md.pullPath,
TLSEnabled: d.tlsEnabled,
Header: d.md.header,
Logger: d.options.Logger,
}
d.clients[addr] = client
}
+10 -18
View File
@@ -9,33 +9,25 @@ import (
)
const (
dialTimeout = "dialTimeout"
defaultAuthorizePath = "/authorize"
defaultPushPath = "/push"
defaultPullPath = "/pull"
defaultPushPath = "/push"
defaultPullPath = "/pull"
)
type metadata struct {
authorizePath string
pushPath string
pullPath string
host string
header http.Header
pushPath string
pullPath string
host string
header http.Header
}
func (d *phtDialer) parseMetadata(md mdata.Metadata) (err error) {
const (
authorizePath = "authorizePath"
pushPath = "pushPath"
pullPath = "pullPath"
host = "host"
header = "header"
pushPath = "pushPath"
pullPath = "pullPath"
host = "host"
header = "header"
)
d.md.authorizePath = mdutil.GetString(md, authorizePath)
if !strings.HasPrefix(d.md.authorizePath, "/") {
d.md.authorizePath = defaultAuthorizePath
}
d.md.pushPath = mdutil.GetString(md, pushPath)
if !strings.HasPrefix(d.md.pushPath, "/") {
d.md.pushPath = defaultPushPath
+5 -2
View File
@@ -3,6 +3,7 @@ package quic
import (
"context"
"net"
"time"
"github.com/quic-go/quic-go"
)
@@ -11,8 +12,10 @@ type quicSession struct {
session *quic.Conn
}
func (session *quicSession) GetConn() (*quicConn, error) {
stream, err := session.session.OpenStreamSync(context.Background())
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
return nil, err
}
+10 -3
View File
@@ -58,7 +58,6 @@ 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() {
@@ -73,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")
}
@@ -89,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()
conn, err = session.GetConn(ctx)
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
}
+3
View File
@@ -40,6 +40,9 @@ func (d *quicDialer) parseMetadata(md mdata.Metadata) (err error) {
}
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
if d.md.maxIdleTimeout <= 0 {
d.md.maxIdleTimeout = 90 * time.Second
}
d.md.maxStreams = mdutil.GetInt(md, maxStreams)
d.md.enableDatagram = mdutil.GetBool(md, "quic.enableDatagram", "enableDatagram")
+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 {
+107 -2
View File
@@ -5,7 +5,6 @@ import (
"crypto/tls"
"net"
"time"
"unsafe"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/logger"
@@ -82,7 +81,7 @@ func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
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))
utlsCfg := toUTLSConfig(d.options.TLSConfig)
uconn := utls.UClient(conn, utlsCfg, clientHelloID)
if err := uconn.HandshakeContext(ctx); err != nil {
conn.Close()
@@ -104,3 +103,109 @@ func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
return tlsConn, nil
}
// toUTLSConfig builds an equivalent utls.Config from a standard crypto/tls.Config.
//
// utls.Config is a distinct type from crypto/tls.Config and its field offsets
// diverge from ServerName onward (crypto/tls added fields utls does not have),
// so a direct (*utls.Config)(unsafe.Pointer(...)) reinterprets memory and reads
// garbage for ServerName/InsecureSkipVerify — which is what broke TLS
// verification for this dialer. We copy fields explicitly instead.
//
// Fields whose types differ between the two packages (Certificate, ClientAuth,
// CurveID, RenegotiationSupport, ConnectionState, the Get* callbacks,
// ClientSessionCache) are converted or skipped: the config path that feeds this
// dialer (LoadClientConfig) never populates the Get*/ClientSessionCache
// callbacks, so skipping them is safe here.
func toUTLSConfig(c *tls.Config) *utls.Config {
if c == nil {
return &utls.Config{}
}
uc := &utls.Config{
Rand: c.Rand,
Time: c.Time,
Certificates: toUTLSCertificates(c.Certificates),
RootCAs: c.RootCAs,
NextProtos: c.NextProtos,
ServerName: c.ServerName,
ClientAuth: utls.ClientAuthType(c.ClientAuth),
ClientCAs: c.ClientCAs,
InsecureSkipVerify: c.InsecureSkipVerify,
CipherSuites: c.CipherSuites,
PreferServerCipherSuites: c.PreferServerCipherSuites,
SessionTicketsDisabled: c.SessionTicketsDisabled,
CurvePreferences: toUTLSCurveIDs(c.CurvePreferences),
DynamicRecordSizingDisabled: c.DynamicRecordSizingDisabled,
Renegotiation: utls.RenegotiationSupport(c.Renegotiation),
KeyLogWriter: c.KeyLogWriter,
MinVersion: c.MinVersion,
MaxVersion: c.MaxVersion,
VerifyPeerCertificate: c.VerifyPeerCertificate,
}
if c.VerifyConnection != nil {
vc := c.VerifyConnection
uc.VerifyConnection = func(cs utls.ConnectionState) error {
return vc(tls.ConnectionState{
PeerCertificates: cs.PeerCertificates,
})
}
}
return uc
}
func toUTLSCertificates(certs []tls.Certificate) []utls.Certificate {
if certs == nil {
return nil
}
out := make([]utls.Certificate, len(certs))
for i, c := range certs {
sa := make([]utls.SignatureScheme, len(c.SupportedSignatureAlgorithms))
for j, s := range c.SupportedSignatureAlgorithms {
sa[j] = utls.SignatureScheme(s)
}
out[i] = utls.Certificate{
Certificate: c.Certificate,
PrivateKey: c.PrivateKey,
SupportedSignatureAlgorithms: sa,
OCSPStaple: c.OCSPStaple,
SignedCertificateTimestamps: c.SignedCertificateTimestamps,
Leaf: c.Leaf,
}
}
return out
}
// utlsCurves maps the crypto/tls curve IDs that utls understands.
//
// crypto/tls and utls use separate CurveID enumerations. Go has added
// post-quantum hybrid curves (X25519MLKEM768, SecP256r1MLKEM768,
// SecP384r1MLKEM1024) whose numeric IDs utls does not recognise, so a
// blind utls.CurveID(id) cast turns them into unsupported curve IDs and
// aborts the handshake with "CurvePreferences includes unsupported curve".
// We keep only the curves utls can actually use and drop the rest; if none
// survive, return nil so utls falls back to the fingerprint's defaults.
var utlsCurves = map[tls.CurveID]utls.CurveID{
tls.X25519: utls.X25519,
tls.CurveP256: utls.CurveP256,
tls.CurveP384: utls.CurveP384,
tls.CurveP521: utls.CurveP521,
}
func toUTLSCurveIDs(ids []tls.CurveID) []utls.CurveID {
if len(ids) == 0 {
return nil
}
out := make([]utls.CurveID, 0, len(ids))
for _, id := range ids {
if c, ok := utlsCurves[id]; ok {
out = append(out, c)
}
}
if len(out) == 0 {
return nil
}
return out
}
+106
View File
@@ -0,0 +1,106 @@
# Issue #777: relay+quic "假死" (Hanging) Under High Load
**Issue**: https://github.com/go-gost/gost/issues/777
**Status**: Open, labeled `NeedsInvestigation`
**Reported**: 2025-09-05
**Maintainer note**: "May related to connection multiplexing or reuse" (@fernvenue)
## 适用版本 / Version Context
本分析最初基于 issue 报告版本 **v3.2.4**(docker)。当前 `main` 已被以下提交修复部分根因,**下列 PRIMARY/SECONDARY/TERTIARY 在 v3.2.4 成立,但在当前 `main` 已不成立**:
- `5484717` (2026-06-20) — 新增 `quicSession.IsClosed()`,并在 `Dial()` 复用前检查 / 驱逐死亡 session(修复 "僵尸 session")。
- `9fab332` (2026-06-21) — `OpenStreamSync` 加 `30s` 超时;在 `GetConn()` 之前释放 `sessionMutex`(修复全局 mutex 阻塞)。
Plan 文档 `777-quic-hanging-plan.md` 为最新修正版(含剩余真问题与修复方案)。
## Symptom
Config: `gost -L socks5://:10086 -F relay+quic://IP:PORT?keepalive=true&ttl=21s&auth=XXXX`
Under high request frequency, connections are accepted (handler logs visible) but no forwarding occurs (no forwarding logs). Restarting the Docker container immediately recovers.
## Root Cause Analysis
### 已在当前 main 修复(v3.2.4 中存在)
#### ~~PRIMARY: Global `sessionMutex` 在 `OpenStreamSync` 期间被持有~~ — 已修复
原诊断认为 `quicDialer.Dial()` 在整个方法期间持有 `sync.Mutex`,包括会阻塞的 `OpenStreamSync`,导致全局假死。当前代码已在 `GetConn()` 之前释放锁:
```go
// x/dialer/quic/dialer.go
d.sessionMutex.Unlock() // :99 在 GetConn 之前释放
conn, err = session.GetConn(ctx) // :101 锁外执行 OpenStreamSync
```
`OpenStreamSync` 不再阻塞全局 mutex,但单个连接在死 session 上仍可能阻塞至超时(见 P1)。
#### ~~SECONDARY: `OpenStreamSync(context.Background())` 无超时~~ — 已修复
当前代码使用 `context.WithTimeout(ctx, 30*time.Second)`,不再是 `context.Background()` 无限阻塞。原诊断假设已不成立(提交 `9fab332`)。
#### ~~TERTIARY: 无 session 健康检查 / 驱逐~~ — 已修复
当前 `Dial()` 复用前调用 `session.IsClosed()` 检查并驱逐死亡 session:
```go
// x/dialer/quic/dialer.go
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
ok = false
}
```
`IsClosed()` 观察 `session.session.Context().Done()` channel 判定 session 是否存活(提交 `5484717`)。
### 仍真实存在的问题(P1–P3)
#### P1 (HIGH): 请求 ctx 未透传到 `GetConn` / `OpenStreamSync`
`Router` 默认 **15s 超时**(`x/chain/router.go:36`),并包裹为 `context.WithTimeout(ctx, 15s)` 透传至 `quicDialer.Dial`(`x/chain/router.go:108` → `route.Dial` → `transport.Dial` → `dialer.go:50`)。但 `GetConn()` 收到的仍是 `context.WithTimeout(context.Background(), 30s)`(由 `Dial` 内部构造),请求 ctx 被丢弃:
- `OpenStreamSync` 不遵守上游 15s 截止时间;
- 请求被取消时,`OpenStreamSync` 不会随之取消。
一个卡死的 QUIC session 仍会让每个新连接在 `OpenStreamSync` 上阻塞最多 30s(单连接级 hang,可被上游 ctx 取消则是修复后的行为)。
#### P2 (MEDIUM): keepalive 在 dialer / listener 间不对称
- Dialer (`x/dialer/quic/metadata.go:35`):`md == nil || !md.IsExists(keepAlive) || GetBool(keepAlive)` → **默认开启**(周期 10s)。
- Listener (`x/listener/quic/metadata.go:47`):`if GetBool(keepAlive)` → **仅显式 `keepalive=true` 才开启**。
若用户只在 client (`-F`) 配 `keepalive=true`,server 端 QUIC listener 不发 PING。过 NAT 时服务端回程映射过期,而客户端以为连接仍存活 —— 与 issue 症状吻合。
#### P3 (LOW): 两端均未显式设置 `MaxIdleTimeout` 默认
dialer 与 listener 均解析 `maxIdleTimeout` 并传入 `quic.Config.MaxIdleTimeout`,默认值为 `0` → quic-go 自身默认(约 30s),无显式下限。设置对称、合理的显式默认(90s)可让死 session 被 quic-go 自身更快检测,与 P2 互补。
## Recommended Fixes
| 编号 | 问题 | 状态 | 方案 |
|------|------|------|------|
| ~~PRIMARY~~ | 全局 mutex 持有 | **已修复** `9fab332` | — |
| ~~SECONDARY~~ | 无超时 OpenStreamSync | **已修复** `9fab332` | — |
| ~~TERTIARY~~ | 无 IsClosed 检查 | **已修复** `5484717` | — |
| Fix B | P1 ctx 未透传 | 待实施 | `GetConn(ctx)` 透传 + 30s 上限,镜像 icmp |
| Fix C | P2 keepalive 不对称 | 待实施 | listener 默认开启 keepalive |
| Fix D | P3 MaxIdleTimeout 默认 | 待实施 | 两端默认 90s,允许 `maxIdleTimeout=` 覆盖 |
详见 `777-quic-hanging-plan.md`。
## Key Files
| File | Role |
|------|------|
| `dialer/quic/dialer.go` | QUIC dialer;窄锁(GetConn 移出锁);IsClosed 复用前检查 |
| `dialer/quic/conn.go` | `GetConn(ctx)` / `IsClosed()` — OpenStreamSync 带 30s 超时 |
| `dialer/quic/metadata.go` | keepalive 默认 ON;maxIdleTimeout 解析 |
| `listener/quic/listener.go` | QUIC listener,keepAlivePeriod 传入 quic.Config |
| `listener/quic/metadata.go` | Listener keepalive / maxIdleTimeout 解析 |
| `dialer/icmp/dialer.go` | ICMP dialer — 与 quic 结构一致,改动需镜像 |
| `dialer/icmp/conn.go` | ICMP `quicSession` / `GetConn(ctx)` |
| `connector/relay/connector.go` | Relay connector(无状态,每调用一次) |
| `chain/router.go` | Router 默认 15s 超时,构造超时 ctx |
| `chain/route.go` | `route.Dial()` → `Transport.Dial()` |
+97
View File
@@ -0,0 +1,97 @@
# Plan: go-gost/gost#777 - relay+quic hanging under high load
## Issue Summary
- **Reporter**: @qianhd
- **Config**: `gost -L socks5://:10086 -F relay+quic://IP:PORT?keepalive=true&ttl=21s&auth=XXXX`
- **Version**: 3.2.4 (docker)
- **Symptom**: Under frequent requests, connections are accepted (handler logs visible) but no forwarding occurs (no forwarding logs). Restarting the container immediately recovers.
- **Maintainer comment**: "May related to connection multiplexing or reuse" (@fernvenue)
## Version Context - prior analysis is partly stale
The earlier analysis (777-quic-hanging-analysis.md) was written against v3.2.4 and named three root causes (global mutex held during OpenStreamSync, no timeout on OpenStreamSync, no IsClosed check). All three are already fixed on current main:
- 5484717 (2026-06-20): added quicSession.IsClosed() and the reuse-time liveness guard in Dial().
- 9fab332 (2026-06-21): added the 30s timeout on OpenStreamSync, and released sessionMutex before GetConn() so a dead session can no longer stall all Dial() calls.
This plan covers only the remaining real problems (P1-P3) plus doc correction. The sibling icmp dialer shares the quicSession type and the same structure, so every quic change is mirrored to icmp.
## Root Cause Analysis (current main)
### P1 (HIGH): request ctx not propagated to GetConn / OpenStreamSync
- Router applies a default 15s timeout (x/chain/router.go:36 sets Timeout = 15s) and wraps the context with context.WithTimeout(ctx, 15s) (x/chain/router.go:108). This ctx flows into quicDialer.Dial(ctx, ...) via route.Dial -> transport.Dial -> dialer.go:50.
- But dialer.go calls session.GetConn() without ctx, and conn.go builds its own context.WithTimeout(context.Background(), 30s). The request ctx (deadline + cancellation) is dropped.
- Consequence: OpenStreamSync neither honors the 15s request deadline nor cancels when the inbound request is cancelled. A stuck QUIC session still blocks each new connection on OpenStreamSync for up to 30s (single-connection hang, no longer global).
### P2 (MEDIUM): keepalive asymmetric between dialer and listener
- Dialer (x/dialer/quic/metadata.go:35): `md == nil || !md.IsExists(keepAlive) || GetBool(keepAlive)` -> keepalive ON by default (period 10s).
- Listener (x/listener/quic/metadata.go:47): `if GetBool(keepAlive)` -> only ON when explicitly set.
If the user only configures keepalive=true on the client (-F), the server-side QUIC listener sends no PING. Across a NAT the server's return-path mapping can expire while the client still thinks the connection is alive - matching the reported symptom.
### P3 (LOW): no explicit MaxIdleTimeout default on either side
Both dialer and listener parse maxIdleTimeout into quic.Config.MaxIdleTimeout with a default of 0 -> quic-go's own default (~30s), with no explicit floor. A symmetric, reasonable explicit default (90s) lets quic-go itself detect dead sessions sooner, complementing P2.
## Fix Plan
### Fix B (P1, HIGH): propagate request ctx into GetConn
- x/dialer/quic/conn.go: change `GetConn()` to `GetConn(ctx context.Context)` and use
`ctx, cancel := context.WithTimeout(ctx, 30*time.Second)` (keep the 30s cap, but the
parent ctx now carries the 15s request deadline and cancellation).
- x/dialer/quic/dialer.go:101: call `session.GetConn(ctx)`.
- Mirror to x/dialer/icmp/conn.go and x/dialer/icmp/dialer.go:120 (identical structure).
### Fix C (P2, MEDIUM): symmetric listener keepalive default
- x/listener/quic/metadata.go:47: change `if mdutil.GetBool(md, keepAlive)` to
`if md == nil || !md.IsExists(keepAlive) || mdutil.GetBool(md, keepAlive)` so the
server side sends keepalive by default (period 10s). No new struct field is needed -
keepAlivePeriod is already parsed.
### Fix D (P3, LOW): explicit MaxIdleTimeout default
- x/dialer/quic/metadata.go, x/dialer/icmp/metadata.go, x/listener/quic/metadata.go:
after parsing maxIdleTimeout, if `<= 0` set to `90 * time.Second`. Users can still
override via `maxIdleTimeout=`.
### Fix A (docs): correct the two issue docs
- 777-quic-hanging-analysis.md and this file: mark PRIMARY/SECONDARY/TERTIARY as already
fixed on main (commits 5484717, 9fab332); rename the remaining items to P1-P3.
## Sibling pattern note
kcp / masque / mtls / mtcp / mws / ssh hold the lock for the whole Dial (their stream-open
is non-blocking). quic / icmp are the only two using the narrow-lock variant (unlock before
GetConn), and their code is byte-for-byte identical - so every quic change is mirrored to
icmp. There is no shared session-cache abstraction to reuse; extraction is out of scope.
## Verification
```bash
cd x && go build ./... && go vet ./...
cd gost && go build ./cmd/gost/...
```
E2e (issue-like config):
```bash
# server: $GOST_BIN -L "relay+quic://:8443?keepalive=true"
# client: $GOST_BIN -L socks5://:10086 -F "relay+quic://SERVER:8443?keepalive=true&ttl=21s"
```
- Fix B: under high concurrency with a cancelled request, confirm OpenStreamSync returns on
ctx cancellation instead of blocking up to 30s.
- Fix C: server WITHOUT explicit keepalive=true should still have quic.Config.KeepAlivePeriod > 0.
- Fix D: without maxIdleTimeout set, confirm quic.Config.MaxIdleTimeout == 90s on both sides.
## Out of scope (optional follow-ups)
- Move initSession (quic.DialEarly handshake) out of sessionMutex (double-check / per-key lock).
- Background session health-check goroutine (IsClosed reuse check already covers dead sessions).
- Extract a shared session-cache abstraction across kcp/quic/icmp/ssh/masque (new abstraction).
@@ -0,0 +1,166 @@
# Plan: Make the mux layer pluggable and integrate Yamux
## Context
GOST's tunnel/relay/mux components multiplex streams over a single connection
through `x/internal/util/mux/mux.go`, which currently hard-wraps
`github.com/xtaci/smux`. Research (`x/docs/superpowers/specs/2026-06-08-tunnel-mux-protocol-research.md`)
concluded Yamux is the most viable alternative backend (broad ecosystem adoption,
different flow-control trade-off) and that making the layer pluggable is
straightforward: the `Session` type is only a handful of methods, and all
consumers funnel through two factory functions.
This plan converts the concrete `*mux.Session` into a `mux.Session` interface,
adds a Yamux implementation behind a `mux.type` selector, and threads the
selector through the existing metadata config (defaulting to `smux`, so all
existing deployments are unchanged).
Scope note: SMUX stays the default; we do **not** change per-component SMUX
version defaults (tunnel/mtcp already default to v2; relay/socks stay v1) — the
research spec explicitly forbids changing those to avoid behavior regressions.
## Approach
Rung check: this needs to exist (integration request) → it's a new backend, not
a re-implementation of an existing helper → stdlib/yamux already solve it → the
smallest change that works is an interface split at the existing chokepoint.
Two design decisions worth flagging:
- **Per-site metadata edits, not a shared `mux.ParseConfig` helper.** The 12
`metadata.go` files already duplicate the same `muxCfg` literal (pre-existing
debt). A shared helper would centralize the selector but would couple the
low-level `internal/util/mux` util to `x/metadata` (architectural smell) and
risk silently changing per-component version defaults. Adding `Type` +
`MaxStreamWindow` to each existing literal is the minimal, safe change.
- **Drop `NumStreams()` from the new interface.** It has zero callers across the
whole module; the 3 dialer wrapper `NumStreams` methods become dead code and
are deleted.
## Changes
### 1. `x/internal/util/mux/mux.go` — core refactor + Yamux backend
- Add imports: `github.com/hashicorp/yamux`, `github.com/go-gost/x/ctx`.
- Extend `Config`:
```go
Type string // "smux" (default) | "yamux"
MaxStreamWindow int // yamux MaxStreamWindowSize (bytes)
```
(keep all existing SMUX fields).
- Define the interface and rename the concrete SMUX type:
```go
type Session interface {
GetConn() (net.Conn, error)
Accept() (net.Conn, error)
Close() error
IsClosed() bool
}
type smuxSession struct { conn net.Conn; session *smux.Session }
```
Keep the existing `streamConn` shim (exposes `Context()`) unchanged.
- Add a Yamux implementation mirroring the SMUX one, including a `Context()`
shim so ctx propagation behaves identically:
```go
type yamuxSession struct { conn net.Conn; session *yamux.Session }
func (s *yamuxSession) GetConn() (net.Conn, error) { return wrapYamux(s.conn, s.session.Open()) }
func (s *yamuxSession) Accept() (net.Conn, error) { return wrapYamux(s.conn, s.session.Accept()) }
func (s *yamuxSession) Close() error { return s.session.Close() }
func (s *yamuxSession) IsClosed() bool { return s.session.IsClosed() }
```
`wrapYamux` embeds the `*yamux.Stream` (a full `net.Conn`) and adds
`Context()` delegating to the original `conn` (same pattern as `streamConn`).
- Branch the factories on `cfg.Type` (empty → `smux`):
```go
func ClientSession(conn net.Conn, cfg *Config) (Session, error) {
if muxType(cfg) == "yamux" { return newYamuxClientSession(conn, cfg) }
return newSMUXClientSession(conn, cfg)
}
// ServerSession analogous
```
- `newYamuxClientSession` / `newYamuxServerSession` build `*yamux.Config` from
`cfg`, **based on `yamux.DefaultConfig()`** (satisfies `VerifyConfig`'s
`KeepAliveInterval > 0` / `AcceptBacklog > 0` requirements), overriding only
when the user supplies a value:
- `KeepAliveInterval` ← `cfg.KeepAliveInterval` (if > 0)
- `EnableKeepAlive = false` ← `cfg.KeepAliveDisabled`
- `MaxStreamWindowSize = uint32(cfg.MaxStreamWindow)` (if > 0; must be ≥ 256 KiB
or Yamux errors at session creation — acceptable misconfig behavior)
### 2. `x/go.mod` + `x/go.sum` — add dependency
Run `cd x && go get github.com/hashicorp/yamux@v0.1.1 && go mod tidy`.
(Yamux has no heavy deps; `go mod tidy` resolves it.)
### 3. The 12 `metadata.go` files — add the selector to the muxCfg literal
In each, add two fields to the existing `&mux.Config{...}` literal
(keep all 7 existing keys + post-hoc `Version`/`MaxStreamBuffer` overrides):
```go
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
```
Files (the `muxCfg = &mux.Config{...}` site in each):
- `handler/tunnel/metadata.go`
- `handler/relay/metadata.go`
- `handler/socks/v5/metadata.go`
- `connector/relay/metadata.go`
- `connector/socks/v5/metadata.go`
- `connector/tunnel/metadata.go`
- `dialer/mtcp/metadata.go`
- `dialer/mws/metadata.go`
- `dialer/mtls/metadata.go`
- `listener/mtcp/metadata.go`
- `listener/mws/metadata.go`
- `listener/mtls/metadata.go`
### 4. Consumer type-reference flips `*mux.Session` → `mux.Session`
Mechanical: change the stored field / constructor param from pointer to
interface value (all already produce values that satisfy the interface):
- `connector/relay/listener.go` — `bindListener.session`
- `connector/socks/v5/listener.go` — `tcpMuxListener.session`
- `connector/tunnel/listener.go` — `bindListener.session`
- `dialer/mtcp/conn.go`, `dialer/mws/conn.go`, `dialer/mtls/conn.go` —
`muxSession.session` **and delete the now-dead `NumStreams()` method** (zero callers)
- `handler/relay/entrypoint.go` — `tcpHandler.session` field + `newTCPHandler` param
- `handler/tunnel/connector.go` — `Connector.s` field + `NewConnector` param
### 5. Test files (keep them compiling)
`x/` has no test convention but these compile during `go vet`:
- `handler/tunnel/tunnel_test.go` — `newTestSession` return type and
`newTestConnector` param: `*mux.Session` → `mux.Session`.
- `handler/tunnel/bind_test.go` — `muxCfg: &mux.Config{Version: 2}` literals are
unaffected (still `*mux.Config`); no change needed unless a `*mux.Session`
reference appears.
## Critical files
| File | Change |
|------|--------|
| `x/internal/util/mux/mux.go` | Interface + SMUX rename + Yamux backend + dispatch |
| `x/go.mod`, `x/go.sum` | Add `hashicorp/yamux` |
| 12 × `*/metadata.go` | Add `Type` + `MaxStreamWindow` to `muxCfg` |
| 8 consumer files (§4) | `*mux.Session` → `mux.Session` |
| `handler/tunnel/*_test.go` | Update session type refs |
## Verification
1. `cd x && go build ./...` — compiles (incl. all consumer/metadata changes).
2. `cd x && go vet ./...` — static analysis + compiles test files.
3. Optional smoke test: build the gost binary and run a tunnel pair where the
tunnel handler/service carries `metadata: { mux.type: yamux }`; confirm the
session establishes and streams flow. (Full e2e suite needs Docker; not
required for this change.)
## Out of scope (deferred)
- Shared `mux.ParseConfig(md)` helper to de-duplicate the 12 literals — would
couple low-level `internal/util/mux` to `x/metadata`; do it only as part of a
broader config-normalization pass.
- QUIC-based multiplexing — already handled at the listener/dialer level
(`http3`), not a mux-plugin concern.
@@ -0,0 +1,103 @@
# Design: Tunnel Mux Negotiation via Relay Protocol Extension
**Date:** 2026-07-13
**Status:** Design proposal (follow-up to `2026-07-13-mux-pluggable-yamux.md`)
**Scope:** Server-side multi-mux support for the tunnel handler, with the client
selecting the mux method per connection — negotiated through a new relay-protocol
feature, SOCKS5-style. Backward compatible; no change to the existing wire protocol.
## Problem
Today the tunnel handler multiplexes streams with a single hard-wired backend
(`github.com/xtaci/smux`), chosen statically per component via `mux.type`
metadata. We want the tunnel server to support several mux methods at once
(smux + yamux) and let each client pick one, **without changing the existing
wire protocol and without breaking old peers**.
## Key finding: the negotiation window already exists
In the tunnel path the relay `CmdBind` handshake runs on the **raw `net.Conn`
before** the mux session is created on that same connection:
```
raw conn: relay Request (CmdBind) → relay Response → mux.ClientSession / mux.ServerSession → streams
```
Confirmed at:
- Server (`handler/tunnel/`): `req.ReadFrom(conn)` → `handleBind` writes `resp` → `mux.ClientSession(conn)`.
- Client (`connector/tunnel/bind.go`): `req.WriteTo(conn)` → `resp.ReadFrom(conn)` → `mux.ServerSession(conn)`.
So a mux-method choice carried in the relay exchange is available in time to
drive the `muxCfg` passed to `mux.ClientSession`/`ServerSession`.
## Design
### 1. New relay feature type
Add `FeatureMux` (e.g. `0x05`) to `relay/feature.go`. Payload = list of
supported mux methods, encoded as method IDs (sketch: `1=smux`, `2=smux2`,
`3=yamux`). Mirror the existing `AddrFeature`/`TunnelFeature` pattern:
`TYPE(1) + LEN(2) + DATA`.
- **Client** (`connector/tunnel/bind.go`, `connector/relay/bind.go`): append
`FeatureMux` (supported methods) to `Request.Features`.
- **Server** (`handler/tunnel/handler.go` → `handleBind`): read it, pick a
mutually supported method (or its configured default), echo the **chosen**
method in `Response.Features`.
- Both ends build `muxCfg` for `mux.ClientSession`/`ServerSession` from the
agreed method instead of static metadata alone.
The relay protocol has no built-in "offer-N / pick-one" opcode — it's symmetric
feature lists — but a feature whose payload is a method list, with the server
echoing the chosen one, reuses the existing echo shape. No protocol change.
### 2. Mux package abstraction (shared with the implementation plan)
`x/internal/util/mux` gains a `mux.Session` interface with an smux backend and a
yamux backend; the negotiated feature selects the implementation + version. This
is the same refactor as `2026-07-13-mux-pluggable-yamux.md`.
## Backward compatibility
- **Wire format unchanged.** We only add a feature type to the existing
`Features []Feature` list. Old peers are unaffected.
- **`OpaqueFeature` guarantees graceful fallback.** A peer with no `case FeatureMux`
simply drops the feature (never errors). So:
- Old smux-only client → new server: no `FeatureMux` sent, server falls back to
its configured/default mux = byte-identical to today.
- New client offering `yamux` → old server: server ignores the feature, client
sees no chosen method, falls back to its default (`smux`). No break.
This satisfies both constraints: 不改变当前协议 and 向后兼容.
## Constraints
1. **Timing is hard, not soft.** `smux.Client/Server` send/parse a version frame
immediately at session creation; mismatched impl/version fails the handshake.
Because `CmdBind` precedes it, the agreed choice is in time — *provided both
ends build `muxCfg` from the agreed feature, not static metadata alone.*
2. **Coverage = relay-based mux family only.** The negotiation window exists for
tunnel handler, tunnel connector, relay handler, relay connector (all do
`CmdBind` on the raw conn before mux). The **raw** mux tunnels
(`listener/mtcp|mws|mtls` and their dialers) call `mux.ServerSession`/
`ClientSession` directly with no relay handshake first, so they keep using the
static `mux.type` from the implementation plan.
3. **`relay/` is a separate zero-dependency module.** `FeatureMux` lives there,
consumed by `x/`. Adding a feature type is backward-compatible but is a
protocol-library change — deliberate, not casual.
## Relationship to the implementation plan
This negotiation design **extends** `2026-07-13-mux-pluggable-yamux.md`; it does
not replace it. The mux package still gets the interface + yamux backend, and the
static `mux.type` remains the default/fallback for raw-mux paths. The relay
`FeatureMux` adds server-side multi-mux support + per-connection client selection
for the tunnel/relay paths.
## Open questions (for later, when implementing)
- Exact `FeatureMux` payload encoding (method-ID list vs. string tags).
- How the server's supported-methods set is configured (metadata keys like
`mux.supported` / `mux.types`).
- Whether `connector/socks/v5` muxbind (SOCKS5, not relay) should get an
analogous SOCKS5-method negotiation, or stay static.
+145
View File
@@ -0,0 +1,145 @@
# failCodes — HTTP Status-Based Node Fail Marking
## Context
When GOST acts as HTTP reverse proxy (e.g. LLM API gateway), the upstream
backend can return HTTP 5xx/429 errors while the node itself is healthy at
the transport level. The existing Marker + FailFilter only catches dial-time
failures — there is no way to mark a node unhealthy based on HTTP response
codes.
`failCodes` adds a node-level HTTP setting defining which response status
codes trigger node marking. Cooldown is handled by the existing
`failTimeout`/`maxFails` selector config. No Marker interface changes needed.
No retry — the response is always relayed to the client as-is; marking only
affects subsequent node selection.
## Design
### 1. `core/chain/node.go` — `FailCodes` type
```go
// FailCodes lists response status codes that mark the node as failed.
// Codes < 100 are wildcard hundred-level prefixes (e.g. 5 → 5xx).
type FailCodes []int
func (fc FailCodes) Match(statusCode int) bool {
for _, code := range fc {
if (code < 100 && statusCode/100 == code) || statusCode == code {
return true
}
}
return false
}
```
`HTTPNodeSettings` gets one new field:
```go
FailCodes FailCodes
```
### 2. `x/config/config.go` — config struct
```go
// in HTTPNodeConfig:
FailCodes string `yaml:"failCodes,omitempty" json:"failCodes,omitempty"`
```
Plain comma-separated string — simpler than the original array-of-objects
approach, consistent with the "single string value" metadata convention.
### 3. `x/config/parsing/node/parse.go` — parsing
```go
func parseFailCodes(s string, log logger.Logger) chain.FailCodes {
var codes chain.FailCodes
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if len(part) == 3 && strings.HasSuffix(part, "xx") {
if prefix, err := strconv.Atoi(part[:1]); err == nil && prefix > 0 {
codes = append(codes, prefix) // < 100 → wildcard
continue
}
} else if code, err := strconv.Atoi(part); err == nil && code >= 100 {
codes = append(codes, code)
continue
}
log.Warnf("failCodes: invalid token %q", part)
}
return codes
}
```
Wired inside the `if cfg.HTTP != nil` block in `ParseNode`:
```go
if v := strings.TrimSpace(cfg.HTTP.FailCodes); v != "" {
settings.FailCodes = parseFailCodes(v, nodeLogger)
}
```
### 4. `x/internal/util/forwarder/sniffer_http.go` — runtime
In `httpRoundTrip`, after `ro.HTTP.StatusCode = resp.StatusCode`:
```go
// failCodes: a matching upstream status marks the node failed so the
// selector's FailFilter skips it. The response still relays to the client;
// closing the connection makes the next client request re-select a node.
if hts := node.Options().HTTP; hts != nil && hts.FailCodes.Match(resp.StatusCode) {
log.Warnf("failCodes matched status %d for node %s", resp.StatusCode, node.Name)
if marker := node.Marker(); marker != nil {
marker.Mark()
}
shouldClose = true
}
```
No early return — the response writes through to the client normally.
`shouldClose = true` closes the upstream connection after this request so the
next client request triggers a fresh `hop.Select()`.
## YAML Example
```yaml
chains:
- name: llm-chain
hops:
- name: llm-hop
selector:
strategy: round
maxFails: 1
failTimeout: 15s
nodes:
- name: deepseek-proxy
addr: api.deepseek.com:443
connector: {type: http}
http:
failCodes: "429,502,503,504"
```
**Important**: `maxFails: 1` is recommended. The `dial()` function calls
`marker.Reset()` on successful TCP dials, which can wipe HTTP-based marks
before `maxFails` is reached. With `maxFails: 1`, one `Mark()` immediately
excludes the node.
## Scope
- **Implemented**: sniffer-based forwarder path (`forward/local`,
`forward/remote`).
- **Future work**: HTTP handler direct path (`x/handler/http/proxy.go`).
- **No retry**: retry without idempotency knowledge is unsafe — the response
is always relayed to the client for the client to decide.
## What's NOT changed
- `core/selector/` — Marker/FailFilter untouched
- `core/hop/` — Select() untouched
- `x/chain/router.go` — Router.Dial untouched
- `x/handler/http/proxy.go` — httpHandler direct path (future work)
## Verification
```bash
cd core && go build ./... && go vet ./...
cd x && go build ./... && go vet ./...
```
@@ -0,0 +1,152 @@
# Generic Cache Component + HTTP Response Cache (issue #349)
## Context
Issue [#349](https://github.com/go-gost/gost/issues/349) requests an HTTP response cache so GOST can mirror static sites / CDNs (e.g. gh-pages) via its proxy. GOST currently re-forwards every request to upstream — no caching.
Rather than a one-off HTTP cache, we build a **generic cache component** (interface + memory backend + registry/config wiring), then make the HTTP proxy its first consumer. This is justified — not speculative — because two more consumers are committed roadmap:
- **DNS** ([resolver/cache.go](../../internal/util/resolver/cache.go)) already stores `[]byte`-serializable data with near-identical TTL + serve-stale + async-refresh semantics → migratable to the new interface later.
- **Redis / other backends** are planned beyond in-memory.
Scope decisions carried from prior review rounds:
- Interface trimmed to **3 methods** (`Get`/`Set`/`Delete`). 349's 7-method draft dropped `Has`/`Clear`/`Len` (awkward/expensive on Redis); `RefreshTTL` also dropped — its only consumer is DNS async-refresh ([handler.go:343](../../handler/dns/handler.go#L343)), which is not in this PR, and it is expressible as `Get`+`Set(WithTTL)`. Add it in the DNS-migration PR where it has a real caller and a native Redis `EXPIRE` mapping. The HTTP consumer uses `singleflight`, not `RefreshTTL`, for anti-stampede.
- **HTTP consumer lands in this same PR** as the validating consumer (349 had put it in "Phase 4" — wrong; it's the issue's actual ask). DNS migration and Redis backend are explicit follow-ups, not this PR.
- **Named shared store** referenced by name (`services[].cache: <name>`), not the per-chain-node config from the earlier `http-response-cache-for-reverse-proxy.md` draft (which was per-node, ambiguous with a shared store, and modified `core/chain/node.go`).
- Interface lives in **`core/cache/`** (first-class component, consistent with recorder/limiter — maintainer-approved core addition). HTTP policy configured via **handler metadata**, keeping the generic cache as dumb bytes.
## Architecture
```
caches: (named, registry) ──► registry.CacheRegistry().Get(name) ──► handler.CacheOption
│
services[].cache: cache-0 ▼
handler metadata: cache.ttl / cache.status.* / cache.serveStale ─► httpcache.Cache{Store, Policy, sf}
│ held by SnifferBuilder (built once in Init)
▼ Build() copies ref per connection (shared store)
HandleHTTP ──(before dial)── cache hit? ──► serve from cache
└─(miss)── singleflight ─► dial+roundtrip ─► tee response ─► Store
```
**Key correctness points** (from round-1 review of the sniffer):
- The `Sniffer` is built **per connection** (`SnifferBuilder.Build()`), so the cache/singleflight must live on the **shared** `SnifferBuilder` (built once in `Init`), never on `*Sniffer`. The store itself is a registry singleton, so all handlers/connections referencing the same name share it.
- Cache **hit check goes in `HandleHTTP` before `h.dial`** (and before keep-alive reuse), not inside `httpRoundTrip` — otherwise the upstream dial is already paid and nothing is saved.
- **SNI caching only works with MITM TLS termination.** Non-MITM `HandleTLS` pipes ciphertext ([sniffer_tls.go:116](../../internal/util/sniffing/sniffer_tls.go#L116)) and cannot cache. With `Certificate`+`PrivateKey` set, `terminateTLS` → `HandleHTTP`, so the HTTP cache glue covers SNI-with-MITM automatically — no extra code, just document the requirement.
## Files
### New — the generic component
**`core/cache/cache.go`** (~60 lines) — interface only. First-class component (maintainer-approved core addition). Mirrors `core/recorder/recorder.go` complexity.
```go
package cache
var ErrNotFound = errors.New("cache: key not found")
type Entry struct {
Data []byte
Expiration time.Time // zero = never expires
}
func (e *Entry) Expired() bool
func (e *Entry) TTL() time.Duration // remaining; <=0 expired, -1 never
type SetOptions struct{ TTL time.Duration }
type SetOption func(*SetOptions)
func WithTTL(d time.Duration) SetOption
// Cache is a concurrency-safe []byte KV store. Get returns ErrNotFound ONLY
// when the key is absent; expired entries are returned (with Expired()==true)
// to support serve-stale. Callers serialize/deserialize their own types.
type Cache interface {
Get(ctx context.Context, key string) (*Entry, error)
Set(ctx context.Context, key string, data []byte, opts ...SetOption) error
Delete(ctx context.Context, key string) error
}
```
Three methods only. `Get`/`Set` are load-bearing for HTTP; `Delete` is kept as a fundamental KV primitive (needed for a future PURGE API, unambiguous on every backend). `RefreshTTL` deliberately omitted — see scope decisions above.
**`core/handler/option.go`** (+~6 lines) — add `Cache cache.Cache` field + `func CacheOption(c cache.Cache) Option`, exactly like `RecordersOption` (option.go:40,115).
**`x/cache/memory.go`** (~180 lines) — memory backend implementing `cache.Cache`. Self-contained (no external lib), supersedes the trivial [x/internal/util/cache](../../internal/util/cache/cache.go) and the intent of patrickmn/go-cache. Features: `sync.RWMutex`, per-entry TTL (default from config, `WithTTL` override), `maxSize` + `maxBytes` bounds, eviction `oldest|lru`, optional background cleanup goroutine (stopped via `io.Closer` — registry's `Unregister` calls Close, see [registry.go Unregister](../../registry/registry.go)). No `init()` self-registration (recorder-style; instances are registered by the loader under their config name).
**`x/config/parsing/cache/parse.go`** (~40 lines) — `ParseCache(cfg *config.CacheConfig) cache.Cache`, dispatches on backend field. Only `Memory` now; `Redis`/`File`/`Plugin` are follow-ups (do NOT ship un-parseable empty backend structs). Mirrors [ParseRecorder](../../config/parsing/recorder/parse.go).
**`x/registry/cache.go`** (~40 lines) — `cacheRegistry` embedding `registry[cache.Cache]` + hot-reload `cacheWrapper` delegating each method to the current instance. Copy [x/registry/recorder.go](../../registry/recorder.go) exactly.
**`x/internal/util/httpcache/httpcache.go`** (~150 lines) — HTTP-specific glue over `cache.Cache`, shared by both sniffer packages. Holds all HTTP policy so the generic cache stays dumb bytes:
```go
type Policy struct {
DefaultTTL time.Duration
StatusTTL map[int]time.Duration // per-status (200/302→60m, 404→1m)
ServeStale bool
Methods map[string]bool // default GET, HEAD
MaxBodyBytes int
}
type Cache struct {
Store cache.Cache
Policy Policy
sf singleflight.Group // shared thundering-herd guard
}
func Key(method, host, uri string) string // "GET example.com/api"
func (c *Cache) Lookup(req) (resp *http.Response, stale bool, ok bool)
func (c *Cache) Cacheable(req, resp) bool // method + 2xx/3xx + configured statuses
func (c *Cache) TTLFor(status int) time.Duration
func (c *Cache) TeeWriter(w io.Writer) *teeWriter // captures up to MaxBodyBytes, discards if exceeded
func (c *Cache) Store(req, data []byte, ttl)
```
Serialization uses stdlib `resp.Write` / `http.ReadResponse` (pass the request method so HEAD parses body-less).
### Modified — config & wiring (copy recorder pattern verbatim)
- **`x/config/config.go`** — add `CacheConfig{Name, Memory *MemoryCache}` + `MemoryCache{TTL, MaxSize, MaxBytes, CleanupInterval, Eviction}`; `Config.Caches []*CacheConfig`; `ServiceConfig.Cache string` (name ref, like `Limiter string` at config.go:514).
- **`x/registry/registry.go`** — add `cacheReg` singleton (registry.go:49 style) + `CacheRegistry()` accessor (registry.go:187).
- **`x/config/loader/loader.go`** — add a `registerGroup` block iterating `cfg.Caches` → `ParseCache` → `CacheRegistry()` (copy recorder block loader.go:239-247).
- **`x/config/parsing/service/parse.go`** — look up `registry.CacheRegistry().Get(cfg.Cache)`, read HTTP cache policy from handler metadata, build `httpcache.Cache`, inject via `handler.CacheOption(...)` (alongside `RecordersOption`, parse.go:393). New metadata keys in [parsing/parse.go](../../config/parsing/parse.go): `cache.ttl`, `cache.status.<code>` (via `mdutil.GetStringMapString(md,"cache.status")`), `cache.serveStale`, `cache.methods`, `cache.maxBodyBytes`.
### Modified — HTTP consumers (the feature)
Both sniffer packages get the same edit. `SnifferBuilder` gains a `Cache *httpcache.Cache` field, `Build()` copies it onto `Sniffer`:
- **`x/internal/util/forwarder/`** (`sniffer.go`, `sniffer_http.go`) — serves forward/local + forward/remote.
- **`x/internal/util/sniffing/`** (`sniffer.go`, `sniffer_http.go`) — serves sni + http/connect (+ redirect).
In each `HandleHTTP`: **before `h.dial`** (and before keep-alive reuse at the re-dial branch), if `Cache != nil` and method is cacheable, `Lookup`; on fresh hit write to client (`resp.Write(rw)`), record, and continue the keep-alive loop with correct `shouldClose` (do **not** close the client conn). In `httpRoundTrip`: on miss, run dial+roundtrip inside `sf.Do(key,...)`; if `Cacheable`, wrap `rw` with `TeeWriter` so `resp.Write(tee)` captures bytes, then `Store` after a successful write. On upstream error with a stale entry and `ServeStale`, write the stale response instead of erroring.
The four dispatch sites that call `Build()` are unchanged in shape — they already build a fresh sniffer per connection; the cache reference rides along on the builder:
[forward/local/sniffing.go:79](../../handler/forward/local/sniffing.go#L79), [forward/remote/sniffing.go:79](../../handler/forward/remote/sniffing.go#L79), [sni/sniffing.go:104](../../handler/sni/sniffing.go#L104), [http/connect.go:126](../../handler/http/connect.go#L126). Each handler's `SnifferBuilder` is populated from `h.options.Cache` in `Init`.
## What is NOT in this PR (deferred, interface already accommodates)
- Redis / File / Plugin backends (Redis struct + impl land together in a follow-up).
- DNS / router / limiter / TLS-certpool migration to the new interface (Phase 2+).
- PURGE API, Vary, RFC 7234 conditional requests, disk persistence.
## Verification
```bash
cd x && go build ./... && go vet ./...
cd ../gost && go build ./cmd/gost/...
cd ../core && go build ./... && go vet ./... # interface + handler option
```
Unit tests:
- `x/cache/memory_test.go` — set/get/expire, `WithTTL` override, oldest & lru eviction, maxBytes bound, cleanup goroutine stop on Close, `Get` returns expired entry (serve-stale contract). Run with `CGO_ENABLED=1 go test -race`.
- `x/internal/util/httpcache/httpcache_test.go` — key format, per-status TTL, `Cacheable` (method/status matrix), teeWriter cutoff at `MaxBodyBytes`, response round-trip via `resp.Write`/`http.ReadResponse` incl. HEAD.
End-to-end (memory backend, HTTP proxy):
```yaml
caches:
- name: c0
memory: { maxSize: 1000, maxBytes: 268435456, eviction: lru }
services:
- name: web
addr: ":8080"
handler:
type: http
metadata: { cache: c0, cache.ttl: 60m, cache.status.404: 1m, cache.serveStale: true }
forwarder:
nodes: [{ name: up, addr: "backend:80" }]
```
Two identical GETs: first logs `<-> upstream`, second serves from cache (no upstream dial in trace). Kill the backend after warming the cache → with `serveStale: true`, the stale entry is still served.
```bash
cd gost && go run ./cmd/gost/... -C cache.yml -D # -D trace shows hit/miss/dial
```
For SNI: same works only when the service also has MITM certs configured (otherwise TLS is piped and uncacheable — documented behavior).
-75
View File
@@ -1,75 +0,0 @@
# 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/`.
+45 -30
View File
@@ -4,22 +4,25 @@ 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/gin-gonic/gin v1.12.0
github.com/go-gost/core v0.6.0
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/tls-dissector v0.2.0
github.com/go-gost/plugin v0.5.0
github.com/go-gost/relay v0.7.0
github.com/go-gost/tls-dissector v0.3.0
github.com/go-redis/redis/v8 v8.11.5
github.com/gobwas/glob v0.2.3
github.com/golang/snappy v0.0.4
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/hashicorp/yamux v0.1.1
github.com/klauspost/compress v1.19.0
github.com/miekg/dns v1.1.61
github.com/mitchellh/go-homedir v1.1.0
github.com/patrickmn/go-cache v2.1.0+incompatible
@@ -31,9 +34,12 @@ require (
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
github.com/tidwall/gjson v1.19.0
github.com/tidwall/sjson v1.2.5
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54
github.com/vishvananda/netns v0.0.4
github.com/vulcand/predicate v1.2.0
@@ -43,46 +49,55 @@ require (
github.com/xtaci/tcpraw v1.2.25
github.com/yl2chen/cidranger v1.0.2
github.com/zalando/go-keyring v0.2.4
golang.org/x/crypto v0.50.0
golang.org/x/crypto v0.54.0
golang.org/x/exp v0.0.0-20241210194714-1829a127f884
golang.org/x/net v0.53.0
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
golang.org/x/net v0.57.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
golang.org/x/time v0.12.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.10
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20
)
require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/goccy/go-yaml v1.19.2 // 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/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // 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
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
)
require (
github.com/alessio/shellescape v1.4.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coreos/go-iptables v0.5.0 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gopacket v1.1.19 // indirect
@@ -90,7 +105,7 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/reedsolomon v1.11.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
@@ -99,7 +114,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/gomega v1.34.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
@@ -120,16 +135,16 @@ require (
github.com/templexxx/xorsimd v0.4.3 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0
golang.org/x/term v0.42.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260723215102-3fe39f3c1018 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
+111 -80
View File
@@ -10,18 +10,18 @@ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl
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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-iptables v0.5.0 h1:mw6SAibtHKZcNzAsOxjoHIG0gy5YFHhypWSSNc6EjbQ=
github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
@@ -43,46 +43,50 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
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/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-gost/core v0.6.0 h1:Y7jG5bI0QxdzDlMOyEtAYxCz+0GxAHKmaei667pRPWc=
github.com/go-gost/core v0.6.0/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/tls-dissector v0.2.0 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc1SicVg=
github.com/go-gost/tls-dissector v0.2.0/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
github.com/go-gost/plugin v0.5.0 h1:zK3Ezcv+z6bz6aUhaFZcZiJc42w44ohybzrj5tnWzcM=
github.com/go-gost/plugin v0.5.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
github.com/go-gost/relay v0.7.0 h1:J8e3Sba6DtBJQotXY5j5EaZNWwtv6Z9CoCFQsnrHNcE=
github.com/go-gost/relay v0.7.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
github.com/go-gost/tls-dissector v0.3.0 h1:mbjmcHFXYRMUXGGTbJ78dllg6utjK3gp9Rn04tda4UA=
github.com/go-gost/tls-dissector v0.3.0/go.mod h1:vGfog053fIm93iXBtvmVzMQqEJo5YwbbNaPNVDjbiOc=
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=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -106,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=
@@ -121,24 +126,26 @@ github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf h1:C1GPyPJr
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf/go.mod h1:zXqxTI6jXDdKnlf8s+nT+3c8LrwUEy3yNpO4XJL90lA=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
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=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.11.8 h1:s8RpUW5TK4hjr+djiOpbZJB4ksx+TdYbRH7vHQpwPOY=
github.com/klauspost/reedsolomon v1.11.8/go.mod h1:4bXRN+cVzMdml6ti7qLouuYi32KHJ5MGv0Qd8a47h6A=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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=
@@ -162,8 +169,8 @@ github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/dtls/v3 v3.1.1 h1:wSLMam9Kf7DL1A74hnqRvEb9OT+aXPAsQ5VS+BdXOJ0=
github.com/pion/dtls/v3 v3.1.1/go.mod h1:7FGvVYpHsUV6+aywaFpG7aE4Vz8nBOx74odPRFue6cI=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
@@ -177,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=
@@ -206,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=
@@ -233,9 +248,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -244,12 +258,25 @@ github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
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=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 h1:8mhqcHPqTMhSPoslhGYihEgSfc77+7La1P6kiB6+9So=
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
@@ -269,6 +296,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=
@@ -277,33 +306,34 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
@@ -312,8 +342,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -322,33 +352,36 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -357,37 +390,37 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260723215102-3fe39f3c1018 h1:yXIvV9x4Vu2wUs2cCW8puVLHAjZkuipNK1MnTCZ0Jo0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260723215102-3fe39f3c1018/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -405,5 +438,3 @@ gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 h1:0DxLu8hxI1OGp1qVRPqNd+2k
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+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),
)
}
+1
View File
@@ -40,6 +40,7 @@ func (h *forwardHandler) dialTarget(ctx context.Context, conn net.Conn, ro *xrec
log.Error(errNodeNotAvailable)
return nil, errNodeNotAvailable
}
ro.Node = target.Name
addr := target.Addr
if opts := target.Options(); opts != nil {
if opts.Network != "" {
+2
View File
@@ -73,6 +73,7 @@ import (
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/util/httpcache"
"github.com/go-gost/x/internal/util/sniffing"
tls_util "github.com/go-gost/x/internal/util/tls"
rate_limiter "github.com/go-gost/x/limiter/rate"
@@ -138,6 +139,7 @@ func (h *forwardHandler) Init(md md.Metadata) (err error) {
CertPool: h.certPool,
MitmBypass: h.md.mitmBypass,
ReadTimeout: h.md.readTimeout,
Cache: httpcache.FromMetadata(h.options.Cache, md),
}
return
+4
View File
@@ -15,6 +15,7 @@ import (
ictx "github.com/go-gost/x/internal/ctx"
"github.com/go-gost/x/internal/net/proxyproto"
"github.com/go-gost/x/internal/util/forwarder"
"github.com/go-gost/x/internal/util/httpcache"
"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"
@@ -36,6 +37,8 @@ type SnifferBuilder struct {
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
// See forwarder.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration
// Cache is the HTTP response cache (nil when caching is disabled).
Cache *httpcache.Cache
}
// Build creates a new forwarder.Sniffer from the builder's configuration.
@@ -51,6 +54,7 @@ func (b *SnifferBuilder) Build() *forwarder.Sniffer {
CertPool: b.CertPool,
MitmBypass: b.MitmBypass,
ReadTimeout: b.ReadTimeout,
Cache: b.Cache,
}
}
+1
View File
@@ -26,6 +26,7 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
log.Error(errNodeNotAvailable)
return errNodeNotAvailable
}
ro.Node = target.Name
addr := target.Addr
if opts := target.Options(); opts != nil {
if opts.Network != "" {
+2
View File
@@ -73,6 +73,7 @@ import (
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/util/httpcache"
"github.com/go-gost/x/internal/util/sniffing"
tls_util "github.com/go-gost/x/internal/util/tls"
rate_limiter "github.com/go-gost/x/limiter/rate"
@@ -138,6 +139,7 @@ func (h *forwardHandler) Init(md md.Metadata) (err error) {
CertPool: h.certPool,
MitmBypass: h.md.mitmBypass,
ReadTimeout: h.md.readTimeout,
Cache: httpcache.FromMetadata(h.options.Cache, md),
}
return
+4
View File
@@ -15,6 +15,7 @@ import (
ictx "github.com/go-gost/x/internal/ctx"
"github.com/go-gost/x/internal/net/proxyproto"
"github.com/go-gost/x/internal/util/forwarder"
"github.com/go-gost/x/internal/util/httpcache"
"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"
@@ -36,6 +37,8 @@ type SnifferBuilder struct {
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
// See forwarder.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration
// Cache is the HTTP response cache (nil when caching is disabled).
Cache *httpcache.Cache
}
// Build creates a new forwarder.Sniffer from the builder's configuration.
@@ -51,6 +54,7 @@ func (b *SnifferBuilder) Build() *forwarder.Sniffer {
CertPool: b.CertPool,
MitmBypass: b.MitmBypass,
ReadTimeout: b.ReadTimeout,
Cache: b.Cache,
}
}
+9 -3
View File
@@ -15,12 +15,13 @@ 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"
"github.com/go-gost/x/internal/util/httpcache"
"github.com/go-gost/x/internal/util/sniffing"
tls_util "github.com/go-gost/x/internal/util/tls"
traffic_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
@@ -132,6 +133,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 +142,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 +236,9 @@ 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
// Cache is the HTTP response cache (nil when caching is disabled).
Cache *httpcache.Cache
}
// Build creates a new sniffing.Sniffer from the builder's configuration.
@@ -249,5 +254,6 @@ func (b *SnifferBuilder) Build() *sniffing.Sniffer {
CertPool: b.CertPool,
MitmBypass: b.MitmBypass,
ReadTimeout: b.ReadTimeout,
Cache: b.Cache,
}
}
+63 -14
View File
@@ -190,6 +190,9 @@ package http
import (
"bufio"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"net"
"net/http"
@@ -209,6 +212,7 @@ import (
xctx "github.com/go-gost/x/ctx"
xnet "github.com/go-gost/x/internal/net"
xhttp "github.com/go-gost/x/internal/net/http"
"github.com/go-gost/x/internal/util/httpcache"
stats_util "github.com/go-gost/x/internal/util/stats"
tls_util "github.com/go-gost/x/internal/util/tls"
rate_limiter "github.com/go-gost/x/limiter/rate"
@@ -302,6 +306,7 @@ func (h *httpHandler) Init(md md.Metadata) error {
CertPool: h.certPool,
MitmBypass: h.md.mitmBypass,
ReadTimeout: h.md.readTimeout,
Cache: httpcache.FromMetadata(h.options.Cache, md),
}
if h.md.certificate != nil && h.md.privateKey != nil {
@@ -353,6 +358,9 @@ func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
})
log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr())
// Save the raw conn before stats wrapping to preserve TLS metadata.
rawConn := conn
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
@@ -386,6 +394,11 @@ func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
}
defer req.Body.Close()
// Extract mTLS peer certificate from the raw connection.
if peerCert := getTLSPeerCert(rawConn); peerCert != nil {
ctx = xctx.ContextWithPeerCert(ctx, peerCert)
}
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
ro.ClientIP = clientIP.String()
}
@@ -445,20 +458,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 +479,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
@@ -543,6 +559,39 @@ func buildHTTPRecorder(req *http.Request) *xrecorder.HTTPRecorderObject {
}
}
// getTLSPeerCert extracts the verified mTLS client certificate identity from
// a connection by walking wrapper layers (traffic limiter, etc.) to reach the
// underlying *tls.Conn, then reading ConnectionState().VerifiedChains.
// It returns nil if the connection has no TLS peer certificate.
func getTLSPeerCert(conn net.Conn) *xctx.PeerCert {
for {
if tc, ok := conn.(interface{ ConnectionState() tls.ConnectionState }); ok {
cs := tc.ConnectionState()
if cs.HandshakeComplete && len(cs.VerifiedChains) > 0 && len(cs.VerifiedChains[0]) > 0 {
cert := cs.VerifiedChains[0][0]
fpr := sha256.Sum256(cert.Raw)
sans := make([]string, 0, len(cert.DNSNames)+len(cert.EmailAddresses)+len(cert.URIs))
sans = append(sans, cert.DNSNames...)
sans = append(sans, cert.EmailAddresses...)
for _, u := range cert.URIs {
sans = append(sans, u.String())
}
return &xctx.PeerCert{
CN: cert.Subject.CommonName,
SANs: sans,
Fingerprint: hex.EncodeToString(fpr[:]),
}
}
return nil
}
if uw, ok := conn.(interface{ UnwrapConn() net.Conn }); ok {
conn = uw.UnwrapConn()
continue
}
return nil
}
}
// observeStats periodically publishes per-client traffic stats to the
// configured Observer. It runs in a background goroutine started by Init.
// Events that fail to send are retried on the next tick.
+7 -2
View File
@@ -93,6 +93,11 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
*ro2 = *ro
ro = ro2
if v := req.Header.Get("Gost-Record"); v != "" {
ro.RecordMode = strings.ToLower(v)
}
req.Header.Del("Gost-Record")
host := normalizeHostPort(req.Host, "80")
ro.Host = host
ro.Time = time.Now()
@@ -177,7 +182,7 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
// Optionally intercept the request body for recording. The original
// body is replaced with a tee reader so the transport still sees it.
var reqBody *xhttp.Body
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && req.Body != nil {
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && req.Body != nil && ro.RecordMode != "headers" && ro.RecordMode != "off" {
reqBody = xhttp.NewBody(req.Body, bodySize)
req.Body = reqBody
}
@@ -226,7 +231,7 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
// Optionally intercept the response body for recording.
var respBody *xhttp.Body
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 {
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && ro.RecordMode != "headers" && ro.RecordMode != "off" {
respBody = xhttp.NewBody(resp.Body, bodySize)
resp.Body = respBody
}
+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})
}
+1 -1
View File
@@ -102,7 +102,7 @@ func (h *httpHandler) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Bu
MaskKey: fr.Header.MaskKey,
Length: fr.Header.PayloadLength,
}
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 {
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && ro.RecordMode != "headers" && ro.RecordMode != "off" {
buf.Reset()
if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil {
return err
+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))
+20 -6
View File
@@ -142,6 +142,14 @@ func (h *redirectHandler) Handle(ctx context.Context, conn net.Conn, opts ...han
"host": dstAddr.String(),
})
// Check bypass on dstAddr before sniffing — the sniffer only matches the
// sniffed req.Host, so IP/CIDR rules miss connections carrying a hostname.
if h.options.Bypass != nil &&
h.options.Bypass.Contains(ctx, dstAddr.Network(), dstAddr.String(), bypass.WithService(h.options.Service)) {
log.Debug("bypass: ", dstAddr)
return xbypass.ErrBypass
}
if h.md.sniffing {
if h.md.sniffingTimeout > 0 {
conn.SetReadDeadline(time.Now().Add(h.md.sniffingTimeout))
@@ -187,6 +195,18 @@ func (h *redirectHandler) Handle(ctx context.Context, conn net.Conn, opts ...han
return cc, err
}
// Dial the original dst instead of the sniffed host; sniffing's
// hostname bypass and recording still run beforehand.
if h.md.sniffingDialOriginalDst {
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", dstAddr.String())
ro.Route = buf.String()
return cc, err
}
}
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
return dial(ctx, network, address)
}
@@ -229,12 +249,6 @@ func (h *redirectHandler) Handle(ctx context.Context, conn net.Conn, opts ...han
log.Debugf("%s >> %s", conn.RemoteAddr(), dstAddr)
if h.options.Bypass != nil &&
h.options.Bypass.Contains(ctx, dstAddr.Network(), dstAddr.String(), bypass.WithService(h.options.Service)) {
log.Debug("bypass: ", dstAddr)
return xbypass.ErrBypass
}
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), dstAddr.Network(), dstAddr.String())
ro.Route = buf.String()
+2
View File
@@ -24,6 +24,7 @@ type metadata struct {
sniffing bool
sniffingTimeout time.Duration
sniffingFallback bool
sniffingDialOriginalDst bool
sniffingWebsocket bool
sniffingWebsocketSampleRate float64
@@ -43,6 +44,7 @@ func (h *redirectHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.sniffing = mdutil.GetBool(md, "sniffing")
h.md.sniffingTimeout = mdutil.GetDuration(md, "sniffing.timeout")
h.md.sniffingFallback = mdutil.GetBool(md, "sniffing.fallback")
h.md.sniffingDialOriginalDst = mdutil.GetBool(md, "sniffing.dialOriginalDst")
h.md.sniffingWebsocket = mdutil.GetBool(md, "sniffing.websocket")
h.md.sniffingWebsocketSampleRate = mdutil.GetFloat(md, "sniffing.websocket.sampleRate")
+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
}
}
+2 -2
View File
@@ -79,11 +79,11 @@ func (l *tcpListener) Close() error {
// the client that requested BIND receives forwarded connections as
// streams on the mux session.
type tcpHandler struct {
session *mux.Session
session mux.Session
options handler.Options
}
func newTCPHandler(session *mux.Session, opts ...handler.Option) handler.Handler {
func newTCPHandler(session mux.Session, opts ...handler.Option) handler.Handler {
options := handler.Options{}
for _, opt := range opts {
opt(&options)
+1
View File
@@ -57,6 +57,7 @@ func (h *relayHandler) handleForward(ctx context.Context, conn net.Conn, network
log.Error(err)
return err
}
ro.Node = target.Name
log = log.WithFields(map[string]any{
"dst": target.Addr,
+3 -1
View File
@@ -111,6 +111,8 @@ func (h *relayHandler) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
h.md.observerPeriod = mdutil.GetDuration(md, "observePeriod", "observer.period", "observer.observePeriod")
@@ -148,4 +150,4 @@ func (h *relayHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.limiterCleanupInterval = mdutil.GetDuration(md, "limiter.cleanupInterval")
return
}
}
+81 -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,9 +49,8 @@ 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/httpcache"
"github.com/go-gost/x/internal/util/sniffing"
tls_util "github.com/go-gost/x/internal/util/tls"
rate_limiter "github.com/go-gost/x/limiter/rate"
@@ -29,26 +64,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 +106,33 @@ 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,
Cache: httpcache.FromMetadata(h.options.Cache, md),
}
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 +168,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 +186,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)
}
}
+130
View File
@@ -0,0 +1,130 @@
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/httpcache"
"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
// Cache is the HTTP response cache (nil when caching is disabled).
Cache *httpcache.Cache
}
// 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,
Cache: b.Cache,
}
}
// 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),
)
+27 -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,19 @@ 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)
case socks.CmdResolve:
return h.handleResolve(ctx, conn, address, log)
case socks.CmdResolvePTR:
return h.handleResolvePTR(ctx, conn, address, log)
default:
err = ErrUnknownCmd
log.Error(err)

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