Compare commits

...
255 Commits
Author SHA1 Message Date
ginuerzh ec4800293d config/parsing/logger: say when a log file has no rotation
A configured output path without a rotation block is a plain file that nothing
rotates: it grows until the disk does something about it. The default logger —
the one an unconfigured program gets — always carries a rotation, so this only
happens to a hand-written config, and the parser is the only place that sees
both the path and the rotation that is missing. Reported on stderr, once, at
parse time; nothing about the behaviour changes.
2026-09-29 16:18:39 +08:00
ginuerzh 1fadb8fe38 config/parsing/logger: a log file that cannot be opened is not fatal
ParseLogger reported the failure through logger.Default(), which is nil until a
caller installs one — and this call is usually what installs it. An unwritable
path therefore dereferenced a nil logger and panicked; inside a c-shared library
that aborts the process, which on Android took an app down a second or two after
start with nothing readable left behind (an app's stderr goes nowhere, and the
tombstone carries no abort text).

Measured with Output "file" — a path, not a mode — in an app whose working
directory is "/" and not writable.

The failure goes to stderr instead, which is always there, and the default
output stays in place. The regression test forks a child process, because a
fresh one is the only place the default logger is still unset: TestMain skips
installing its logger for that child.
2026-09-29 16:03:21 +08:00
ginuerzh ce290f9143 feat(listener/tun): adopt a host-provided device fd on Android
An unprivileged Android app cannot open /dev/net/tun, so the device can only
come from a VpnService: the Java side creates and configures it (addresses,
routes, MTU, DNS via VpnService.Builder) and hands its fd over as the "fd"
metadata, either as a fixed descriptor or — for an embedder, which is what the
platform's own VPN is — as a provider resolved when each device is built, so a
device rebuilt after its session ended never copies a closed or recycled
descriptor.

The device is created with tun.CreateUnmonitoredTUNFromFD (no netlink listener,
no setMTU — the builder already set it), which makes the Linux variant, with
its netlink and resolvectl calls, inapplicable: it is now linux && !android.
The interface lookup in the accept loop was fatal on any error; it is
informational, and on Android the device may not be listed in this process.
2026-09-24 15:56:46 +08:00
ginuerzh 1776e1a4ef p2p: rename TunnelProvider to Tunnel and OpenTunnelStream to Dial 2026-09-22 15:17:10 +08:00
ginuerzh a9cc544470 docs: widen the rename plan to TunnelProvider -> Tunnel 2026-09-22 15:09:37 +08:00
ginuerzh 73baffc689 docs: plan the TunnelProvider.Dial rename 2026-09-22 15:06:42 +08:00
ginuerzh 5b85e90db8 chore(x): bump plugin to v0.8.1 and widen the p2p inner dialer list
Bump github.com/go-gost/plugin to v0.8.1, which reports transport stats
over the p2p Status RPC (direct/derp peer gauges plus punch/stream
counters), and pick up the matching go.sum entries.

SupportedDialer: also accept wss, grpc, http2, h2, ssh, sshd and mwss as
inner protocols on top of a p2p tunnel. The list stays fail-closed; each
entry is a dialer verified to dial one plain conn and use it as its base.
2026-09-20 21:23:22 +08:00
ginuerzh 3627030098 Revert "fix(forwarder): authenticate HTTP node with Proxy-Authorization"
This reverts commit 34fbff3d3c.
2026-09-20 20:51:30 +08:00
ginuerzh 34fbff3d3c fix(forwarder): authenticate HTTP node with Proxy-Authorization
Node auth on the HTTP forwarder path read the origin credential
(Authorization) and challenged with 401/WWW-Authenticate. When the
forwarded backend also used Basic auth, the node consumed the credential
meant for the backend, so the two could not coexist.

Read the proxy credential (Proxy-Authorization) instead and challenge
with 407/Proxy-Authenticate. Authorization is left untouched so it
reaches the backend, and Proxy-Authorization is stripped before the
upstream write since it is hop-by-hop (RFC 9110 11.7.2).
2026-09-20 20:43:11 +08:00
ginuerzh 06197bd987 fix(handler/tun): run keepalive on p2p links and expire stale routes
Three fixes found while wiring the p2p hub topology:

- The client keepalive was gated on network == "udp", but on a p2p link
  raddr is the peer's base64 key (no port), so SplitHostPort fails and
  network is "ip": the keepalive never ran, a tun server never learned the
  client's route, and the read deadline was never armed. Gate on the udp
  network OR a configured keepalive period; the udp branch keeps the one-shot
  registration handshake it always sent.
- Liveness was refreshed only by the keepalive echo; a transient channel
  hiccup can drop that single packet and redial a healthy tunnel after
  3xperiod. Refresh the read deadline after any successful read instead.
- Routes were never removed: a departed client's IP -> addr stayed forever
  and the device kept blackholing to a dead address. Expire a route once its
  last keepalive is older than 3x the configured period, checked lazily on
  the write path (no sweeper). The TTL is opt-in: without a server-side
  keepalive/ttl, routes keep their current never-expiring behavior.
2026-09-13 14:50:29 +08:00
ginuerzh a5605b935c chore(x): bump kcp-go to v5.6.72 and smux to v1.5.57 2026-09-12 12:07:28 +08:00
ihipop 8c999f1f5b feat(socks5): add optional UDP device binding (#127) 2026-09-12 10:37:46 +08:00
ginuerzh 7a4db061c9 fix(handler/sni): clear sniffing read deadline before the data path
The read deadline set for ClientHello sniffing was reset via defer, so it
remained on the client connection until Handle returned, i.e. for the whole
session. xnet.Pipe is called without a read timeout for TLS/HTTP, so nothing
refreshed it and any connection was torn down after readTimeout (15s default)
regardless of traffic. Clear the deadline immediately after sniffing, matching
the relay/router/tunnel/socks4 handlers.

Regression test asserts the deadline is zero when the upstream dial fires.

Fixes #904
2026-09-12 10:23:53 +08:00
ginuerzh a3e82c086c chore(x): bump github.com/go-gost/plugin to v0.8.0 2026-09-12 09:39:40 +08:00
ginuerzh ad702b4cbe test(p2p): feed chunks concurrently in the reassembly test
The test fed all 715 chunks before the first Read; with the cap-1 pump
handoff and a 64-slot fake stream the feeder blocks long before the
reader starts — a deterministic deadlock the earlier "passing" runs
masked (a `go test | tail` pipeline hid the exit code). Feed from a
goroutine.
2026-09-12 09:39:40 +08:00
ginuerzh 2cb1365d46 fix(p2p): release the datagram assembly buffer at frame boundaries
readDatagram kept appending into one backing array; an idle conn held
onto the largest frame's array indefinitely (bounded, but anchored until
a later append crossed capacity). Drain it to nil once a frame boundary
empties it, and assert the release plus the received-bytes-only bound in
the tests.
2026-09-12 09:21:59 +08:00
ginuerzh 4f3261a9d9 feat(p2p): move tunnel data onto the gRPC Tunnel stream
TunnelProvider becomes OpenTunnelStream: OpenTunnel authorizes the tunnel
and issues a single-use id, the Tunnel stream carries the data. The new
x/p2p/streamconn conn wraps the stream as a net.Conn with real
Set*Deadline support (bounded Recv pump, idempotent Close, framed mode
for udp). The tunnel base dialer collapses to the stream; its failure
path closes the conn nil-safely instead of leaking.
2026-09-12 08:19:21 +08:00
ginuerzh 988c9afe28 fix(masque): reap idle CONNECT-UDP associations
udp.Relay.Run ignored ctx cancellation, so an idle MASQUE association (or a
disconnecting client) leaked its target UDP socket and both copy goroutines,
and service shutdown blocked on the stuck handlers. quic-go's
ReceiveDatagram only unblocks on ctx cancellation, never on stream close, so
closing the datagram conn could not release the read either.

Make Relay.Run cancellation-aware and close both conns on exit, add
WithReadTimeout for an idle read deadline, give DatagramConn a cancelable
context that Close cancels, and apply the parsed idleTimeout to the UDP
relay.

Ref: https://github.com/go-gost/x/issues/128
2026-09-11 21:40:12 +08:00
ginuerzh 30f5a0b2c8 docs: add the pending issue and plan write-ups
Sixteen issue analyses, fourteen feature plans, and three superpowers
specs/plans that were sitting untracked.
2026-09-11 20:51:35 +08:00
ginuerzh 5bf06ef9ac docs(plans): drop the plans moved to the p2p repo
These four p2p phase plans now live in the p2p repo's docs/, alongside the
work they describe.
2026-09-11 20:50:46 +08:00
ginuerzh 8c255d8ec4 docs(plans): add the p2p tun link plan
Design and implementation notes for moving the tun-to-tun link out of the
p2p host: GOST owns the device, the host supplies a datagram tunnel. Records
how each review finding was closed and what the e2e run covered.
2026-09-11 20:43:00 +08:00
ginuerzh 54ea0220a9 feat(p2p): datagram tunnel endpoints for tun-to-tun links
OpenTunnel carries a network: "udp" asks the plugin for a datagram endpoint
(one datagram per write) instead of a byte stream, which is what a tun
handler needs — a stream endpoint coalesces and splits packets. tcp stays
the default, so existing deployments are untouched.

The tun handler could not reach client mode without a service forwarder, so
a chain with no forwarder now means client mode with an empty dial address:
the link is transparent and the peer comes from the chain node (a p2p node's
addr is its peer key). That combination used to be dead config, so it is
logged. A forwarder still wins when present, and server mode is unchanged.

Alongside: the udp dialer no longer hard-asserts *net.UDPConn (a wrapped
base dialer yields a plain conn), udp joins the p2p dialer whitelist, and a
udp tunnel announces its client with an empty datagram right after dialing —
the endpoint learns where to send peer packets only from a packet it
receives, so without it a link whose peer speaks first would never come up.

Requires plugin v0.7.0 for the new field.
2026-09-11 20:42:29 +08:00
ginuerzh f70b114578 plugin: manage external plugin subprocess lifecycle
Add Command []string to PluginConfig and a new x/plugin package that
spawns plugin subprocesses (deduplicated by argv) and terminates them
on Shutdown. Linux uses Pdeathsig, Windows a kill-on-close job object,
so children follow an abrupt parent exit.

Wire Spawn into every config/parsing plugin constructor.
2026-09-07 17:40:20 +08:00
ginuerzh 4cd6cd5c30 add p2p M2 hole-punch plan (STUN + KCP + smux direct path) 2026-09-07 15:16:48 +08:00
ginuerzh dcd02f8d52 fix(quic): don't close shared transport on invalid datagram
cipherConn.ReadFrom returned decrypt errors straight to quic-go, which
treats any packet-socket error as fatal and closes the shared QUIC
transport. A single malformed datagram (short, wrong key, tampered tag)
thus killed the whole listener while the process kept running.

Discard unauthenticated/invalid peer datagrams and keep reading; only
surface real socket errors and bad local AES keys. Also fix WriteTo to
return the caller-supplied length instead of the encrypted nonce+tag
length, per the net.PacketConn contract.
2026-09-06 23:03:35 +08:00
ginuerzh d7f0b88086 docs(plans): add p2p mux + token plan (phase one) 2026-09-06 13:35:18 +08:00
ginuerzh 71c1566b00 docs(plans): add p2p DERP relay plan (phase two) 2026-09-06 13:34:24 +08:00
ginuerzh 8127c99153 feat(p2p): lazy tunnel open, whitelist the tls/ws/mux inner dialer family
The tunnel is now opened inside the base dialer on first use: mux
inners (mtcp/mtls/mws) hit their session cache without touching the
base, so a cached session spends no OpenTunnel and leaks no tunnel
per dial. SupportedDialer grows to tcp/tls/ws/mtcp/mtls/mws (each
verified end to end; tls/ws handshakes trigger via the forwarded
Handshaker, mux inners reuse one tunnel as a session). Error paths
release the tunnel exactly once: base-dial failure resets the base
state, inner failure after a successful base dial is closed by the
wrapper, tunnelConn close clears the base state.

Plugin bumped to v0.6.1; QUIC evolution note (transparency principle,
no automatic inner substitution) recorded in the stub plan.
2026-09-06 00:17:10 +08:00
ginuerzh 967053c690 fix(mtcp): re-init session in Handshake when it has no mux session yet
A session Dialed but not yet Handshaken has session.session == nil; a
concurrent Dial treated it as dead (IsClosed() is true for nil sessions),
deleted it and closed the base conn under the first caller's Handshake.
Re-initializing on the nil-session case self-heals instead of failing
the identity check.
2026-09-06 00:17:10 +08:00
ginuerzh fe9d9c9574 add p2p plugin-backed tunnel dialer for chain nodes
Any dialer can now declare its base transport to run through a tunnel
opened by a named p2p plugin component: a top-level p2ps: section
registers TunnelProviders (gRPC plugin), and chain node metadata
"p2p: <name>" wraps the node's dialer so it dials the plugin's
OpenTunnel endpoint instead of the network directly. The inner dialer
keeps its protocol (tls/ws/mux) and Handshake, which the wrapper
forwards so it still triggers at Transport.Handshake.

- x/p2p: tunnelDialer (fail-closed, per-dial tunnel with idempotent
  CloseTunnel on conn close), SupportedDialer whitelist (tcp only;
  fail closed for unverified inner dialers), gRPC TunnelProvider
- config: P2PConfig, parsing, loader registration (reload-safe via
  unregister+Close), mergeConfig wiring
- dialer wrapper registered before Init so the chain inits once
- pin github.com/go-gost/plugin v0.6.0 (p2p proto); dependency bumps
  are a consequence of the new import
2026-09-05 21:25:13 +08:00
ginuerzh dadbbdf5c7 docs(plans): harden p2p stub plan after review
- inner dialer whitelist becomes prerequisite 3 (fail-closed, tcp only)
- record dead-session tunnel-recall fix and the known IsClosed race
- loopback default bind declared as the security boundary for the
  unauthenticated control channel (active-dial SSRF surface)
- contract scope narrowed to TCP-semantic byte pipes; UDP/datagram
  engines (hole punching) are an explicit future collision point
2026-09-05 16:39:27 +08:00
ginuerzh c210b74106 fix(mtcp,mws,mtls): close dead session's base conn before dropping it
Dial() deleted a dead muxSession without closing session.conn, leaking
the base TCP conn on every dead session (and, once a p2p base conn is
injected, leaking the plugin-side tunnel). session.Close() is a no-op
when session.session is nil, so the base conn is closed explicitly.
2026-09-05 16:39:27 +08:00
ginuerzh dac42ba39c fix(tun,tungo): release tun/tap device on listener close (gost#901)
The tun/tungo listeners never tore down the active device on Close, so
reloading (API config change or SIGHUP) with the same device name hit
"device or resource busy": the kernel kept the old interface because its
fd was still open, and the listen loop leaked devices created around
shutdown, spamming errors and blocking SIGTERM.

Track the accepted conn on the listener and close it (idempotently via
sync.Once folding cancel + fd close) from both the loop's shutdown branch
and Close(), with a send-race guard so a device created just as Close runs
is still released. Closing the fd removes the interface, so rebind with the
same name now succeeds and the process exits cleanly.
2026-09-05 11:48:52 +08:00
ginuerzh 53269265e1 refactor(config): swap yaml.v3 encoder for goccy/go-yaml
gopkg.in/yaml.v3 is archived/unmaintained upstream. Its only use in x is
Config.Write()'s YAML encoding, which is replaced with the already-present
goccy/go-yaml (compiled in via gin) — no new dependency. goccy output is
wire-equivalent (same keys/structure/omitempty); viper reads it back
identically, confirmed by the config round-trip tests. yaml.v3 drops to an
indirect dep (retained only by the inspector module in the workspace).
2026-09-02 18:00:43 +08:00
ginuerzh cfccd22fc9 refactor(ss,tap): migrate tap off upstream go-shadowsocks2 onto the fork
Drop the classic github.com/shadowsocks/go-shadowsocks2 dependency. tap and
internal/util/ss were its only remaining users after the ss/ssu handlers
moved to the go-gost fork. The fork's shadowaead is byte-identical on wire
(classic AEAD: same md5-KDF, HKDF-SHA1 subkey, salt sizes), so tap now uses
a small stateless ss.AEADPacketConn built on fork Pack/Unpack, and
ss.ShadowCipher becomes a fork builder (dummy/none -> existing none.Cipher).
Removes the undocumented tap handler key metadata and the dead ShadowConn
helper in the process.
2026-09-02 17:42:09 +08:00
ginuerzh de301e8cfb refactor(handler/http): drop govalidator dep, inline DNS name check
Replace the sole govalidator.IsDNSName call in normalizeRequest with a local
isDNSName helper that mirrors the library's exact behavior (empty/size guard,
net.ParseIP exclusion, and the same label regex), removing the
asaskevich/govalidator dependency from x.
2026-09-02 17:01:58 +08:00
ginuerzh e119a973f2 refactor(routing): drop predicate dep, parse matcher rules via go/ast
Replace the vulcand/predicate dependency in the routing rules parser with a
direct go/parser/go/ast implementation covering the same grammar (matcher
calls, parentheses, &&/||/!). This removes the predicate -> gravitational/
trace -> logrus logging chain from x, since trace was only used to build
errors. Public API (NewParser, Parser, TreeBuilder, Parse, CheckRule,
ParseMatchers) is unchanged and the existing test suite passes as-is.
2026-09-02 16:58:35 +08:00
ginuerzh 66960e7dd6 fix(redirect): skip pre-sniffing IP bypass check in whitelist mode
Domain-only whitelist bypass never matched the bare destination IP, so the
pre-sniffing bypass check (fe394a8) rejected connections before SNI sniffing
could match the sniffed host (gost#899).

- skip the pre-sniffing check when bypass IsWhitelist(); the sniffer applies
  the whitelist against the sniffed host
- restore the post-sniffing bypass check on dstAddr (removed by fe394a8) so
  whitelist mode still rejects non-matching hosts on the sniffingFallback
  path and when sniffing is disabled
- bypassGroup.IsWhitelist() now reports true only for all-whitelist groups
  instead of always false (sole external consumer is the redirect handler)
- rewrite handler tests to drive a real TLS ClientHello through Init()'d
  metadata: whitelisted SNI is forwarded, non-whitelisted SNI gets ErrBypass
2026-08-31 21:45:28 +08:00
ginuerzh 14fed91c62 httpcache: guard Turbo short-circuit responses, add StoreKey
Cacheable() now refuses X-Turbo-Short-Circuit responses (partial <meta>
payloads meant to merge client-side, would serve blank pages on later
hits). StoreKey() lets callers whose request identity is rewritten before
store (e.g. mirror Host routing) pin the key so Lookup and Store agree.

Bump github.com/go-gost/core to v0.6.1.
2026-08-29 09:23:26 +08:00
ginuerzh 2299c43bb7 forwarder: HTTP header rewrite (regex + plugin) and hostPattern wiring
Implement the header counterpart of the existing body rewrite:

- rewriteHeaderBlock applies rewrite rules to an http.Header in place.
  Regex mode matches header names case-insensitively (per HTTP spec) and
  rewrites each value independently; an empty replacement drops the value,
  and a fully-empty header is deleted. Plugin mode serializes the header
  block (http.Header.Write + trailing CRLF, since ReadMIMEHeader needs the
  blank line), sends it to a core/rewriter.Rewriter plugin, and parses the
  result back with clear+copy (h is a map; reassignment would not escape).
- rewrite_metadata.go consolidates the hand-rolled plugin metadata maps
  (baseMetadata + three SSE inline maps) into rewriteMeta() with common
  sid/direction/uri fields and a new kind (body|header), additive for
  existing plugins.
- sniffer_http.go wires rewriteReqHeader before the request is written
  upstream (and refreshes the recorder header clone) and rewriteRespHeader
  after static response-header overrides, before the recorder clone.
- hostPattern: derive req.Host from the request path via regex submatch
  expansion (Host = $1-style template), for Host-routed mirror origins.
- parse: parseHeaderRewrites gates on the config string (regexp.Compile("")
  yields a non-nil match-all, so gating on the compiled regexp would turn
  an empty name into a silent no-op rule).

Fixes found by e2e: reqHeaderRewrites was assigned after its guard, so the
request header rewrite never ran; tests cover the header round-trip,
plugin gating, metadata kind, and hostPattern expansion.
2026-08-29 08:37:44 +08:00
ginuerzh dd65d46926 fix(handler/http): sync Gost-Target override into req.URL.Host to prevent policy bypass
A valid Gost-Target / X-Gost-Target header replaced only req.Host while
leaving req.URL.Host untouched. Destination-policy checks (bypass) evaluated
the header-derived authority, but http.Transport dialed the unchanged
absolute-URL authority. A client could label a forbidden URL with an allowed
authority and reach the blocked destination (403 -> 200 with backend body).

setRequestHost now applies the decoded target to both req.Host and req.URL.Host
so policy evaluation and transport dialing always resolve to the same authority.
2026-08-27 20:16:22 +08:00
ginuerzh 2e9befc39c fix(handler/http): resolve origin-form requests via Host header (#679)
Origin-form requests ("GET / HTTP/1.1" with only a Host header, as
sent by nginx proxy_pass to an upstream proxy) failed RoundTrip with
"http: no Host in request URL": normalizeRequest infers URL.Scheme
but leaves URL.Host empty, and http.Transport requires a non-empty
URL.Host. Also affected every keep-alive request after the first,
which bypass normalizeRequest entirely.

Fill scheme+Host from req.Host in the shared proxyRoundTrip path,
covering both initial and subsequent requests.

e2e-verified: raw origin-form request proxied by Host header
(gost/tests/e2e TestHTTPProxyOriginForm).
2026-08-27 00:02:13 +08:00
ginuerzh 93bd2ea760 fix(hop): parenthesize host OR and normalize wildcard in filter->matcher rule
filterToMatcherRule produced `Host(x) || Host(.x) && Proto(...) && ...`,
but the matcher DSL (vulcand/predicate) binds && tighter than ||, so the
apex Host(x) branch bypassed the protocol/path conjuncts — a request to
the apex with a non-matching protocol/path matched where the legacy
filter (AND of host/protocol/path) rejected it.

Also normalize `*.example.com` to `.example.com` before the leading-dot
branch: legacy ParseNode did this so both forms matched apex+subdomains;
without it, `*.example.com` reached Host() as a wildcard matching only
subdomains, dropping apex coverage that `.example.com` still had.

- Parenthesize the host OR when protocol/path conjuncts follow.
- Strip leading `*` + ensure leading dot, mirroring legacy normalization.
- Update TestFilterToMatcherRule expectations; add dot/star combined cases.
- Add TestFilterToMatcherRule_Precedence: runtime guard that apex+wrong
  proto/path is rejected and apex/subdomain+valid is accepted, for both
  `.example.com` and `*.example.com` forms.
2026-08-26 21:02:50 +08:00
ginuerzh 9292e0af3e fix(tunnel, forward): route tunneled hostname through hop filter.host
The tunnel bind conns (bindConn/bindUDPConn) now expose their metadata (the
tunneled hostname) via the xctx.Context interface, so the service accept loop
forwards it into the handler context. handleRawForwarding selects a hop node by
that hostname when a hop is configured, so a node with a matching filter.host is
picked; otherwise it falls back to dialing the hostname directly. Fixes
reverse-proxy-tunnel TCP routing (gost#898) where the docs example failed with
"node not available" because the mux path never set the host metadata.
2026-08-26 20:28:37 +08:00
ginuerzh 6ad7269644 refactor(hop): normalize deprecated node filter into matcher at parse time
Fold the deprecated per-node filter (host/protocol/path) into an equivalent
matcher DSL rule during config parsing, so hop.Select keeps a single
filtering path (routing.Matcher). Legacy filter configs stay accepted.

- filterToMatcherRule() maps host->Host(), protocol->Proto(), path->PathPrefix();
  a leading-dot host emits `Host(x) || Host(.x)` to preserve the legacy
  apex+subdomain coverage. Converted rules get priority=-1 to keep the
  always-through-selector behavior of the legacy path.
- Remove isEligible/checkHost/checkProtocol/checkPath from x/hop.
  core/ chain.NodeFilterSettings is left untouched (dead but harmless).
- Migrate hop tests to matchers; add TestFilterToMatcherRule.
2026-08-26 20:26:26 +08:00
ginuerzh 4ea4efaba9 fix(forwarder, sniffing): assert BasicProxy recording via captured output
Since 486a7766 HandleHTTP records on an internal clone of the passed
recorder object, TestHandleHTTP_BasicProxy's assertions on the original
ro were always empty, failing every run. Assert SrcAddr/DstAddr on the
captured recorder payload instead, and drain the response body before
closing the net.Pipe so HandleHTTP completes the write and exits cleanly.
2026-08-25 22:17:05 +08:00
ginuerzh faeede7881 feat(forwarder): delete JSON body fields via json-: rewrite match
Add a json-: match prefix to http.rewriteRequestBody/rewriteResponseBody
that deletes the matched JSON field (sjson.DeleteBytes) instead of
replacing it, keeping the existing value-regex conditional semantics.
2026-08-25 21:54:27 +08:00
ginuerzh a38a701dc4 refactor(sniffing): register protocol handlers via init() registries
Replace the 5 duplicated switch statements that dispatch sniffed
connections (forward/local, forward/remote, http CONNECT, sni, relay)
with per-package registries keyed by proto string. Protocol handlers
self-register via init(), following the house registration pattern, so
adding a protocol no longer requires touching every dispatch site.

Unify the two HandleOptions types (forwarder lacked dialTLS; sniffing
lacked hop/node/httpKeepalive) into one exported struct in sniffing;
forwarder aliases it. Unknown protocols fall through to raw forwarding
exactly as before; SSH remains detection-only. redirect/tcp's special
fallback-replay dispatch is intentionally left untouched.

Adds Dispatch helpers and 8 registry tests (register/get/duplicate/
unknown/known) in both packages. Net -151 lines.
2026-08-25 21:35:45 +08:00
ginuerzh 0132cefaec fix(api): save config back to the loaded file path (gost/x#123)
POST /config without an explicit path= always wrote gost.yaml/gost.json to
the process CWD, silently leaving the config loaded via -C untouched. Track
the last file read by ReadFile and default the save target to it, deriving
the output format from the file extension so a JSON config is saved back as
JSON. Falls back to the CWD default when no config file was loaded.
2026-08-21 21:43:17 +08:00
ginuerzh 25cd60d42d fix(connector/ss): own the target addr instead of aliasing the pooled buffer (gost#896)
WrapConn stores the target and only writes it on first I/O, but the
connector encoded it into a bufpool buffer that was returned to the pool
when Connect returned. A later Connect could reuse the backing array and
overwrite the bytes, misrouting the first connection. Allocate the target
per connection instead of sharing a pooled buffer.

Regression test drives two Connect calls and asserts the first conn's
wire target survives a second Connect reusing the pool.
2026-08-21 21:42:43 +08:00
ginuerzh f8ca4908f3 fix(hop): skip failed nodes before priority short-circuit, last-resort fallback
Failed nodes (http.failCodes marker) were still selected when the
priority short-circuit fired or when FailFilter saw a single-node tier,
so failover never happened for a high-priority matcher node returning
429. Check IsFailed first in the candidate-pool scan, and when every
candidate is failed return the first node matching the request gates as
a best-effort last resort instead of nil.

Refactors the fail-code check into shared selector.IsFailed (cee264e)
wired through FailFilterSettingsOption (64ea518).
2026-08-18 23:42:08 +08:00
ginuerzh cee264e184 refactor(selector): share IsFailed dead-node check with FailFilter
Extract the per-item failed-state decision (failure count >= maxFails,
last failure within failTimeout, metadata overrides) from FailFilter
into an exported IsFailed helper, and reuse it in the hop's
pre-selection dead-node filter instead of a local duplicate.
2026-08-18 23:21:34 +08:00
ginuerzh 64ea5184f8 fix(hop): drop marked-failed nodes before priority selection
http.failCodes (and probe) marking had no effect when the failing node
had a strictly higher matcher priority: the priority short-circuit
returned the top node directly, and FailFilter no-ops on a single-node
tier (len(vs) <= 1 guard), so the marked node was always re-selected
and failover never happened.

Pre-filter nodes marked failed (same Count >= maxFails && within
failTimeout criteria as FailFilter) out of the candidate pool before
priority selection. Single-node hops keep their lone node (safety net),
and an all-failed pool returns nil as before.

Wired maxFails/failTimeout from the hop selector config into the hop
via FailFilterSettingsOption.
2026-08-18 23:15:59 +08:00
ginuerzh fc5fc052e4 chore(deps): bump go-shadowsocks2 to v0.1.4
Pulls in the classic-AEAD UDP [addr][payload] response-framing fix that
restores shadowsocks UDP interop with third-party servers/clients
(go-shadowsocks2, shadowsocks-libev). Fixes go-gost/gost#894.
2026-08-18 22:17:20 +08:00
ginuerzh e6aeba8701 fix(listener/redirect/udp): set SO_REUSEADDR on tproxy listener socket (gost#413)
Match the dialUDP socket so per-packet tproxy sockets can bind a
destination port overlapping this listener without "address already in use".
2026-08-18 21:55:12 +08:00
ginuerzh cc02cff00c fix(logger): deduplicate keys across WithFields chains (gost#895)
slog.Logger.With does not deduplicate keys, so chained WithFields calls
(e.g. service -> listener/handler, each setting "kind") emitted duplicate
JSON keys. slogLogger now rebuilds from a pristine base logger with
accumulated attrs deduplicated by key, last write wins.
2026-08-18 21:49:11 +08:00
ginuerzh 18df26e9bc Merge branch 'master' of github.com:go-gost/x 2026-08-17 23:05:14 +08:00
Vincent Yang a9be7e6dce fix(masque): repair server CONNECT-UDP relay (#122)
The MASQUE handler's CONNECT-UDP path was broken in two ways, so no
standards-compliant client (e.g. Surge) could establish UDP tunnels:

1. Missing Capsule-Protocol response header. HTTPStream() flushes the
   response internally and commits the headers, but it was called before
   Header().Set("Capsule-Protocol", "?1"), so the mandatory RFC 9298
   header was silently dropped and clients rejected the tunnel.

2. WriteTo on a pre-connected socket. A direct UDP dial via the router
   returns a *net.UDPConn that satisfies net.PacketConn but is
   pre-connected; the relay called WriteTo on it, which fails with "use
   of WriteTo with pre-connected connection", so no packet reached the
   target.

Set the Capsule-Protocol header before hijacking the stream (dialing the
target first, matching handleConnectTCP), and always wrap the
router-returned conn so writes go through Conn.Write. Verified with a
standards-compliant HTTP/3 MASQUE client: CONNECT-UDP now returns
Capsule-Protocol: ?1 and datagrams relay end to end; CONNECT-TCP
unaffected.
2026-08-17 23:05:00 +08:00
ginuerzhandgithub 0e0eab0c52 fix(cmd): restore unix socket port-forward semantics
Commit b3b5986 crammed the URL path into the listen/connect address and
disabled forward mode for path-based protocols, breaking the
`unix://a.sock/b.sock` (listen on a, forward to b) port-forward form and
producing an invalid `a.sockb.sock` address.

Distinguish the two URL forms by host presence:
- three-slash (unix:///abs.sock, empty host) → path is the absolute socket
  address, no forward target;
- two-slash (unix://a.sock/b.sock) → host is the socket and the path is a
  forward target (service) or ignored (node).

Add regression tests for both forms.

Co-Authored-By: Claude (claude-opus-4-8[1m])
2026-08-17 22:51:00 +08:00
Dean Chen 98e9651361 cmd: keep absolute unix socket paths for path-based schemes (#121)
Path-based address selection used the dialer/listener type after registry
fallback, so unix remapped to tcp skipped EscapedPath and dropped
addrs like /var/run/docker.sock from three-slash URLs.

Decide path-based protocols from the original scheme components.

Signed-off-by: Dean Chen <862469039@qq.com>
2026-08-17 22:29:17 +08:00
PahaKush e4e36ca095 x: stream websocket reads instead of buffering each frame (#120)
websocketConn.Read called Conn.ReadMessage, which is NextReader plus
io.ReadAll. io.ReadAll starts at 512 bytes and grows by doubling, so every
frame allocated a fresh slice and a chain of intermediate ones: a stream of
64KB frames costs about 2.5 bytes of garbage per byte carried, even though
the caller already supplied a buffer to copy into.

On a relay+wss tunnel moving 750MB this was 2526KB of garbage per MB of
payload and 757 GC cycles in 15 seconds against a 1.5MB live heap. An
allocation profile attributed 99.92% of all process garbage to io.ReadAll
under ReadMessage; a CPU profile spent 15% in memclrNoHeapPointers and ~21%
in the collector, against 8% in AES-GCM.

Use NextReader and copy straight into the caller's buffer. The reader is
kept across calls and dropped at end of message, so a message larger than
the caller's buffer is still delivered across successive reads.

This also fixes a zero-length message surfacing as a (0, nil) read, which
io.Copy counts as no progress and rejects after 100 in a row.

Benchmark on 64KB frames:

    before  39267 ns/op  1668.97 MB/s  138444 B/op  19 allocs/op
    after   19920 ns/op  3289.98 MB/s      80 B/op   3 allocs/op
2026-08-17 22:28:48 +08:00
Niek van der Maas 746811eea5 fix: support SOCKS5 UDP associations over MASQUE (#119)
* fix: support SOCKS5 UDP over MASQUE

* fix: complete MASQUE SOCKS5 proxy support

* fix: isolate MASQUE UDP destinations
2026-08-17 22:28:20 +08:00
ginuerzh 7f72a81864 x: fix SSH authorized_keys parse failing on trailing newline (gost#699)
ParseAuthorizedKeysFile looped over ssh.ParseAuthorizedKey, but x/crypto's
parser splits on '\n' and returns rest='\n' for a trailing newline. The next
iteration called ParseAuthorizedKey('\n'), which skips whitespace and returns
'ssh: no key found', aborting the whole parse. Any normal authorized_keys file
(ends with newline, or has comments/blank lines) failed despite being valid.

Break the loop once rest is exhausted so all keys are parsed.
2026-08-01 18:24:10 +08:00
ginuerzh 2112532e0a x: set TCP_NODELAY on ws/wss/tls transports to cut relay UDP latency
Disable Nagle on the underlying TCP socket for relay+ws, relay+wss, and
relay+tls listeners/dialers. Small latency-sensitive datagrams (e.g.
WireGuard UDP over a relay tunnel) were being coalesced by Nagle until a
peer ACK returned, adding up to one RTT per datagram and inflating
round-trip latency (go-gost/gost#858). wstunnel sets TCP_NODELAY on its
tunnel socket by default and keeps latency flat; gost did not.

Add ApplyNoDelay helper and WrapNoDelayListener (mirrors ApplyKeepalive),
applied on the raw *net.TCPConn before the proxyproto/tls wrapper chain.
2026-08-01 16:59:54 +08:00
ginuerzh b2d901e70f x: add Redis TCP sniffing with protocol-based routing
Add RESP protocol detection to the sniffing framework so plaintext
Redis traffic on a port-reuse listener can be identified and routed by
protocol. Sniff() now peeks 1 byte first to match RESP type bytes
(*+-:$) without blocking, then falls through to the existing 5-byte
TLS/HTTP/SSH detection.

ParseRedisMetadata consumes exactly one RESP message via io.TeeReader
and records command/key in HandlerRecorderObject.Redis. HandleRedis in
both the sniffing (injected-dial) and forwarder (hop-selected) packages
replays the captured first message and pipes the rest, so pipelined
commands beyond the first are forwarded transparently.

Wired into all 5 dispatch sites: sni, forward/local, forward/remote,
http CONNECT, and relay tunnel handlers. forwarder paths route via
resolveRedisNode using ProtocolSelectOption(ProtoRedis), enabling
`protocol: redis` hop selection.

Part of two-phase plan (2026-07-29-sniffing-redis-postgres.md);
PostgreSQL deferred to phase 2.
2026-08-01 16:03:23 +08:00
ginuerzh b106f764a6 x: bump quic-dissector to v0.1.0, remove merged plan docs
Bump quic-dissector dependency from pseudo-version to tagged v0.1.0.
Remove docs/plans that have been implemented and merged.
2026-08-01 16:03:23 +08:00
ginuerzh edb0b5d651 x: extract shared QUIC sniffing helpers, add redirect UDP QUIC sniff
Extract PopulateQUICClientHello/PopulateQUICServerHello into
internal/util/sniffing/quic.go to deduplicate the QUIC ClientHello/ServerHello
population logic that was copy-pasted across forward/local (Handle, handleRawDatagram)
and redirect/udp handlers (3 sites → 2 helpers).

- handler/redirect/udp: add QUIC ClientHello + ServerHello sniff with
  sniffing/sniffing.timeout metadata, populating ro.Proto/ro.TLS for recording.
- handler/forward/local: replace inline QUIC blocks with shared helpers;
  use h.md.bufferSize instead of hardcoded 8192 for sniff reads.
- internal/util/sniffing: add ProtoQUIC constant.

Bump quic-dissector dependency to v0.1.0.
2026-08-01 16:03:23 +08:00
dependabot[bot] e3e41abd66 build(deps): bump github.com/pion/dtls/v3 from 3.1.1 to 3.1.4 (#118)
Bumps [github.com/pion/dtls/v3](https://github.com/pion/dtls) from 3.1.1 to 3.1.4.
- [Release notes](https://github.com/pion/dtls/releases)
- [Commits](https://github.com/pion/dtls/compare/v3.1.1...v3.1.4)

---
updated-dependencies:
- dependency-name: github.com/pion/dtls/v3
  dependency-version: 3.1.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 15:59:34 +08:00
Anivice IvesandYour Name 221144fab3 fixed the issue where the pull request to exit and subsequent application writes to fail immediately in gost (#117)
Co-authored-by: Your Name <you@example.com>
2026-08-01 15:59:08 +08:00
ginuerzh 6b1c6757f6 x: record cache-hit status in HTTP recorder object 2026-07-29 23:02:18 +08:00
ginuerzh 3157436140 x: add Redis backend for cache
Adds a RedisCache config block and redisCache implementation for the
generic cache.Cache interface, enabling distributed HTTP response
caching across gost instances.

go-redis/redis/v8 was already a dependency (used by recorder and
internal loader) — no new imports needed.
2026-07-29 22:39:19 +08:00
ginuerzh 0ab5363f60 x: distinguish client vs server errors in service handler loop
The service Serve loop counted every handler error — including client-side
disconnects (io.EOF, net.ErrClosed, context cancellation) and policy
decisions (rate limiting, bypass) — as server handler errors, inflating
the error counter and log noise. isServerError() now gates the error-level
log, error counter, and stats increment; non-server errors are logged at
debug level only.
2026-07-29 21:44:12 +08:00
ginuerzh 88670baeae x: don't overwrite ro.Host when TLS SNI is empty
The previous empty-SNI fix clobbered ro.Host with the listener's local
address. For handlers that set ro.Host before HandleTLS (SOCKS5 via the
CONNECT address, SS via target), that destroyed the real destination used
for recording and bypass. Now ro.Host is only set when SNI is present;
the caller's pre-set value is left intact otherwise.
2026-07-29 21:42:56 +08:00
ginuerzh 226420a622 x: allow empty TLS SNI in sniffing handlers
Clients connecting to HTTPS by raw IP (e.g. https://1.1.1.1) don't
send SNI per RFC 6066. Both HandleTLS methods errored immediately
on empty SNI, closing the connection. Now they fall through to the
normal routing path — hop selection in the forwarder, or the caller's
dial closure in the sniffing sniffer (e.g. HTTP CONNECT) — which
handles the empty host gracefully or fails naturally.
2026-07-28 23:52:29 +08:00
ginuerzh 3cd4fad4bf x: close forwarder hop probe goroutines on service shutdown
Same leak class as the previous chainGroup fix: a forwarder built from
a hopGroup or inline nodes starts probe goroutines at parse time, but
nothing closed them — every SIGHUP reload leaked them permanently.

- parseForwarder returns an io.Closer for service-owned hops (hop
  group or inline nodes); registry-owned named hops return nil since
  the registry closes them on unregister
- ParseService registers the closer via ClosersOption and closes it
  when construction fails partway
- hopGroup.Close no longer closes the underlying hops: they come from
  the HopRegistry and are shared with other referents; closing them
  here would kill shared state
2026-07-28 23:45:40 +08:00
ginuerzh 291fa48fae x: add -race test coverage for chainGroup matcher/probe/close paths
Adds tests for the new chainGroup code paths the matcher+probe review flagged
as untested: matcher filtering in selectChain, probe goroutine cleanup on
Close(), and probe marking the entry marker on cmd failure. All run clean under
`CGO_ENABLED=1 go test -race`.
2026-07-28 23:36:08 +08:00
ginuerzh 6e9ff675b5 x: extract duplicated cmd-probe logic into probe.RunCmdProbe
chainGroup and hopGroup had verbatim copies of the cmd-probe block (timeout
default, CmdProber run, marker Mark/Reset, log). Move it to
internal/probe.RunCmdProbe(cfg, marker, log) so both call sites share one
implementation (cleanup5 from the chainGroup review).
2026-07-28 23:31:15 +08:00
ginuerzh 506edffc93 x: skip eligible allocation when no matchers; always run FailFilter
In selectChain: precompute whether any entry has a matcher and, when none do,
pass entries straight to the selector instead of allocating a new slice on every
Route() call. Drop the single-eligible-entry shortcut so the selector's
FailFilter always runs and can exclude probe-failed chains (design6).

Combined fix for efficiency4 (hot-path allocation) and design6 (single-match
bypass of FailFilter/probe markers) from the chainGroup review.
2026-07-28 23:26:54 +08:00
ginuerzh 6248d4c0f8 x: derive probe goroutine context from lifecycle ctx (bug2)
probeEntry used context.Background() for Route/Dial, so an in-flight probe
could not be cancelled by Close()/reload. Thread the lifecycle ctx through
probeEntry so a stuck route aborts when the group is torn down.

Also promote mapstructure to a direct dependency (now imported by config.go).
2026-07-28 23:25:28 +08:00
ginuerzh 50f4a6b096 x: close chainGroup probe goroutines on service shutdown
chainGroup.Close() existed but was never called: the group is wrapped
in a chain.Router held by the listener/handler, and service.Close()
only closed the handler, observer, and listener. Every SIGHUP reload
leaked the old config's probe goroutines permanently.

Wire the close path end to end:
- Router.Close() closes its chain when it implements io.Closer
- service ClosersOption registers io.Closers called on shutdown
- ParseService passes both routers via ClosersOption, and closes
  them on construction failure so probes started early don't leak
2026-07-28 23:14:27 +08:00
ginuerzhandClaude 30ba6d64a5 x: add matcher + probe to chainGroup with independent entry marker
chainGroup now selects chains in two stages: a matcher filter (by host/network)
builds an eligible pool, then the group selector applies FailFilter + strategy.
Each entry carries its own failure marker so chain-level probe marking is
independent of node-level failCodes markers (probe Reset() can't undo a node
failCodes Mark()).

Config gains ChainGroupEntry (chain/matcher/probe) replacing the old []string,
with a mapstructure decode hook preserving backward-compatible "chains: [a, b]"
string lists and time.Duration string parsing. Probe goroutines run per-entry
and mark the entry on failure.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:20:34 +08:00
ginuerzh 72db96d4cd x: add hopGroup for conditional hop-level selection with probe/failCodes marker separation
hopGroup wraps named hops with per-entry matcher + probe, implementing
hop.Hop for drop-in compatibility with the existing Forwarder.Forward().

Core design: hopEntry.marker (probe, hop-level) is independent of
node.Marker() (failCodes, node-level) so probe Reset() cannot undo a
failCodes Mark() — the two mechanisms no longer conflict on the single
chain.Node marker.

- hop/group.go: HopEntry + hopGroup + probe infrastructure
- config/config.go: ForwardHopGroupConfig, ForwardHopConfig types
- config/parsing/service/parse.go: parseHopGroup, HopGroup > Hop > Name > Nodes precedence
- config/parsing/node/parse.go: export parseProbeConfig → ParseProbeConfig
2026-07-27 22:08:34 +08:00
ginuerzh 8edf82af9b hop: fix backup-priority interaction — primary beats backup regardless of priority
Stage 3 shortcut now gates on isBackupNode(maxPriNode) instead of
hasBackup — the old check disabled priority short-circuit whenever any
backup existed anywhere in the pool, even when the winning node was a
primary.

Stage 4 tier loop gains hasAnyPrimary scan + backup-tier skip — a
lower-priority primary always beats a higher-priority backup tier.
Nil-selector path now returns the first node of the first non-skipped
tier instead of unconditionally returning maxPriNode.
2026-07-27 22:06:37 +08:00
ginuerzh 839f2a280b chore: bump deps (tls-dissector v0.3.1, quic-go v0.60.0, webtransport-go v0.11.1) 2026-07-26 22:58:52 +08:00
ginuerzh c23a2f1e53 fix(redirect/tcp): sniffingFallback with empty SNI falls through to raw forwarding
When sniffing detects TLS but the ClientHello has no SNI, HandleTLS
errors with 'tls: sni is empty, closing connection'. Previously this
error was returned unconditionally even with sniffingFallback enabled.

Now, bytes consumed by the sniffer are captured via io.TeeReader and
replayed through io.MultiReader when sniffingFallback is true, allowing
the connection to fall through to raw forwarding to the original dstAddr.
2026-07-26 22:27:44 +08:00
ginuerzh 0ffc000d0d fix(forwarder/sniffer): update ho.recorderObject after ro copy to prevent nil HTTP panic
The HandleHTTP ro clone (486a776) only updated the local ro variable, but
dial() reads ho.recorderObject directly, getting the original with nil HTTP
field, causing nil pointer dereference at ro.HTTP.StatusCode access.
2026-07-26 22:09:55 +08:00
ginuerzh 486a77665d fix: prevent double-recording when HTTP response cache is enabled
The cache-hit path (serveCachedResponse) recorded on the same HandlerRecorderObject
as the calling handler's deferred Record(), producing two records per request.
Copy ro at HandleHTTP entry so the sniffer's internal recording stays isolated
from the caller's defer. Also zero ro.Time when the sniffer takes over, so the
handler's defer Record() skips the now-redundant connection-level record.

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

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

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

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

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

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

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

Fixes go-gost/gost#228
2026-06-20 20:47:10 +08:00
ginuerzh 548471717a fix(dialer): detect dead QUIC sessions in ICMP/QUIC dialer caches
When a QUIC session's MaxIdleTimeout expires, the underlying *quic.Conn dies
but the dead quicSession stays in the sessions map. The next Dial() finds
it and calls session.GetConn() which calls OpenStreamSync(context.Background())
against the dead connection — blocking indefinitely.

Add IsClosed() to both quicSession types (using the session context's Done()
channel) and add the liveness guard in both Dial() methods, matching the
existing pattern in KCP and MASQUE dialers.

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

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

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

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

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

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

Fixes go-gost/gost#89
2026-06-20 15:54:39 +08:00
ginuerzh 9fcd7d6890 feat(dialer): add utls dialer for TLS ClientHello fingerprint simulation
Add a new "utls" dialer type that uses the refraction-networking/utls
library to mimic browser TLS fingerprints (Chrome, Firefox, Safari, etc.).

- x/dialer/utls/dialer.go: Registered as "utls", mirrors the TLS dialer
  with Handshake() using utls.UClient() for fingerprint emulation.
- x/dialer/utls/fingerprint.go: Maps string names (chrome, firefox, ios,
  safari, edge, randomized, golang, custom) to utls.ClientHelloID presets.
- x/dialer/utls/metadata.go: Parses fingerprint from dialer metadata.
- go.mod: Add github.com/refraction-networking/utls v1.8.2.

The fingerprint is dialer-only metadata — the standard TLS dialer is
untouched. Falls back to crypto/tls if no fingerprint is configured.

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

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

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

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

Fixes #103
2026-06-20 15:01:14 +08:00
ginuerzh 296d87a597 fix(quic): preserve user-configured ALPN instead of hardcoding NextProtos
The QUIC listener and the QUIC/ICMP dialers unconditionally set
tlsCfg.NextProtos = ["h3", "quic/v1"], discarding any user-specified
ALPN values from TLS options (via tls.options.alpn in config).

Clone the TLS config and only apply the default NextProtos when none are
already configured, matching the existing pattern in the MASQUE HTTP/3
dialer. This enables non-HTTP/3 QUIC use cases like SMB-over-QUIC which
requires ALPN "smb".

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

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

Fixes:

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

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

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

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

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

Add no-op stubs to all remaining platform files so the dialer package
compiles on every GOOS.
2026-06-18 14:33:02 +08:00
ginuerzh a86b5ab66b feat(ss): support none/dummy cipher via no-op AEAD implementation
The go-gost fork of go-shadowsocks2 dropped built-in support for the
none/dummy cipher that was present in the original go-shadowsocks2.
Combined with the SS components' hard requirement for auth (and
therefore a valid cipher), this made gost fail to start with
method=none or method=dummy.

Add a no-op cipher in x/internal/util/ss/none/ that implements the
core.ShadowCipher interface using the standard go-shadowsocks2 AEAD
framing (salt + length-prefixed chunks, target address embedding)
without actual encryption.  SaltSize=16 ensures random salts on every
connection to avoid bloom-ring replay detection.

Update SS TCP connector/handler and SS UDP connector/handler to detect
method=none/dummy (case-insensitive) and use the no-op cipher instead
of utils.NewClientConfig/NewServerConfig.

Fixes go-gost/x#105
2026-06-17 22:18:23 +08:00
ginuerzh 183252f6ae Merge branch 'master' of github.com:go-gost/x 2026-06-17 21:56:52 +08:00
BBQ 301dbc56d0 fix(listener): keep *net.UDPConn visible to quic-go for GSO/GRO
The quic and wt (WebTransport) listeners wrap the raw UDP socket with
net.PacketConn metrics/stats/admission/limiter wrappers before handing
it to quic-go. WrapPacketConn returns a plain net.PacketConn, which
hides the underlying *net.UDPConn. quic-go probes the conn for
OOBCapablePacketConn (SyscallConn, SetReadBuffer, ReadMsgUDP,
WriteMsgUDP); behind the wrapper that probe fails, so quic-go
disables its kernel offloads and cannot size its receive buffer.

Switch the non-cipher path of both listeners to WrapUDPConn variants,
which forward SyscallConn/SetReadBuffer/ReadMsgUDP/WriteMsgUDP down to
the underlying *net.UDPConn, so quic-go sees an OOBCapablePacketConn
and keeps GSO/GRO + buffer sizing. The same metrics/stats/admission/
limiting still apply.

The quic listener's cipher path keeps the net.PacketConn wrappers,
since CipherPacketConn is not OOB-capable.

+48% throughput (1.80 → 2.69 Gbit/s single-stream, loopback iperf3).
The quic-go receive-buffer warning is gone.
2026-06-17 21:55:36 +08:00
ginuerzh 219dd51548 refactor(logger): replace logrus with stdlib log/slog
Drop the external github.com/sirupsen/logrus dependency in favor of Go's
standard log/slog package (available since Go 1.21). The core/logger.Logger
interface is preserved unchanged — this is a pure implementation swap.

Key changes:
- slogLogger replaces logrusLogger, backed by slog.Logger + slog.LevelVar
- Custom levelTrace (-8) and levelFatal (+12) constants extend slog's
  built-in 4 levels to cover the 6-level logrus-compatible range
- replaceAttr normalises output: lowercase level names and RFC 3339
  timestamps with milliseconds (matches previous logrus JSON format)
- Shared *slog.LevelVar ensures WithFields clones inherit parent level
- Level guard (early return) in log/logf avoids wasted fmt.Sprint/Sprintf
  when the message level is below the configured threshold
- Documented the stack depth assumption for the caller skip=3 frame

Test coverage (14 tests, all passing with -race):
- convertLevel/revertLevel round-trip for all 6 levels
- levelString for all levels including custom trace/fatal
- replaceAttr timestamp format, level name, groups passthrough
- caller format, level guard, WithFields, IsLevelEnabled, GetLevel
2026-06-17 21:51:06 +08:00
ginuerzh ca582a147f fix: set StateReady after temporary accept error retry
When Accept() returns a transient error, the service enters StateFailed,
sleeps for the retry delay, then loops back to Accept(). Previously the
state stayed StateFailed until the next successful Accept() — which
could take arbitrarily long for idle services, making status observers
see a stale 'error' even though the service has already reconnected
and is ready.

Now set StateReady (and clear lastError) immediately after the retry
sleep. The redundant state transition on successful Accept is removed
since recovery is handled once after the sleep.
2026-06-17 21:35:49 +08:00
ginuerzh c5b62d41ae fix: set StateReady after temporary accept error retry
When Accept() returns a transient error, the service enters StateFailed,
sleeps for the retry delay, then loops back to Accept(). Previously the
state stayed StateFailed until the next successful Accept() — which
could take arbitrarily long for idle services, making status observers
see a stale 'error' even though the service has already reconnected
and is ready.

Now set StateReady (and clear lastError) immediately after the retry
sleep. Accept will block until the next connection, but the state
correctly reflects that the service is functional.
2026-06-17 21:28:31 +08:00
ginuerzh 81dc9331b8 fix(dialer): split netns into platform file to support android cross-compile 2026-06-15 16:50:44 +08:00
ginuerzh 3b3eaa609a fix(listener/pht): set X-Accel-Buffering to fix hang behind Nginx (issue #721)
The /pull handler streams data as base64 chunks with a flush per chunk.
With Nginx proxy_buffering on (the default), these tiny chunks and the
heartbeat newlines are buffered and never flushed to the client, so
active data transfer hangs until the buffer fills or the upstream closes.

Set X-Accel-Buffering: no on the pull response so Nginx forwards each
flushed chunk immediately without buffering. Nginx consumes the header
(it is not forwarded to the client) and it is inert when no proxy sits
in front of the listener, so it is safe to set unconditionally.

Covers pht, phts and h3, which share the same Server handler.
2026-06-14 14:15:22 +08:00
ginuerzh 9b4a320a9f fix(listener/tun): parse routes from URL query string and bare CIDR (issue #735)
mdutil.GetStrings only matches []string and []any — a plain string from a URL query parameter (routes=0.0.0.0/0) returned nil, silently dropping the route. Added GetString fallback with comma-split, and bare-CIDR support that defaults to config.Gateway. Same fix applied to both tun and tungo listeners.
2026-06-14 10:04:10 +08:00
ginuerzh ba3e1cbe09 fix(config/loader): close old services before binding new ports on reload (issue #754)
Services are the only component group that binds a port during parsing
(ParseService calls listener.Init). The generic registerGroup "parse-all-
then-swap" sequence bound every new listener while the old services were
still listening, causing EADDRINUSE on SIGHUP reload — a regression from
82e7e50.

Close and unregister all old services first (unregisterAll relies on
registry.Unregister closing io.Closer values, which frees each service's
port), then parse, bind, and register the new ones. This restores the
pre-82e7e50 close-old-before-bind ordering for the services group.
2026-06-14 01:40:50 +08:00
ginuerzh fd17c3e18d feat(observer/stats): track UDP connection counts in WrapUDPConn
Mirror WrapConn behavior: WrapUDPConn now increments total and current
connection counts on creation (one accepted UDP client session == one
connection) and decrements the current count on Close. Close is guarded
by a mutex and a closed channel so it is safe to call multiple times —
the current-conns decrement fires exactly once and the monotonic total
is untouched.
2026-06-14 01:07:41 +08:00
ginuerzh 62265b424a fix(handler/socks): bind UDP associate relay socket to BND.ADDR source IP (issue #763)
The client-facing UDP relay listener (cc) in handleUDP was bound to the
wildcard address, so replies sent from it used the kernel-selected source
IP (the interface's primary IP), which mismatches the BND.ADDR advertised
to the client (the TCP control connection's local IP). Per RFC 1928 6,
compliant clients drop such replies, breaking SOCKS5 UDP associate under
multiple IP aliases where metadata.interface differs from the primary IP.

Bind cc to the TCP control connection's local IP so its reply source IP
always matches BND.ADDR. The outbound relay socket (pc) already binds the
interface IP via Router.Dial -> dialer.ListenUDP(laddr).
2026-06-13 22:59:42 +08:00
ginuerzh 18c1d6063d fix(dialer/ssh): evict dead session when OpenChannel fails (issue #792) 2026-06-13 19:16:10 +08:00
ginuerzh 49ceda6cc2 feat(wrapper): add UnwrapConn to remaining TCP conn wrappers
Add UnwrapConn() to the admission, proxyproto, conn limiter, metrics,
and observer/stats TCP connection wrappers, complementing the traffic
limiter's limitConn added in the previous commit.

This makes the unwrapConn() helper in handler/sshd able to peel through
any combination of these wrappers uniformly, keeping the pattern
complete and future-proof for handlers that assert on concrete
connection types.
2026-06-13 14:43:49 +08:00
ginuerzh bc44baba55 fix(handler/sshd): unwrap limiter wrappers to fix wrong connection type (issue #867)
The sshd handler performed a concrete type assertion on the accepted
connection, but the SSH listener's Accept() wraps each connection with
a traffic limiter (limitConn), obscuring the underlying *DirectForwardConn
type and causing 'sshd: wrong connection type' whenever a limiter was
configured.

Add an UnwrapConn() method to the traffic limiter wrapper and an
unwrapConn() helper in the handler that peels through wrapper layers
before the type switch. The handler uses the unwrapped conn only for
the type-specific DstAddr() call, while the original limiter-wrapped
conn drives data transfer so rate limiting remains effective.

Also track the last accept/bind error on service Status so callers can
retrieve the cause when a service enters the failed state.
2026-06-13 14:29:51 +08:00
Yuan Tong 8b0806bad4 feat(listener/runix): support remote UDS forwarding (#102) 2026-06-13 13:39:14 +08:00
Denis Galeev 9f610fd163 feat service: introduce labels for logs/recorder (#104) 2026-06-13 13:05:14 +08:00
Kebin Liu eaeac2763e Bypass with network (#101)
* Fix logger init before use it

* Support specific network protocol validation in bypass
2026-06-13 12:36:09 +08:00
ginuerzh caa82f2861 fix(dialer): skip SO_BINDTODEVICE for loopback interface (issue #785)
When using IP aliases on loopback (e.g. ip addr add 10.1.1.1/32 dev lo)
with policy routing, findInterfaceByIP() resolves the alias IP to the
loopback interface. Binding the socket to lo via SO_BINDTODEVICE prevents
outbound traffic from reaching non-local destinations.

Skip SO_BINDTODEVICE when the resolved interface is loopback — source IP
binding via LocalAddr is sufficient for policy routing to steer traffic
through the correct physical interface.
2026-06-08 21:36:33 +08:00
ginuerzh 078cdbcb81 fix(listener/tap): support custom TAP adapter name on Windows (issue #772)
The songgao/water library's InterfaceName parameter is a selection filter
that searches the Windows registry for an existing adapter with a matching
friendly name — it cannot name a new adapter during creation. Passing
name=tap0 caused the library to fail before even creating the adapter.

Fix by omitting InterfaceName from water.New(), letting Windows auto-name
the adapter, then renaming via netsh if a custom name was specified.

Also add directory validation to file handler to catch misconfig early.
2026-06-08 20:05:57 +08:00
ginuerzh 28e5922fce docs(handler/masque): add comprehensive comments for MASQUE proxy handler
Add detailed documentation to the masque handler package covering:
- Package-level doc explaining MASQUE protocol (RFC 9298 CONNECT-UDP,
  RFC 9114 CONNECT-TCP) and request flow
- Doc comments on all exported and unexported types, methods, and structs
- Inline comments explaining HTTP/3 context extraction and data path
- Field-level comments on metadata struct explaining each config param
- Step-by-step flow and data path diagrams for handleConnectUDP and
  handleConnectTCP
2026-06-07 14:29:18 +08:00
ginuerzh 3f671abfb6 fix(ssu): prevent goroutine/connection leak in UDP session cache
The ssuHandler's connMap cached UDP forwarding connections but spawned
goroutines had no context cancellation mechanism. When relayPacketUDP
exited (e.g. on SS UDP session timeout), orphaned goroutines blocked
indefinitely on ReadFrom, preventing connMap cleanup and leaking both
goroutines and connections.

Fix: derive a cancellable context in relayPacketUDP so all per-session
goroutines are cancelled on exit. Add SetReadDeadline with 30s periodic
timeout so goroutines can check context cancellation instead of blocking
forever. Store cancel functions in a cancels sync.Map for cleanup.

Fixes #821
2026-06-07 12:44:28 +08:00
ginuerzh e45d9a8cc8 feat: add stateless UDP forwarding mode (#853)
Add stateless=true metadata option to UDP listener/handler for raw
datagram forwarding without per-client session tracking, similar to
NGINX Stream Module behavior.

- udp.NewListener: add Stateless field to ListenConfig; branch in
  NewListener/Accept to skip connPool/listenLoop when stateless
- datagramConn: lightweight net.Conn+PacketConn wrapping a single
  UDP datagram — no channels, mutexes, or pool logic
- forward handler: add handleRawDatagram for single request-response
  cycle; extract dialTarget to share hop selection/dial/proxyproto
  preamble with handleRawForwarding

Usage: gost -L "udp://:10000/127.0.0.1:2000?stateless=true"
2026-06-05 23:26:03 +08:00
ginuerzh 5e3280d166 fix(handler/socks5): accumulate UDP traffic metrics into recorder (issue #45)
The SOCKS5 handler recorder only tracked TCP traffic metrics. UDP
associations (CmdUdp) overwrote TCP byte counts with UDP counts, and
UDP tunnels (CmdUDPTun) recorded zero bytes.

Changes:
- udp.go: change UDP stats assignment (=) to accumulation (+=) so the
  TCP control connection bytes tracked by Handle() are preserved.
- udp_tun.go: add xstats.Stats{} wrapper around the PacketConn with
  deferred accumulation into HandlerRecorderObject, mirroring the
  handleUDP pattern.
2026-06-05 21:16:16 +08:00
ginuerzh 4d4690b535 fix(handler/serial,sni): 5 code-review fixes - xnet.Pipe errors, (nil,nil) warning, readTimeout comment, defer deadline, clean close
- serial/handler.go: capture xnet.Pipe return value in Handle() and forwardSerial()
- serial/handler.go: log Warnf when chain dial returns (nil,nil) before falling back
- sni/handler.go: use defer for read deadline reset to prevent stale deadline
- sni/handler.go: unrecognized protocol returns nil (clean close) instead of error
- sni/metadata.go: update readTimeout comment to reflect dual usage (deadline + SnifferBuilder)
2026-06-05 21:09:10 +08:00
ginuerzh f09a7a69b3 fix(sniffing): return early on empty SNI to avoid 'missing port in address' errors (#645)
When TLS ClientHello has no SNI extension (ServerName is empty),
normalizeHost returns empty string. Both sniffer_tls.go paths (sniffing
and forwarder packages) previously still called dial with an empty host,
cascading 'missing port in address' errors through connector, router,
and service layers.

Now returns early with a debug-level log, silently closing the
connection instead of attempting to dial upstream.
2026-06-05 20:46:00 +08:00
ginuerzh 066f6813b2 fix(handler/sni): add sniff deadline, log sniff errors, nil Router guard
- Set read deadline before/after sniffing.Sniff to prevent goroutine
  leak from slow clients (matches redirect/tcp and ss patterns)
- Log sniff errors at Debug level instead of silently discarding them
- Add nil Router check in dial closure to prevent NPE panic
2026-06-05 20:46:00 +08:00
ginuerzh c6d3238b15 docs(handler/serial): add comprehensive English comments to all files
Add package-level architecture documentation describing two modes
(hop-based forwarding and direct proxy), two-tier dialing strategy
in forwardSerial, recorderConn per-packet logging with direction
convention, and metadata parsing details. No code changes.
2026-06-05 20:46:00 +08:00
ginuerzh 6810e50c2c fix(handler/serial): record-after-write ordering, nil Router guards, consistent ReadTimeout
- conn.go: Write() now writes first then records b[:n] (not len(b)),
  matching the Read() pattern and preventing phantom data on failures.
- handler.go: add nil Router check before h.options.Router.Dial()
  in Handle's non-hop path to return a clear error instead of panicking.
- handler.go: guard h.options.Router against nil in forwardSerial;
  restructure if/else so ReadTimeout is applied in both the direct
  OpenPort path and when the Router has no chain configured.
2026-06-05 20:46:00 +08:00
ginuerzh e45328d1bb fix(handler/router): 3 bugs fixed + comprehensive data-flow documentation
Bug fixes:
- packetConn.Read: fix slice bounds panic when dlen > len(b);
  n was set to dlen (the wire size) but only len(b) bytes were
  copied to b; callers doing b[:n] would panic. Clamp n via copy().
- lockWriter.Close: add mutex lock to prevent data race with Write;
  Router.Close calls connector.Close (lockWriter.Close) while
  handlePacket concurrently calls Writer.Write — both touch w.w.
- Router.DelConnector: delete empty host slices from the map after
  removing the last connector, preventing unbounded map growth.

Documentation:
- Package-level doc with architecture diagram, data flow, component
  hierarchy, thread-safety invariants, and connector weighting rules.
- Documented every exported type (Connector, Router, ConnectorPool,
  ConnectorOptions, routerHandler, metadata, lockWriter, packetConn)
  and all non-trivial methods with purpose, parameters, lifecycle,
  and algorithmic details (e.g., GetConnector weighted selection).
- Annotated critical code paths with step-by-step comments
  (handleAssociate stages, handlePacket routing algorithm,
   handleEntrypoint packet format and forwarding logic).
- Explained observeStats retry pattern and metadata key conventions.

Verification: build, vet, 98 tests race-clean.
2026-06-05 20:46:00 +08:00
ginuerzh e6c9952ad4 test(handler/router): fix review findings - error matching, version test, cache assertion, entrypoint sync 2026-06-05 20:46:00 +08:00
ginuerzh 1a1851fe03 test(handler/router): fix pipe-based tests data race and pipe blocking issues 2026-06-05 20:46:00 +08:00
ginuerzh a4d45fa278 test(handler/router): add entrypoint_test.go (handleEntrypoint) 2026-06-05 20:46:00 +08:00
ginuerzh 7e9d14bb41 test(handler/router): add associate_test.go (handleAssociate, handlePacket, getRoute, getAddrforRoute, sdRenew) 2026-06-05 20:46:00 +08:00
ginuerzh ca83acc323 test(handler/router): add handler_test.go (NewHandler, Init, Handle, Close) 2026-06-05 20:46:00 +08:00
ginuerzh 901131d0ed test(handler/router): add conn_test.go (packetConn, lockWriter) 2026-06-05 20:46:00 +08:00
ginuerzh 105f7e32d0 test(handler/router): add observe_test.go (checkRateLimit, observeStats) 2026-06-05 20:46:00 +08:00
ginuerzh 32056092f5 test(handler/router): add router_test.go (Connector, Router, ConnectorPool, parseRouterID) 2026-06-05 20:46:00 +08:00
ginuerzh 9db0f07721 test(handler/router): add metadata_test.go for parseMetadata 2026-06-05 20:46:00 +08:00
ginuerzh b48ef7e363 test(handler/router): add helpers_test.go with mocks and test infrastructure 2026-06-05 20:46:00 +08:00
ginuerzh 5608b5fa67 fix(handler/router): add break to DelConnector after successful delete 2026-06-05 20:46:00 +08:00
ginuerzh ef83d839db refactor(handler/router): extract observe.go (observeStats, checkRateLimit) from handler.go 2026-06-05 20:46:00 +08:00
ginuerzh 31cf571a6a refactor(handler/router): extract conn.go (packetConn, lockWriter) from associate.go 2026-06-05 20:46:00 +08:00
ginuerzh b4f39b4bcb chore(deps): bump quic-go to 0.59.1 (security patch)
chore(deps): bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1
2026-06-05 20:45:03 +08:00
Kebin Liu 69acf08560 Fix logger init before use it (#100) 2026-06-05 20:41:50 +08:00
dependabot[bot] a1582d9711 chore(deps): bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.59.0 to 0.59.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.59.0...v0.59.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-version: 0.59.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 21:16:50 +00:00
ginuerzh a1fddedcc3 fix(handler/relay): remove remaining Chinese comments missed in conversion
Clean up 12 inline comments across bind.go, conn.go, connect.go,
entrypoint.go, handler.go that were still in Chinese.
2026-06-03 23:09:04 +08:00
ginuerzh d5fd62aa47 docs(handler/relay): convert all comments to English
Translate all Chinese comments in the relay handler package to English:
handler.go, connect.go, bind.go, forward.go, conn.go, entrypoint.go,
observe.go, metadata.go. All inline and doc comments are now in English.

Also set a persistent preference: all future code comments must be
written in English only.
2026-06-03 23:05:08 +08:00
ginuerzh 95874c53f5 fix: add status field to service list/detail APIs, unify observeStats retry pattern, fix router bugs
- api: add fillServiceStatus helper and call it from getServiceList/getService
  so status field appears in service list and detail API responses
- observeStats: unify retry pattern across all 9 handlers (http, http2, masque,
  relay, router, socks4, socks5, tungo, tunnel) — buffer events on failure,
  continue to skip fresh collection, clear on success; fix event-loss bug
  where interim events were dropped during retry cycles
- handler/router: check WriteTo/Write return errors in associate.go and
  entrypoint.go; fix DelConnector and ConnectorPool.Del using RLock instead
  of Lock (write under read lock); remove unused fields t and cancel
2026-06-03 22:12:08 +08:00
ginuerzh 785d52da31 feat(metrics): add CORS headers for browser-based metrics access
Allow browser-based dashboards (e.g. Flutter web apps) to access the
metrics endpoint by setting Access-Control-Allow-Origin: * and handling
OPTIONS preflight requests.
2026-06-03 21:14:47 +08:00
ginuerzh 29a5b16395 fix(handler/tunnel): respect user-supplied host in handleBind, add ingress conflict detection
The response address sent back to the tunnel client was unconditionally
set to the md5 hash of the tunnel ID. This meant the user's custom host
(e.g. "dash" from "dash:8081") was ignored — the client listened on
the hash instead.

Now the user-supplied host is used directly when:
1. A host is present in the bind address AND ingress is configured.
2. No other tunnel has already claimed that host in ingress
   (conflict check via GetRule).

Fall back to the md5 hash when no host is supplied, ingress is nil, or
the host conflicts with an existing ingress rule.

Also fixes indentation in the WriteTo error handling block (extra tab
removed).

Tests:
- TestHandleBind_CustomHost: response AddrFeature uses "dash", not hash
- TestHandleBind_CustomHostConflict: conflicting host falls back to hash
- fakeIngress enhanced with ruleByHost map for conflict simulation
2026-06-03 21:14:42 +08:00
ginuerzh 91920aa805 fix #866: SOCKS5 nil ptr TLSConfig in selector
Move SetDefaultTLSConfig before register(cfg) in loader.go so
ParseService can access it when building handlers. Also add a
defensive nil check in socks5 serverSelector.Select() for
MethodTLS to prevent future ordering regressions.

Root cause: in selector.go, Select() tried to read ClientHello
via TLSConfig.GetConfigForClient but s.TLSConfig was nil because
register(cfg) -> ParseService -> handler Init ran before
SetDefaultTLSConfig was called from loader.go.
2026-06-03 20:17:20 +08:00
ginuerzh 81db46b725 refactor(handler/tunnel): code review fixes and entrypoint subpackage extraction
- Fix dead-code branch in bind.go host assignment (always use endpoint hash)
- Return descriptive error on bypass match in connect.go (was masking as success)
- Update bypass test in connect_test.go for new error behavior
- Extract entrypoint subpackage from monolithic entrypoint.go (6 files)
- Fix observeStats event-loss bug (break -> fallthrough on retry success)
- Add 47 unit tests across handler, connect, bind, metadata packages
- Add architecture doc comments to all key files
- Build and vet clean, 173 tests pass with -race
2026-06-02 23:51:55 +08:00
ginuerzh d432d28f81 refactor(handler/relay): split handler.go -> observe.go, fix observeStats event-loss bug (break->fallthrough)
Extract checkRateLimit and observeStats into their own file (observe.go).
Fix the one-tick event-loss bug in observeStats where the retry path used
'unconditional break' instead of fallthrough, causing events collected
after a successful retry to be discarded.

Add 87 unit tests across 7 files (helpers, metadata, conn, handler,
connect, forward, bind) covering all handler modes, error paths,
observer/stats integration, and conn wrapper edge cases.

handler.go: -43 lines (unused observer import, extracted functions)
observe.go: +52 lines (checkRateLimit, observeStats with fallthrough fix)
test files: +1959 lines
2026-06-02 23:51:19 +08:00
451 changed files with 42882 additions and 3891 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/
+18 -2
View File
@@ -44,6 +44,12 @@ func WrapConn(admission admission.Admission, c net.Conn) net.Conn {
}
}
// UnwrapConn returns the underlying connection, allowing type assertions
// through wrapper layers.
func (c *serverConn) UnwrapConn() net.Conn {
return c.Conn
}
// Read checks the remote address against the admission controller
// before delegating to the underlying connection. If denied, it returns
// io.EOF to signal end-of-stream.
@@ -157,10 +163,20 @@ type udpConn struct {
// WrapUDPConn wraps a net.PacketConn as a udp.Conn with admission control.
// This is used in UDP forwarding paths where the connection is treated
// as a connected UDP socket.
func WrapUDPConn(admission admission.Admission, pc net.PacketConn) udp.Conn {
// If pc is nil, nil is returned. If admission is nil, the original connection
// is returned unchanged (no-op).
func WrapUDPConn(adm admission.Admission, pc net.PacketConn) udp.Conn {
if pc == nil {
return nil
}
if adm == nil {
if uc, ok := pc.(udp.Conn); ok {
return uc
}
}
return &udpConn{
PacketConn: pc,
admission: admission,
admission: adm,
}
}
+13
View File
@@ -145,6 +145,13 @@ func Register(r *gin.Engine, opts *Options) {
config.PUT("/limiters/:limiter", updateLimiter)
config.DELETE("/limiters/:limiter", deleteLimiter)
config.GET("/quotas", getQuotaList)
config.GET("/quotas/:quota", getQuota)
config.POST("/quotas", createQuota)
config.PUT("/quotas/:quota", updateQuota)
config.DELETE("/quotas/:quota", deleteQuota)
config.POST("/quotas/:quota/reset", resetQuota)
config.GET("/climiters", getConnLimiterList)
config.GET("/climiters/:limiter", getConnLimiter)
config.POST("/climiters", createConnLimiter)
@@ -156,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)
}
+82 -37
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-gost/core/observer/stats"
@@ -17,6 +19,58 @@ type serviceStatus interface {
Status() *service.Status
}
func fillServiceStatus(svc *config.ServiceConfig) {
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
}
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
}
}
var stopped bool
for _, qname := range svc.Quotas {
lim := registry.QuotaLimiterRegistry().Get(qname)
if lim == nil {
continue
}
qs := lim.Snapshot()
if qs.Blocked {
stopped = true
}
svc.Status.Quotas = append(svc.Status.Quotas, config.ServiceQuota{
Name: qname,
Used: qs.Used,
Limit: qs.Limit,
StartsAt: qs.StartsAtUnix,
ExpiresAt: qs.ExpiresAtUnix,
Active: qs.Active,
Expired: qs.Expired,
Blocked: qs.Blocked,
Direction: qs.Direction,
})
}
svc.Status.StoppedByLimit = stopped
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
}
}
}
}
// swagger:parameters getConfigRequest
type getConfigRequest struct {
// output format, one of yaml|json, default is json.
@@ -49,32 +103,7 @@ func getConfig(ctx *gin.Context) {
if svc == nil {
continue
}
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
}
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
}
}
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
}
}
}
fillServiceStatus(svc)
}
return nil
})
@@ -100,10 +129,11 @@ func getConfig(ctx *gin.Context) {
// swagger:parameters saveConfigRequest
type saveConfigRequest struct {
// output format, one of yaml|json, default is yaml.
// output format, one of yaml|json, default is derived from the file extension.
// in: query
Format string `form:"format" json:"format"`
// file path, default is gost.yaml|gost.json in current working directory.
// file path, default is the loaded config file, or gost.yaml|gost.json in
// the current working directory when no config file was loaded.
// in: query
Path string `form:"path" json:"path"`
}
@@ -128,16 +158,31 @@ func saveConfig(ctx *gin.Context) {
var req saveConfigRequest
ctx.ShouldBindQuery(&req)
file := "gost.yaml"
switch req.Format {
case "json":
file = "gost.json"
default:
req.Format = "yaml"
file := req.Path
if file == "" {
// Default to the config file that was loaded (via -C), falling back to
// the current working directory when the config came from elsewhere.
file = config.ConfigFile()
}
if req.Path != "" {
file = req.Path
if file == "" {
file = "gost.yaml"
if req.Format == "json" {
file = "gost.json"
}
}
switch strings.ToLower(req.Format) {
case "json":
req.Format = "json"
case "yaml":
req.Format = "yaml"
default:
// Derive the format from the file extension so a loaded JSON config
// is saved back as JSON (ReadFile forces format from extension).
if strings.ToLower(filepath.Ext(file)) == ".json" {
req.Format = "json"
} else {
req.Format = "yaml"
}
}
f, err := os.Create(file)
+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",
})
}
+350
View File
@@ -0,0 +1,350 @@
package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
parser "github.com/go-gost/x/config/parsing/quota"
"github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry"
)
func fillQuotaStatus(q *config.QuotaConfig) {
lim := registry.QuotaLimiterRegistry().Get(q.Name)
if lim == nil {
return
}
s := lim.Snapshot()
q.Status = &config.QuotaStatus{
Used: s.Used,
Limit: s.Limit,
StartsAt: s.StartsAtUnix,
ExpiresAt: s.ExpiresAtUnix,
Active: s.Active,
Expired: s.Expired,
Blocked: s.Blocked,
Direction: s.Direction,
}
}
// swagger:parameters getQuotaListRequest
type getQuotaListRequest struct {
}
// successful operation.
// swagger:response getQuotaListResponse
type getQuotaListResponse struct {
// in: body
Data quotaList
}
type quotaList struct {
Count int `json:"count"`
List []*config.QuotaConfig `json:"list"`
}
func getQuotaList(ctx *gin.Context) {
// swagger:route GET /config/quotas Quota getQuotaListRequest
//
// Get quota list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getQuotaListResponse
var req getQuotaListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Quotas
for _, q := range list {
if q == nil {
continue
}
fillQuotaStatus(q)
}
ctx.JSON(http.StatusOK, Response{
Data: quotaList{
Count: len(list),
List: list,
},
})
}
// swagger:parameters getQuotaRequest
type getQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
}
// successful operation.
// swagger:response getQuotaResponse
type getQuotaResponse struct {
// in: body
Data *config.QuotaConfig
}
func getQuota(ctx *gin.Context) {
// swagger:route GET /config/quotas/{quota} Quota getQuotaRequest
//
// Get quota.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getQuotaResponse
var req getQuotaRequest
ctx.ShouldBindUri(&req)
var resp getQuotaResponse
for _, q := range config.Global().Quotas {
if q == nil {
continue
}
if q.Name == req.Quota {
fillQuotaStatus(q)
resp.Data = q
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createQuotaRequest
type createQuotaRequest struct {
// in: body
Data config.QuotaConfig `json:"data"`
}
// successful operation.
// swagger:response createQuotaResponse
type createQuotaResponse struct {
Data Response
}
func createQuota(ctx *gin.Context) {
// swagger:route POST /config/quotas Quota createQuotaRequest
//
// Create a new quota, the name of the quota must be unique in quota list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: createQuotaResponse
var req createQuotaRequest
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Data.Name)
if name == "" {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "quota name is required"))
return
}
req.Data.Name = name
req.Data.Status = nil
if registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
v := parser.ParseQuotaLimiter(&req.Data)
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
c.Quotas = append(c.Quotas, &req.Data)
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters updateQuotaRequest
type updateQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
// in: body
Data config.QuotaConfig `json:"data"`
}
// successful operation.
// swagger:response updateQuotaResponse
type updateQuotaResponse struct {
Data Response
}
func updateQuota(ctx *gin.Context) {
// swagger:route PUT /config/quotas/{quota} Quota updateQuotaRequest
//
// Update quota by name, the quota must already exist. The cumulative counter
// is preserved across the update as long as the window is unchanged.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: updateQuotaResponse
var req updateQuotaRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Quota)
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
req.Data.Name = name
req.Data.Status = nil
v := parser.ParseQuotaLimiter(&req.Data)
registry.QuotaLimiterRegistry().Unregister(name)
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
for i := range c.Quotas {
if c.Quotas[i].Name == name {
c.Quotas[i] = &req.Data
break
}
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters deleteQuotaRequest
type deleteQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
}
// successful operation.
// swagger:response deleteQuotaResponse
type deleteQuotaResponse struct {
Data Response
}
func deleteQuota(ctx *gin.Context) {
// swagger:route DELETE /config/quotas/{quota} Quota deleteQuotaRequest
//
// Delete quota by name. Services referencing it stop being limited (fail-open).
//
// Security:
// basicAuth: []
//
// Responses:
// 200: deleteQuotaResponse
var req deleteQuotaRequest
ctx.ShouldBindUri(&req)
name := strings.TrimSpace(req.Quota)
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
registry.QuotaLimiterRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
quotas := c.Quotas
c.Quotas = nil
for _, q := range quotas {
if q.Name == name {
continue
}
c.Quotas = append(c.Quotas, q)
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters resetQuotaRequest
type resetQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
// in: body
Data resetQuotaData `json:"data"`
}
type resetQuotaData struct {
Used *uint64 `json:"used,omitempty"`
}
// successful operation.
// swagger:response resetQuotaResponse
type resetQuotaResponse struct {
Data Response
}
func resetQuota(ctx *gin.Context) {
// swagger:route POST /config/quotas/{quota}/reset Quota resetQuotaRequest
//
// Overwrite the cumulative counter of a quota (defaults to 0). The new value
// is applied immediately and persisted; if the quota was blocking, services
// resume once the value is below the limit.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: resetQuotaResponse
var req resetQuotaRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Quota)
lim := registry.QuotaLimiterRegistry().Get(name)
if lim == nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
used := uint64(0)
if req.Data.Used != nil {
used = *req.Data.Used
}
lim.Update(quota.Update{Used: &used})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
+8
View File
@@ -43,6 +43,13 @@ func getServiceList(ctx *gin.Context) {
list := config.Global().Services
for _, svc := range list {
if svc == nil {
continue
}
fillServiceStatus(svc)
}
var resp getServiceListResponse
resp.Data = serviceList{
Count: len(list),
@@ -89,6 +96,7 @@ func getService(ctx *gin.Context) {
continue
}
if service.Name == req.Service {
fillServiceStatus(service)
resp.Data = service
}
}
+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
}
+49 -14
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 (
@@ -47,6 +47,12 @@ type options struct {
// When true: only matching addresses bypass the proxy.
whitelist bool
// network restricts this bypass to a specific network protocol.
// When set, the incoming network must match before address matchers
// are evaluated. Recognized values include "tcp" and "udp".
// An empty value disables the network check.
network string
// matchers holds static patterns provided at construction time
// (e.g. from config file or command-line arguments).
matchers []string
@@ -74,14 +80,23 @@ 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
}
}
// NetworkOption restricts this bypass to a specific network protocol.
// When set, Contains returns false when the incoming network does not
// match, regardless of address matchers. Accepts values like "tcp" or "udp".
func NetworkOption(network string) Option {
return func(opts *options) {
opts.network = network
}
}
// MatchersOption sets the static bypass patterns (CIDR, IP range, wildcard, or address).
func MatchersOption(matchers []string) Option {
return func(opts *options) {
@@ -199,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.
@@ -272,10 +287,10 @@ func (p *localBypass) reload(ctx context.Context) error {
patterns := append(p.options.matchers, v...)
p.logger.Debugf("load items %d", len(patterns))
ps := classifyPatterns(patterns, p.logger)
p.mu.Lock()
p.patterns = ps
if len(patterns) > 0 {
p.patterns = classifyPatterns(patterns, p.logger)
}
p.mu.Unlock()
return nil
@@ -402,7 +417,7 @@ func (p *localBypass) Contains(ctx context.Context, network, addr string, opts .
return false
}
decision := p.decide(addr)
decision := p.decide(network, addr)
log := p.logger.WithFields(map[string]any{
"sid": ctxvalue.SidFromContext(ctx),
@@ -414,11 +429,22 @@ func (p *localBypass) Contains(ctx context.Context, network, addr string, opts .
// decide returns the bypass decision for the given address, applying the
// whitelist or blacklist mode to the pattern match result.
func (p *localBypass) decide(addr string) bypassDecision {
func (p *localBypass) decide(network string, addr string) bypassDecision {
p.mu.RLock()
defer p.mu.RUnlock()
if p.options.network != "" && p.options.network != network {
return decisionProxy
}
if p.patterns == nil {
if p.options.network != "" {
if p.options.whitelist {
return decisionProxy
}
return decisionBypass
}
return decisionProxy
}
@@ -526,8 +552,17 @@ func (p *bypassGroup) evaluate(ctx context.Context, network, addr string, opts .
return decisionProxy
}
// IsWhitelist always returns false for a group, since the group may contain
// a mix of whitelist and blacklist rules.
// IsWhitelist reports whether the group acts as a pure whitelist: every
// member is a whitelist rule. A mixed group is neither pure whitelist nor
// pure blacklist, so it reports false.
func (p *bypassGroup) IsWhitelist() bool {
return false
if len(p.bypasses) == 0 {
return false
}
for _, bp := range p.bypasses {
if !bp.IsWhitelist() {
return false
}
}
return true
}
+212 -18
View File
@@ -288,32 +288,32 @@ func TestMatched_IP(t *testing.T) {
b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"}))
defer b.Close()
assert.Equal(t, decisionBypass, b.decide("192.168.1.1"))
assert.Equal(t, decisionProxy, b.decide("10.0.0.1"))
assert.Equal(t, decisionBypass, b.decide("", "192.168.1.1"))
assert.Equal(t, decisionProxy, b.decide("", "10.0.0.1"))
}
func TestMatched_CIDR(t *testing.T) {
b := newSyncedBypass(MatchersOption([]string{"10.0.0.0/8"}))
defer b.Close()
assert.Equal(t, decisionBypass, b.decide("10.0.0.1"))
assert.Equal(t, decisionProxy, b.decide("192.168.1.1"))
assert.Equal(t, decisionBypass, b.decide("", "10.0.0.1"))
assert.Equal(t, decisionProxy, b.decide("", "192.168.1.1"))
}
func TestMatched_Wildcard(t *testing.T) {
b := newSyncedBypass(MatchersOption([]string{"*.example.com"}))
defer b.Close()
assert.Equal(t, decisionBypass, b.decide("foo.example.com"))
assert.Equal(t, decisionProxy, b.decide("foo.other.com"))
assert.Equal(t, decisionBypass, b.decide("", "foo.example.com"))
assert.Equal(t, decisionProxy, b.decide("", "foo.other.com"))
}
func TestMatched_IPRange(t *testing.T) {
b := newSyncedBypass(MatchersOption([]string{"10.0.0.1-10.0.0.100"}))
defer b.Close()
assert.Equal(t, decisionBypass, b.decide("10.0.0.50"))
assert.Equal(t, decisionProxy, b.decide("10.0.0.200"))
assert.Equal(t, decisionBypass, b.decide("", "10.0.0.50"))
assert.Equal(t, decisionProxy, b.decide("", "10.0.0.200"))
}
// --- reload tests ---
@@ -667,6 +667,20 @@ func TestBypassGroup_IsWhitelist(t *testing.T) {
assert.False(t, g.IsWhitelist())
}
// gost#899: in whitelist mode a domain-only allowlist must not reject the
// destination IP before sniffing runs (the sniffer checks the sniffed host).
func TestBypassGroup_WhitelistDomainNoMatch(t *testing.T) {
g := BypassGroup(
NewBypass(
WhitelistOption(true),
MatchersOption([]string{"example.com"}),
LoggerOption(xlogger.Nop()),
),
)
assert.True(t, g.Contains(context.Background(), "tcp", "93.184.216.34:443"))
assert.False(t, g.Contains(context.Background(), "tcp", "example.com:443"))
}
// --- NewBypass reload error in background ---
func TestNewBypass_ReloadError(t *testing.T) {
@@ -683,7 +697,20 @@ func TestNewBypass_ReloadError(t *testing.T) {
// --- Network parameter ignored ---
func TestContains_NetworkIgnoredForMatching(t *testing.T) {
// --- Network option tests ---
func TestNetworkOption(t *testing.T) {
var opts options
NetworkOption("tcp")(&opts)
assert.Equal(t, "tcp", opts.network)
NetworkOption("")(&opts)
assert.Equal(t, "", opts.network)
}
// --- Network pre-filter tests ---
func TestContains_NetworkNotSet_BehavesAsBefore(t *testing.T) {
b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"}))
defer b.Close()
@@ -692,6 +719,173 @@ func TestContains_NetworkIgnoredForMatching(t *testing.T) {
assert.True(t, b.Contains(context.Background(), "ip4", "192.168.1.1"))
}
func TestContains_NetworkMatch_BlacklistBypass(t *testing.T) {
b := newSyncedBypass(
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// Network matches and addr matches → bypass
assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestContains_NetworkMatch_BlacklistPass(t *testing.T) {
b := newSyncedBypass(
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// Network matches but addr does not match → proxy
assert.False(t, b.Contains(context.Background(), "tcp", "10.0.0.1"))
}
func TestContains_NetworkMatch_WhitelistProxy(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(true),
NetworkOption("udp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// Network matches and addr matches → in whitelist, addr goes through proxy
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestContains_NetworkMatch_WhitelistBypass(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(true),
NetworkOption("udp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// Network matches but addr does not match → whitelist bypass
assert.True(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
}
func TestContains_NetworkMismatch_ReturnsFalse(t *testing.T) {
b := newSyncedBypass(
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// Network does not match → bypass returns false regardless of addr
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
assert.False(t, b.Contains(context.Background(), "udp", ""))
assert.False(t, b.Contains(context.Background(), "sctp", "192.168.1.1"))
}
func TestContains_NetworkMismatch_EmptyAddr(t *testing.T) {
b := newSyncedBypass(
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// addr is empty → early return false
assert.False(t, b.Contains(context.Background(), "tcp", ""))
}
func TestContains_NetworkMismatch_WhitelistReturnsFalse(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(true),
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// Network does not match → bypass returns false even in whitelist mode
assert.False(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestContains_PatternSetPassesThroughNetworkFilter(t *testing.T) {
b := newSyncedBypass(
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1", "10.0.0.0/8", "*.example.com"}),
)
defer b.Close()
// Network matches → pattern set is evaluated
assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1"))
assert.True(t, b.Contains(context.Background(), "tcp", "foo.example.com"))
assert.False(t, b.Contains(context.Background(), "tcp", "8.8.8.8"))
}
// --- Network-only tests (no address matchers) ---
func TestNetworkOnly_Blacklist_BypassesMatchingNetwork(t *testing.T) {
b := newSyncedBypass(NetworkOption("tcp"))
defer b.Close()
// Network matches → bypass
assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1"))
assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1"))
}
func TestNetworkOnly_Blacklist_DoesNotBypassNonMatching(t *testing.T) {
b := newSyncedBypass(NetworkOption("tcp"))
defer b.Close()
// Network does not match → not bypassed
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestNetworkOnly_Whitelist_ProxiesMatchingNetwork(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(true),
NetworkOption("tcp"),
)
defer b.Close()
// In whitelist mode, matched network → proxy (not bypassed)
assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1"))
assert.False(t, b.Contains(context.Background(), "tcp", "10.0.0.1"))
}
func TestNetworkOnly_Whitelist_NonMatchingNotAffected(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(true),
NetworkOption("tcp"),
)
defer b.Close()
// network: tcp 是作用域限定,非 TCP 流量不受影响
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestNetworkOnly_NoNetwork_BehavesAsBefore(t *testing.T) {
b := newSyncedBypass()
defer b.Close()
// No network configured, no matchers → never bypass
assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1"))
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestNetworkOnly_EmptyAddr_ReturnsFalse(t *testing.T) {
b := newSyncedBypass(NetworkOption("tcp"))
defer b.Close()
assert.False(t, b.Contains(context.Background(), "tcp", ""))
}
func TestNetworkOption_DoesNotAffectGroupBypass(t *testing.T) {
b := newSyncedBypass(
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
// bypassGroup embeds the same localBypass and respects the network filter
g := BypassGroup(b)
assert.False(t, g.Contains(context.Background(), "udp", "192.168.1.1"))
assert.True(t, g.Contains(context.Background(), "tcp", "192.168.1.1"))
}
// --- Debug logging doesn't panic ---
func TestContains_DenyLogs(t *testing.T) {
@@ -842,9 +1036,9 @@ func TestPatternSet_MatchAny_Empty(t *testing.T) {
func TestPatternSet_MatchAny_MixedTypes(t *testing.T) {
ps := classifyPatterns([]string{
"192.168.1.1", // address
"10.0.0.0/8", // CIDR
"*.example.com", // wildcard
"192.168.1.1", // address
"10.0.0.0/8", // CIDR
"*.example.com", // wildcard
"172.16.0.1-172.16.0.255", // IP range
}, xlogger.Nop())
assert.True(t, ps.matchAny("192.168.1.1"))
@@ -916,40 +1110,40 @@ func TestDecide_BlacklistMatched(t *testing.T) {
b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"}))
defer b.Close()
assert.Equal(t, decisionBypass, b.decide("192.168.1.1"))
assert.Equal(t, decisionBypass, b.decide("", "192.168.1.1"))
}
func TestDecide_BlacklistNotMatched(t *testing.T) {
b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"}))
defer b.Close()
assert.Equal(t, decisionProxy, b.decide("10.0.0.1"))
assert.Equal(t, decisionProxy, b.decide("", "10.0.0.1"))
}
func TestDecide_WhitelistMatched(t *testing.T) {
b := newSyncedBypass(WhitelistOption(true), MatchersOption([]string{"192.168.1.1"}))
defer b.Close()
assert.Equal(t, decisionProxy, b.decide("192.168.1.1"))
assert.Equal(t, decisionProxy, b.decide("", "192.168.1.1"))
}
func TestDecide_WhitelistNotMatched(t *testing.T) {
b := newSyncedBypass(WhitelistOption(true), MatchersOption([]string{"192.168.1.1"}))
defer b.Close()
assert.Equal(t, decisionBypass, b.decide("10.0.0.1"))
assert.Equal(t, decisionBypass, b.decide("", "10.0.0.1"))
}
func TestDecide_NilPatterns(t *testing.T) {
lb := &localBypass{logger: xlogger.Nop()}
assert.Equal(t, decisionProxy, lb.decide("anything"))
assert.Equal(t, decisionProxy, lb.decide("", "anything"))
}
// --- evaluate (bypassGroup) tests ---
func TestEvaluate_AllBlacklistAllMatch(t *testing.T) {
g := BypassGroup(alwaysContains{}, alwaysContains{}).(*bypassGroup)
assert.Equal(t, decisionBypass, g.evaluate(context.Background(), "tcp", "any", ))
assert.Equal(t, decisionBypass, g.evaluate(context.Background(), "tcp", "any"))
}
func TestEvaluate_BlacklistNoneMatch(t *testing.T) {
+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")
}
}
+119
View File
@@ -0,0 +1,119 @@
package cache
import (
"context"
"time"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/logger"
"github.com/go-redis/redis/v8"
)
// defaultKeyPrefix is prepended to cache keys when RedisOptions.KeyPrefix is empty.
const defaultKeyPrefix = "gost:cache:"
// RedisOptions configures a Redis cache.
type RedisOptions struct {
// Addr is the Redis server address (host:port).
Addr string
// DB is the Redis database number.
DB int
// Username for Redis authentication. Optional.
Username string
// Password for Redis authentication. Optional.
Password string
// DefaultTTL is applied to entries stored without an explicit TTL.
// A non-positive value means entries never expire by default.
DefaultTTL time.Duration
// KeyPrefix is prepended to all cache keys. Defaults to "gost:cache:".
KeyPrefix string
// Logger is used for debug logging. Optional.
Logger logger.Logger
}
// redisCache is a cache.Cache backed by a Redis server.
//
// Redis expires keys natively, so an expired entry is a true miss here — the
// interface's serve-stale contract (returning expired entries from Get) does
// not apply to this backend.
type redisCache struct {
client *redis.Client
defaultTTL time.Duration
keyPrefix string
logger logger.Logger
}
// NewRedisCache creates a Redis-backed cache. Close releases the underlying
// client connections.
func NewRedisCache(opts RedisOptions) cache.Cache {
keyPrefix := opts.KeyPrefix
if keyPrefix == "" {
keyPrefix = defaultKeyPrefix
}
return &redisCache{
client: redis.NewClient(&redis.Options{
Addr: opts.Addr,
Username: opts.Username,
Password: opts.Password,
DB: opts.DB,
}),
defaultTTL: opts.DefaultTTL,
keyPrefix: keyPrefix,
logger: opts.Logger,
}
}
// Get returns the entry for key, or cache.ErrNotFound if the key is absent
// (including keys Redis has already expired).
func (c *redisCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
k := c.keyPrefix + key
pipe := c.client.Pipeline()
getCmd := pipe.Get(ctx, k)
ttlCmd := pipe.PTTL(ctx, k)
if _, err := pipe.Exec(ctx); err != nil {
if err == redis.Nil {
return nil, cache.ErrNotFound
}
return nil, err
}
entry := &cache.Entry{
Data: []byte(getCmd.Val()),
}
if ttl := ttlCmd.Val(); ttl > 0 {
entry.Expiration = time.Now().Add(ttl)
}
return entry, nil
}
// Set stores data under key. A non-positive TTL (after applying the default)
// stores the entry without expiration.
func (c *redisCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
var options cache.SetOptions
for _, opt := range opts {
opt(&options)
}
ttl := options.TTL
if ttl <= 0 {
ttl = c.defaultTTL
}
if ttl < 0 {
ttl = 0 // redis: 0 = no expiration
}
return c.client.Set(ctx, c.keyPrefix+key, data, ttl).Err()
}
// Delete removes key from the cache. Deleting an absent key is a no-op.
func (c *redisCache) Delete(ctx context.Context, key string) error {
return c.client.Del(ctx, c.keyPrefix+key).Err()
}
// Close releases the Redis client. Implements io.Closer so the registry
// closes the client on hot reload.
func (c *redisCache) Close() error {
return c.client.Close()
}
+197 -8
View File
@@ -30,12 +30,16 @@ package chain
import (
"context"
"io"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
"github.com/go-gost/core/routing"
"github.com/go-gost/core/selector"
"github.com/go-gost/x/internal/probe"
)
var (
@@ -111,6 +115,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.
@@ -148,14 +163,20 @@ func (c *Chain) Route(ctx context.Context, network, address string, opts ...chai
}
type chainGroup struct {
chains []chain.Chainer
selector selector.Selector[chain.Chainer]
entries []*ChainEntry
selector selector.Selector[chain.Chainer]
logger logger.Logger
cancelFunc context.CancelFunc
}
// NewChainGroup creates a chain group that selects one Chainer from the
// given list using the configured selector (round-robin by default).
func NewChainGroup(chains ...chain.Chainer) *chainGroup {
return &chainGroup{chains: chains}
entries := make([]*ChainEntry, len(chains))
for i, c := range chains {
entries[i] = NewChainEntry(c, nil, nil)
}
return &chainGroup{entries: entries}
}
func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGroup {
@@ -163,17 +184,185 @@ func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGrou
return p
}
// WithGroupEntries sets the chain entries for the group (replaces defaults)
// and starts probe goroutines for entries that have probe configurations.
func (p *chainGroup) WithGroupEntries(entries ...*ChainEntry) *chainGroup {
// Cancel any previous probe goroutines before replacing entries.
if p.cancelFunc != nil {
p.cancelFunc()
}
p.entries = entries
ctx, cancel := context.WithCancel(context.Background())
p.cancelFunc = cancel
for _, e := range entries {
if e.probe != nil {
go p.runEntryProbe(ctx, e)
}
}
return p
}
// WithGroupLogger sets the logger for the group.
func (p *chainGroup) WithGroupLogger(log logger.Logger) *chainGroup {
p.logger = log
return p
}
func (p *chainGroup) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
if chain := p.next(ctx); chain != nil {
return chain.Route(ctx, network, address, opts...)
if c := p.selectChain(ctx, network, address, opts...); c != nil {
return c.Route(ctx, network, address, opts...)
}
return nil
}
func (p *chainGroup) next(ctx context.Context) chain.Chainer {
if p == nil || len(p.chains) == 0 {
func (p *chainGroup) selectChain(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Chainer {
if p == nil || len(p.entries) == 0 {
return nil
}
return p.selector.Select(ctx, p.chains...)
var options chain.RouteOptions
for _, opt := range opts {
opt(&options)
}
// When no entry has a matcher, the eligible pool equals all entries; the
// selector must still run (FailFilter excludes probe-failed chains), so
// skip the per-call allocation and pass entries directly.
anyMatcher := false
for _, e := range p.entries {
if e.matcher != nil {
anyMatcher = true
break
}
}
if !anyMatcher {
eligible := make([]chain.Chainer, len(p.entries))
for i, e := range p.entries {
eligible[i] = e
}
return p.selectFrom(ctx, eligible...)
}
// Stage 1: matcher filter — build eligible pool.
req := routing.Request{
Network: network,
Host: options.Host,
}
eligible := make([]chain.Chainer, 0, len(p.entries))
for _, e := range p.entries {
if e.matcher != nil {
if !e.matcher.Match(&req) {
continue
}
if p.logger != nil {
p.logger.Debugf("chain entry matched request %s %s", req.Network, req.Host)
}
}
eligible = append(eligible, e) // *ChainEntry as chain.Chainer, FailFilter reads e.Marker()
}
if len(eligible) == 0 {
return nil
}
// Stage 2: group selector — FailFilter + strategy.
return p.selectFrom(ctx, eligible...)
}
func (p *chainGroup) selectFrom(ctx context.Context, eligible ...chain.Chainer) chain.Chainer {
if p.selector != nil {
return p.selector.Select(ctx, eligible...)
}
return eligible[0]
}
// Close stops all probe goroutines.
func (p *chainGroup) Close() error {
if p.cancelFunc != nil {
p.cancelFunc()
}
return nil
}
// runEntryProbe periodically probes a chain and marks
// the chain entry (not the chain's nodes) on failure.
func (p *chainGroup) runEntryProbe(ctx context.Context, e *ChainEntry) {
cfg := e.probe
interval := cfg.Interval
if interval <= 0 {
interval = 30 * time.Second
}
p.probeEntry(ctx, e, cfg) // first probe immediately
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.probeEntry(ctx, e, cfg)
case <-ctx.Done():
return
}
}
}
func (p *chainGroup) probeEntry(ctx context.Context, e *ChainEntry, cfg *chain.ProbeConfig) {
// cmd probe runs a shell command — no chain routing needed.
if cfg.Type == chain.ProbeTypeCmd {
probe.RunCmdProbe(cfg, e.marker, p.logger)
return
}
// TCP/HTTP probe: route through the chain to probe target.
addr := cfg.Addr
if addr == "" {
e.marker.Mark()
if p.logger != nil {
p.logger.Debug("chain entry probe: no probe address configured")
}
return
}
// Route through the probe lifecycle context so cancellation (reload/close)
// aborts an in-flight probe instead of blocking forever on a stuck route.
route := e.chainer.Route(ctx, "tcp", addr)
if route == nil {
e.marker.Mark()
if p.logger != nil {
p.logger.Debug("chain entry probe: no route available")
}
return
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
probeCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := route.Dial(probeCtx, "tcp", addr)
if err != nil {
e.marker.Mark()
if p.logger != nil {
p.logger.Debugf("chain entry probe dial %s failed: %v", addr, err)
}
return
}
defer conn.Close()
if cfg.Type == chain.ProbeTypeHTTP {
if err := probe.NewHTTPProber(cfg).Probe(conn); err != nil {
e.marker.Mark()
if p.logger != nil {
p.logger.Debugf("chain entry probe http %s failed: %v", addr, err)
}
return
}
}
e.marker.Reset()
}
+77 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
"github.com/go-gost/core/recorder"
"github.com/go-gost/core/routing"
xlogger "github.com/go-gost/x/logger"
xmetadata "github.com/go-gost/x/metadata"
"github.com/stretchr/testify/assert"
@@ -787,7 +788,7 @@ func TestNewChainGroup(t *testing.T) {
ch2 := NewChain("ch2")
g := NewChainGroup(ch1, ch2)
require.NotNil(t, g)
assert.Len(t, g.chains, 2)
assert.Len(t, g.entries, 2)
}
func TestChainGroup_Route_NoChains(t *testing.T) {
@@ -1758,12 +1759,83 @@ func TestChainGroup_WithSelector(t *testing.T) {
assert.Len(t, rt.Nodes(), 1)
}
func TestChainGroup_Next_NilReceiver(t *testing.T) {
func TestChainGroup_SelectChain_NilReceiver(t *testing.T) {
var g *chainGroup
assert.Nil(t, g.next(context.Background()))
assert.Nil(t, g.selectChain(context.Background(), "", ""))
}
func TestChainGroup_Next_EmptyChains(t *testing.T) {
func TestChainGroup_SelectChain_EmptyChains(t *testing.T) {
g := NewChainGroup()
assert.Nil(t, g.next(context.Background()))
assert.Nil(t, g.selectChain(context.Background(), "", ""))
}
// hostMatcher matches requests whose Host equals the configured value.
type hostMatcher struct {
host string
}
func (m *hostMatcher) Match(req *routing.Request) bool {
return req != nil && req.Host == m.host
}
func TestChainGroup_SelectChain_MatcherFilter(t *testing.T) {
matchChain := NewChain("match")
skipChain := NewChain("skip")
entries := []*ChainEntry{
NewChainEntry(matchChain, &hostMatcher{host: "a.example.com"}, nil),
NewChainEntry(skipChain, &hostMatcher{host: "b.example.com"}, nil),
}
g := NewChainGroup().WithGroupEntries(entries...)
// Only the entry matching "a.example.com" should be eligible.
got := g.selectChain(context.Background(), "tcp", "a.example.com:80", chain.WithHostRouteOption("a.example.com"))
require.NotNil(t, got)
// selectChain returns the *ChainEntry (so the FailFilter can read its marker),
// not the inner *Chain.
ce, ok := got.(*ChainEntry)
require.True(t, ok)
assert.Equal(t, matchChain, ce.chainer)
// Non-matching host → no eligible entry → nil.
assert.Nil(t, g.selectChain(context.Background(), "tcp", "x.example.com:80", chain.WithHostRouteOption("x.example.com")))
}
func TestChainGroup_Close_StopsProbeGoroutines(t *testing.T) {
ch := NewChain("ch")
// cmd probe with a short interval; the entry marker count is the observable
// signal that the probe goroutine is (or is not) still running.
entry := NewChainEntry(ch, nil, &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "true",
Interval: 10 * time.Millisecond,
})
g := NewChainGroup().WithGroupEntries(entry)
// Let the probe tick a few times, then Close().
time.Sleep(50 * time.Millisecond)
require.NoError(t, g.Close())
before := entry.Marker().Count()
time.Sleep(60 * time.Millisecond)
after := entry.Marker().Count()
// A succeeding cmd probe ("true") never marks, so the count must stay
// stable after Close(): the goroutine has stopped ticking.
assert.Equal(t, before, after, "probe goroutine should stop ticking after Close()")
}
func TestChainGroup_Probe_MarksEntryOnCmdFailure(t *testing.T) {
ch := NewChain("ch")
entry := NewChainEntry(ch, nil, &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "false", // always exits non-zero → probe failure
Interval: time.Second,
})
g := NewChainGroup().WithGroupEntries(entry)
// First probe runs immediately; give it time to mark.
time.Sleep(50 * time.Millisecond)
assert.NotZero(t, entry.Marker().Count(), "failing cmd probe should mark the entry")
require.NoError(t, g.Close())
}
+44
View File
@@ -0,0 +1,44 @@
package chain
import (
"context"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/routing"
"github.com/go-gost/core/selector"
)
// ChainEntry wraps a chain.Chainer with an optional matcher and an independent
// failure marker for chain-level probe tracking. The entry's marker is
// separate from any node-level markers inside the chain so that probe
// Reset() cannot undo a node-level failCodes Mark().
type ChainEntry struct {
chainer chain.Chainer
matcher routing.Matcher
probe *chain.ProbeConfig
marker selector.Marker
}
// NewChainEntry creates a ChainEntry. matcher may be nil (unconditional
// catch-all). probe may be nil (no health check for this entry).
func NewChainEntry(c chain.Chainer, m routing.Matcher, probe *chain.ProbeConfig) *ChainEntry {
return &ChainEntry{
chainer: c,
matcher: m,
probe: probe,
marker: selector.NewFailMarker(),
}
}
// Route implements chain.Chainer so the entry can be passed directly to the
// group selector. The selector's FailFilter reads Marker() from the entry,
// not from the underlying chain — this keeps probe-driven marking independent
// of any node-level markers.
func (e *ChainEntry) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
return e.chainer.Route(ctx, network, address, opts...)
}
// Marker implements selector.Markable.
func (e *ChainEntry) Marker() selector.Marker {
return e.marker
}
+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))
}
+6
View File
@@ -86,6 +86,12 @@ func (*defaultRoute) Bind(ctx context.Context, network, address string, opts ...
Logger: logger,
})
return ln, err
case "unix":
addr, err := net.ResolveUnixAddr(network, address)
if err != nil {
return nil, err
}
return net.ListenUnix(network, addr)
default:
err := fmt.Errorf("network %s unsupported", network)
return nil, err
+41 -1
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"context"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/go-gost/core/chain"
@@ -48,6 +50,19 @@ func (r *Router) Options() *chain.RouterOptions {
return &r.options
}
// Close releases resources held by the router's chain. A chain that owns
// background goroutines (e.g. a chainGroup running liveness probes) is closed
// here so its goroutines stop when the service shuts down or is reloaded.
func (r *Router) Close() error {
if r == nil {
return nil
}
if c, ok := r.options.Chain.(io.Closer); ok {
return c.Close()
}
return nil
}
// Dial establishes a connection to the target address through the configured
// chain. It resolves the address, records the dial event, retries up to
// Retries+1 times, and returns the resulting connection.
@@ -141,9 +156,34 @@ func (r *Router) dial(ctx context.Context, network, address string, log logger.L
if route == nil {
route = DefaultRoute
}
ifceName := r.options.IfceName
if strings.Contains(ifceName, "auto") {
// "auto" triggers per-connection interface detection: the
// ingress IP (conn.LocalAddr()) is used as the bind address,
// ensuring egress traffic leaves via the same interface that
// received the connection — the "source-in source-out"
// pattern for multi-homed hosts.
if dstAddr := xctx.DstAddrFromContext(ctx); dstAddr != nil {
if host, _, _ := net.SplitHostPort(dstAddr.String()); host != "" &&
host != "0.0.0.0" && host != "::" {
// Replace only exact "auto" tokens in the
// comma-separated interface list.
parts := strings.Split(ifceName, ",")
for i, p := range parts {
if p == "auto" {
parts[i] = host
}
}
ifceName = strings.Join(parts, ",")
}
}
if strings.Contains(ifceName, "auto") {
log.Debugf("auto interface: no suitable ingress address, keeping literal %q", ifceName)
}
}
conn, err = route.Dial(ctx, network, ipAddr,
append([]chain.DialOption{
chain.InterfaceDialOption(r.options.IfceName),
chain.InterfaceDialOption(ifceName),
chain.NetnsDialOption(r.options.Netns),
chain.SockOptsDialOption(r.options.SockOpts),
chain.LoggerDialOption(log),
+52 -24
View File
@@ -209,7 +209,7 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
for i, service := range services {
service.Name = fmt.Sprintf("%sservice-%d", namePrefix, i)
if chain != nil {
if service.Listener.Type == "rtcp" || service.Listener.Type == "rudp" {
if service.Listener.Type == "rtcp" || service.Listener.Type == "rudp" || service.Listener.Type == "runix" {
service.Listener.Chain = chain.Name
} else {
service.Handler.Chain = chain.Name
@@ -397,15 +397,24 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
listener = schemes[1]
}
// For path-based protocols (unix socket, serial), use path as address
isPathBasedProtocol := listener == "unix" || listener == "serial"
// Decide path-based from the scheme before registry remaps listener to tcp.
isPathBasedProtocol := listener == "unix" || listener == "runix" || listener == "serial"
for _, s := range schemes {
if s == "unix" || s == "runix" || s == "serial" {
isPathBasedProtocol = true
break
}
}
// A path-based protocol with an empty host (three-slash URL like
// unix:///var/run/sock) uses the path as the absolute listen address and
// has no forward target. With a non-empty host (two-slash URL like
// unix://sock/other.sock), the host is the listen address and any path is a
// forward target (port-forward to another socket), handled below.
isPathAddr := isPathBasedProtocol && url.Host == ""
var addrs []string
if isPathBasedProtocol {
path := url.EscapedPath()
if url.Host != "" {
addrs = append(addrs, url.Host+strings.TrimPrefix(path, "/"))
} else if path != "" {
if isPathAddr {
if path := url.EscapedPath(); path != "" {
addrs = append(addrs, path)
} else {
addrs = append(addrs, url.Host)
@@ -436,8 +445,10 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
var nodes []*config.ForwardNodeConfig
// forward mode
// For path-based protocols, path is address not forward target
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathBasedProtocol {
// A path-based protocol with an empty host (e.g. unix:///abs.sock) treats
// the path as the listen address, not a forward target. With a host
// (e.g. unix://a.sock/b.sock) the path is a forward target.
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathAddr {
i := 0
for _, addr := range strings.Split(remotes, ",") {
addrs := xnet.AddrPortRange(addr).Addrs()
@@ -457,7 +468,7 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
if listener == "tcp" || listener == "udp" ||
listener == "rtcp" || listener == "rudp" ||
listener == "tun" || listener == "tap" ||
listener == "dns" || listener == "unix" ||
listener == "dns" || listener == "unix" || listener == "runix" ||
listener == "serial" {
handler = listener
} else {
@@ -466,6 +477,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{
@@ -629,6 +651,18 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
dialer = schemes[1]
}
// Path-based address logic must use the requested scheme, not the
// registry fallback (unix/serial often remap to http/tcp when only
// the CLI registry is partially populated, or in unit tests).
isPathBasedProtocol := dialer == "unix" || dialer == "serial" ||
connector == "unix" || connector == "serial"
for _, s := range schemes {
if s == "unix" || s == "serial" || s == "runix" {
isPathBasedProtocol = true
break
}
}
md := mdx.NewMetadata(m)
if c := registry.ConnectorRegistry().Get(connector); c == nil {
@@ -714,20 +748,14 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
nodeMd[k] = v
}
// For path-based protocols (unix socket, serial), use path as address
// For path-based protocols (Unix socket dialers and serial), use path as address.
// Three-slash URLs (empty host) give the absolute socket path; two-slash URLs
// use the host as the socket. The path on a host form is not part of the node
// address (it is a forward target for services, ignored for nodes).
var nodeAddr string
isPathBasedProtocol := dialer == "unix" || dialer == "serial"
if isPathBasedProtocol {
path := url.EscapedPath()
if url.Host != "" {
// Two slashes: host + path = relative path
nodeAddr = url.Host + strings.TrimPrefix(path, "/")
} else if path != "" {
// Three slashes: path is absolute (keep leading slash)
nodeAddr = path
} else {
nodeAddr = url.Host
}
if isPathBasedProtocol && url.Host == "" {
// Three slashes: path is absolute (keep leading slash)
nodeAddr = url.EscapedPath()
} else {
nodeAddr = url.Host
}
+62 -6
View File
@@ -1765,10 +1765,10 @@ func TestBuildNodeConfig_TLSWithCert(t *testing.T) {
}
}
func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
func TestBuildNodeConfig_UnixAbsolutePath(t *testing.T) {
// http+unix scheme: connector=http, dialer=unix.
// Since unix is not in the test registry, dialer falls back to tcp,
// and the path-based address logic (dialer=="unix") is skipped.
// Dialer falls back to tcp when unix is not in the test registry, but
// the absolute socket path from a three-slash URL must still be kept.
rawURL := "http+unix:///var/run/socket"
u, _ := url.Parse(rawURL)
@@ -1780,12 +1780,68 @@ func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
if node.Dialer.Type != "tcp" {
t.Errorf("Dialer.Type = %q, want tcp (unix not in test registry)", node.Dialer.Type)
}
// Addr is url.Host ("") since no host component in three-slash unix URL
if node.Addr != "" {
t.Errorf("Addr = %q, want empty", node.Addr)
if node.Addr != "/var/run/socket" {
t.Errorf("Addr = %q, want /var/run/socket", node.Addr)
}
}
func TestBuildNodeConfig_UnixRelativeHost(t *testing.T) {
u, _ := url.Parse("unix://docker.sock")
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Addr != "docker.sock" {
t.Errorf("Addr = %q, want docker.sock", node.Addr)
}
}
func TestBuildServiceConfig_UnixAbsolutePath(t *testing.T) {
// three-slash: path is the absolute socket listen address, no forward target
svcs, err := buildServiceConfig(mustURL("unix:///var/run/gost.sock"))
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) != 1 {
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
}
if svcs[0].Addr != "/var/run/gost.sock" {
t.Errorf("Addr = %q, want /var/run/gost.sock", svcs[0].Addr)
}
if svcs[0].Forwarder != nil {
t.Errorf("Forwarder = %+v, want nil (no forward target)", svcs[0].Forwarder)
}
}
func TestBuildServiceConfig_UnixPortForward(t *testing.T) {
// two-slash: host is the listen socket, path is a forward target socket
svcs, err := buildServiceConfig(mustURL("unix://gost.sock/gost2.sock"))
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) != 1 {
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
}
if svcs[0].Addr != "gost.sock" {
t.Errorf("Addr = %q, want gost.sock", svcs[0].Addr)
}
if svcs[0].Forwarder == nil || len(svcs[0].Forwarder.Nodes) == 0 {
t.Fatalf("Forwarder = %+v, want a forward target node", svcs[0].Forwarder)
}
if got := svcs[0].Forwarder.Nodes[0].Addr; got != "gost2.sock" {
t.Errorf("forward Addr = %q, want gost2.sock", got)
}
}
func mustURL(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
panic(err)
}
return u
}
func TestBuildServiceConfig_RelayTLSForward(t *testing.T) {
// relay+tls scheme: handler=relay, listener=tls.
// relay not in test registry → handler falls back to auto, listener falls back to tcp.
+232 -15
View File
@@ -3,11 +3,16 @@ package config
import (
"encoding/json"
"io"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
yaml "github.com/goccy/go-yaml"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
var (
@@ -24,8 +29,21 @@ func init() {
var (
global = &Config{}
globalMux sync.RWMutex
// configFile remembers the last file loaded via ReadFile so the API can
// save back to the same location (issue go-gost/x#123).
configFile atomic.Value // string
)
// ConfigFile returns the path of the last config file loaded via ReadFile,
// or an empty string when the config came from stdin, a URL, or inline JSON.
func ConfigFile() string {
if s, ok := configFile.Load().(string); ok {
return s
}
return ""
}
func Global() *Config {
globalMux.RLock()
defer globalMux.RUnlock()
@@ -130,6 +148,7 @@ type TLSOptions struct {
type PluginConfig struct {
Type string `json:"type"`
Addr string `json:"addr"`
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
TLS *TLSConfig `yaml:",omitempty" json:"tls,omitempty"`
Timeout time.Duration `yaml:",omitempty" json:"timeout,omitempty"`
Token string `yaml:",omitempty" json:"token,omitempty"`
@@ -155,6 +174,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 {
@@ -175,6 +195,7 @@ type BypassConfig struct {
// Deprecated: use whitelist instead
Reverse bool `yaml:",omitempty" json:"reverse,omitempty"`
Whitelist bool `yaml:",omitempty" json:"whitelist,omitempty"`
Network string `yaml:",omitempty" json:"network,omitempty"`
Matchers []string `yaml:",omitempty" json:"matchers,omitempty"`
Reload time.Duration `yaml:",omitempty" json:"reload,omitempty"`
File *FileLoader `yaml:",omitempty" json:"file,omitempty"`
@@ -281,6 +302,13 @@ type RecorderConfig struct {
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
// P2PConfig defines a named p2p tunnel provider, currently backed by an
// external gRPC plugin process.
type P2PConfig struct {
Name string `json:"name"`
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
type FileRecorder struct {
Path string `json:"path"`
Sep string `yaml:",omitempty" json:"sep,omitempty"`
@@ -313,6 +341,38 @@ 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. Memory and Redis backends are
// supported; other backends (file, plugin) are follow-ups.
type CacheConfig struct {
Name string `json:"name"`
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"`
}
// RedisCache configures the Redis cache backend.
type RedisCache struct {
Addr string `json:"addr"`
DB int `yaml:",omitempty" json:"db,omitempty"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
Key string `yaml:",omitempty" json:"key,omitempty"` // key prefix, default "gost:cache:"
}
// 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"`
@@ -357,9 +417,21 @@ type ForwarderConfig struct {
// Deprecated: use hop instead
Name string `yaml:",omitempty" json:"name,omitempty"`
// the referenced hop name
Hop string `yaml:",omitempty" json:"hop,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Nodes []*ForwardNodeConfig `json:"nodes"`
Hop string `yaml:",omitempty" json:"hop,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Nodes []*ForwardNodeConfig `json:"nodes"`
HopGroup *ForwardHopGroupConfig `yaml:"hopGroup,omitempty" json:"hopGroup,omitempty"`
}
type ForwardHopGroupConfig struct {
Hops []*ForwardHopConfig `yaml:",omitempty" json:"hops,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
}
type ForwardHopConfig struct {
Hop string `yaml:",omitempty" json:"hop,omitempty"`
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
}
type ForwardNodeConfig struct {
@@ -381,6 +453,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"`
}
@@ -394,6 +467,20 @@ 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 HTTPHeaderRewriteConfig struct {
// Name is the header name regex, e.g. "(?i)^(location|set-cookie)$".
Name string
// Match is the header value regex.
Match string
// Replacement is the replacement string.
Replacement string
// Rewriter is the name of the rewriter plugin (via registry).
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
}
type NodeFilterConfig struct {
@@ -402,14 +489,44 @@ 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 {
// rewrite host header
Host string `yaml:",omitempty" json:"host,omitempty"`
// regex matched against the request path to derive Host; Host becomes a
// replacement template ($1, $2, ...) when set.
HostPattern string `yaml:"hostPattern,omitempty" json:"hostPattern,omitempty"`
// Deprecated: use requestHeader instead
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
// additional request header
@@ -427,6 +544,14 @@ type HTTPNodeConfig struct {
// rewrite response body
RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"`
// rewrite request header values
RewriteRequestHeader []HTTPHeaderRewriteConfig `yaml:"rewriteRequestHeader,omitempty" json:"rewriteRequestHeader,omitempty"`
// rewrite response header values
RewriteResponseHeader []HTTPHeaderRewriteConfig `yaml:"rewriteResponseHeader,omitempty" json:"rewriteResponseHeader,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"`
}
@@ -469,12 +594,15 @@ type ServiceConfig struct {
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Limiter string `yaml:",omitempty" json:"limiter,omitempty"`
Quotas []string `yaml:",omitempty" json:"quotas,omitempty"`
CLimiter string `yaml:"climiter,omitempty" json:"climiter,omitempty"`
RLimiter string `yaml:"rlimiter,omitempty" json:"rlimiter,omitempty"`
Logger string `yaml:",omitempty" json:"logger,omitempty"`
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
Observer string `yaml:",omitempty" json:"observer,omitempty"`
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"`
@@ -484,10 +612,12 @@ type ServiceConfig struct {
}
type ServiceStatus struct {
CreateTime int64 `yaml:"createTime" json:"createTime"`
State string `yaml:"state" json:"state"`
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
CreateTime int64 `yaml:"createTime" json:"createTime"`
State string `yaml:"state" json:"state"`
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
StoppedByLimit bool `yaml:"stoppedByLimit,omitempty" json:"stoppedByLimit,omitempty"`
Quotas []ServiceQuota `yaml:",omitempty" json:"quotas,omitempty"`
}
type ServiceEvent struct {
@@ -503,15 +633,96 @@ type ServiceStats struct {
OutputBytes uint64 `yaml:"outputBytes" json:"outputBytes"`
}
// QuotaConfig is a named cumulative traffic-volume limiter; the same name
// referenced by several services (ServiceConfig.Quotas) shares one counter.
type QuotaConfig struct {
Name string `json:"name"`
Limit string `yaml:",omitempty" json:"limit,omitempty"`
StartsAt string `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt string `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Direction string `yaml:",omitempty" json:"direction,omitempty"`
Flush string `yaml:",omitempty" json:"flush,omitempty"`
Store *QuotaStoreConfig `yaml:",omitempty" json:"store,omitempty"`
// read-only
Status *QuotaStatus `yaml:",omitempty" json:"status,omitempty"`
}
type QuotaStoreConfig struct {
Type string `json:"type"` // "file" (default) | "redis" (stub, not implemented)
File string `yaml:",omitempty" json:"file,omitempty"`
Redis *QuotaRedisConfig `yaml:",omitempty" json:"redis,omitempty"`
}
type QuotaRedisConfig struct {
Addr string `json:"addr"`
DB int `yaml:",omitempty" json:"db,omitempty"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
Key string `yaml:",omitempty" json:"key,omitempty"`
}
type QuotaStatus struct {
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ServiceQuota struct {
Name string `yaml:"name" json:"name"`
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ChainConfig struct {
Name string `json:"name"`
Hops []*HopConfig `json:"hops"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
type ChainGroupEntry struct {
Chain string `yaml:",omitempty" json:"chain,omitempty"`
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
}
// gostDecodeHook is a composite mapstructure decode hook that handles custom
// type conversions needed by GOST config fields. viper's internal unmarshalling
// never calls yaml.Unmarshaler / json.Unmarshaler on leaf types, so the hook is
// the only way to support these.
func gostDecodeHook() mapstructure.DecodeHookFunc {
return func(f, t reflect.Type, data any) (any, error) {
// Plain string → ChainGroupEntry: backward compat for "chains: [a, b]".
if t == reflect.TypeOf(ChainGroupEntry{}) {
if s, ok := data.(string); ok {
return ChainGroupEntry{Chain: s}, nil
}
return data, nil
}
// String → time.Duration: accept "10s", "1m", etc. in YAML/JSON.
if t == reflect.TypeOf(time.Duration(0)) {
if s, ok := data.(string); ok {
return time.ParseDuration(s)
}
return data, nil
}
return data, nil
}
}
type ChainGroupConfig struct {
Chains []string `yaml:",omitempty" json:"chains,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Chains []*ChainGroupEntry `yaml:",omitempty" json:"chains,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
}
type HopConfig struct {
@@ -555,6 +766,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"`
}
@@ -571,7 +783,11 @@ 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"`
P2Ps []*P2PConfig `yaml:"p2ps,omitempty" json:"p2ps,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"`
RLimiters []*LimiterConfig `yaml:"rlimiters,omitempty" json:"rlimiters,omitempty"`
Observers []*ObserverConfig `yaml:",omitempty" json:"observers,omitempty"`
@@ -588,7 +804,7 @@ func (c *Config) Load() error {
return err
}
return v.Unmarshal(c)
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
}
func (c *Config) Read(r io.Reader, configType string) error {
@@ -597,15 +813,17 @@ func (c *Config) Read(r io.Reader, configType string) error {
return err
}
return v.Unmarshal(c)
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
}
func (c *Config) ReadFile(file string) error {
v.SetConfigFile(file)
v.SetConfigType(strings.TrimPrefix(filepath.Ext(file), ".")) // force format from extension
if err := v.ReadInConfig(); err != nil {
return err
}
return v.Unmarshal(c)
configFile.Store(file)
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
}
func (c *Config) Write(w io.Writer, format string) error {
@@ -618,9 +836,8 @@ func (c *Config) Write(w io.Writer, format string) error {
case "yaml":
fallthrough
default:
enc := yaml.NewEncoder(w)
enc := yaml.NewEncoder(w, yaml.Indent(2))
defer enc.Close()
enc.SetIndent(2)
return enc.Encode(c)
}
+87 -20
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,13 +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/core/service"
"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"
@@ -36,11 +38,16 @@ import (
limiter_parser "github.com/go-gost/x/config/parsing/limiter"
logger_parser "github.com/go-gost/x/config/parsing/logger"
observer_parser "github.com/go-gost/x/config/parsing/observer"
p2p_parser "github.com/go-gost/x/config/parsing/p2p"
quota_parser "github.com/go-gost/x/config/parsing/quota"
recorder_parser "github.com/go-gost/x/config/parsing/recorder"
resolver_parser "github.com/go-gost/x/config/parsing/resolver"
rewriter_parser "github.com/go-gost/x/config/parsing/rewriter"
router_parser "github.com/go-gost/x/config/parsing/router"
sd_parser "github.com/go-gost/x/config/parsing/sd"
service_parser "github.com/go-gost/x/config/parsing/service"
quota "github.com/go-gost/x/limiter/quota"
xp2p "github.com/go-gost/x/p2p"
"github.com/go-gost/x/registry"
)
@@ -67,23 +74,23 @@ func (l *loader) Load(cfg *config.Config) error {
return nil
}
tlsCfg, err := parsing.BuildDefaultTLSConfig(cfg.TLS)
if err != nil {
return err
}
logCfg := cfg.Log
if logCfg == nil {
logCfg = &config.LogConfig{}
}
logger.SetDefault(logger_parser.ParseLogger(&config.LoggerConfig{Log: logCfg}))
if err := register(cfg); err != nil {
tlsCfg, err := parsing.BuildDefaultTLSConfig(cfg.TLS)
if err != nil {
return err
}
parsing.SetDefaultTLSConfig(tlsCfg)
if err := register(cfg); err != nil {
return err
}
return nil
}
@@ -94,14 +101,12 @@ type named[T any] struct {
v T
}
// registerGroup replaces all entries in r with the given entries.
// Old entries are unregistered first; if any new entry fails to register,
// the group is left partially updated (matching the historical reload
// behavior for intra-group failures).
// registerGroup replaces all entries in r with the given entries. Old entries
// are unregistered first (see unregisterAll); if any new entry fails to
// register, the group is left partially updated (matching the historical
// reload behavior for intra-group failures).
func registerGroup[T any](entries []named[T], r reg.Registry[T]) error {
for name := range r.GetAll() {
r.Unregister(name)
}
unregisterAll(r)
for _, e := range entries {
if err := r.Register(e.name, e.v); err != nil {
return err
@@ -110,6 +115,15 @@ func registerGroup[T any](entries []named[T], r reg.Registry[T]) error {
return nil
}
// unregisterAll removes every entry from r. registry.Unregister closes a value
// before deleting it when it implements io.Closer, so for services this frees
// the bound port (a service's Close closes its listener).
func unregisterAll[T any](r reg.Registry[T]) {
for name := range r.GetAll() {
r.Unregister(name)
}
}
// register parses config sections and registers them into the global
// registries. Groups are processed in dependency order: leaf components
// first, then hops, chains, and finally services. This ensures that
@@ -236,6 +250,36 @@ 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[xp2p.Tunnel]
for _, c := range cfg.P2Ps {
entries = append(entries, named[xp2p.Tunnel]{c.Name, p2p_parser.ParseP2P(c)})
}
if err := registerGroup(entries, registry.P2PRegistry()); 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 {
@@ -246,6 +290,16 @@ func register(cfg *config.Config) error {
}
}
{
var entries []named[*quota.Limiter]
for _, c := range cfg.Quotas {
entries = append(entries, named[*quota.Limiter]{c.Name, quota_parser.ParseQuotaLimiter(c)})
}
if err := registerGroup(entries, registry.QuotaLimiterRegistry()); err != nil {
return err
}
}
{
var entries []named[conn.ConnLimiter]
for _, c := range cfg.CLimiters {
@@ -300,21 +354,34 @@ func register(cfg *config.Config) error {
// --- services (references chains, resolvers, hosts, recorders,
// limiters, observers, hops from registries) ---
//
// Services are the only component group whose construction binds a port:
// service_parser.ParseService calls listener.Init, which binds. The generic
// registerGroup "parse-all-then-swap" sequence used by the other groups
// would therefore bind every new listener while the old services are still
// listening, causing EADDRINUSE on SIGHUP reload (issue #754, regressed by
// 82e7e50). Instead, unregister all old services first — unregisterAll
// closes each one (a service implements io.Closer), freeing its port —
// then parse, bind, and register the new ones.
//
// Trade-off: a parse error in the loop below leaves the registry partially
// updated (the old services are already closed and only the services parsed
// before the failure are registered). The construct/bind split would be
// needed for atomic reload, but this restores the pre-82e7e50 behavior.
{
var entries []named[service.Service]
unregisterAll(registry.ServiceRegistry())
for _, c := range cfg.Services {
svc, err := service_parser.ParseService(c)
if err != nil {
return err
}
if svc != nil {
entries = append(entries, named[service.Service]{c.Name, svc})
if err := registry.ServiceRegistry().Register(c.Name, svc); err != nil {
return err
}
}
}
if err := registerGroup(entries, registry.ServiceRegistry()); err != nil {
return err
}
}
return nil
+128
View File
@@ -3,8 +3,10 @@ package loader
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
"github.com/go-gost/core/handler"
@@ -805,3 +807,129 @@ func TestRegister_ServiceUnknownListener(t *testing.T) {
t.Fatal("expected error for unknown listener type, got nil")
}
}
// bindingStubListener is a stubListener whose Init actually binds a TCP port
// via net.Listen, reproducing the EADDRINUSE condition the close-old-before-
// bind ordering in register() must prevent. Used only by reload-collision tests.
type bindingStubListener struct {
addr string
ln net.Listener
}
func (l *bindingStubListener) Init(md metadata.Metadata) error {
ln, err := net.Listen("tcp", l.addr)
if err != nil {
return err
}
l.ln = ln
return nil
}
func (l *bindingStubListener) Accept() (net.Conn, error) {
if l.ln == nil {
return nil, net.ErrClosed
}
return l.ln.Accept()
}
func (l *bindingStubListener) Addr() net.Addr {
if l.ln != nil {
return l.ln.Addr()
}
return &net.TCPAddr{IP: net.IPv4zero, Port: 0}
}
func (l *bindingStubListener) Close() error {
if l.ln == nil {
return nil
}
return l.ln.Close()
}
// freeTCPPort returns the number of a TCP port that is free at call time by
// opening and immediately closing a listener on 127.0.0.1:0. There is a small
// race window before the caller rebinds, which is acceptable for tests.
func freeTCPPort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen for free port: %v", err)
}
addr := l.Addr().(*net.TCPAddr)
l.Close()
return addr.Port
}
// TestRegister_ServiceReloadNoCollision verifies that calling register() twice
// with a service on a fixed port does NOT return EADDRINUSE on the second call
// (issue #754). Before the close-old-before-bind ordering, the second
// register() bound the new listener while the old one was still listening.
func TestRegister_ServiceReloadNoCollision(t *testing.T) {
port := freeTCPPort(t)
addr := fmt.Sprintf("127.0.0.1:%d", port)
// Register a binding listener factory under a test-specific name so we
// don't interfere with the "tcp" save/restore in TestRegister_Services.
const factoryName = "tcp-binding-test"
origListener := registry.ListenerRegistry().Get(factoryName)
registry.ListenerRegistry().Register(factoryName, func(opts ...listener.Option) listener.Listener {
options := listener.Options{}
for _, opt := range opts {
opt(&options)
}
return &bindingStubListener{addr: options.Addr}
})
// Reuse the inert "auto" handler stub.
origHandler := registry.HandlerRegistry().Get("auto")
registry.HandlerRegistry().Register("auto", func(opts ...handler.Option) handler.Handler {
return &stubHandler{}
})
t.Cleanup(func() {
registry.ListenerRegistry().Unregister(factoryName)
registry.HandlerRegistry().Unregister("auto")
if origListener != nil {
registry.ListenerRegistry().Register(factoryName, origListener)
}
if origHandler != nil {
registry.HandlerRegistry().Register("auto", origHandler)
}
})
r := registry.ServiceRegistry()
const svcName = "reload-svc"
t.Cleanup(func() { r.Unregister(svcName) })
buildCfg := func() *config.Config {
return &config.Config{
Services: []*config.ServiceConfig{
{
Name: svcName,
Addr: addr,
Listener: &config.ListenerConfig{Type: factoryName},
Handler: &config.HandlerConfig{Type: "auto"},
},
},
}
}
// First load: registers the service and binds the port.
if err := register(buildCfg()); err != nil {
t.Fatalf("first register: unexpected error: %v", err)
}
if !r.IsRegistered(svcName) {
t.Fatal("expected service registered after first load")
}
// Second load simulates a SIGHUP reload. With the old ordering this bound
// the new listener while the old one was still open and returned
// "address already in use"; the fix closes old services first.
if err := register(buildCfg()); err != nil {
if strings.Contains(err.Error(), "address already in use") {
t.Fatalf("second register (reload): port collision not avoided: %v", err)
}
t.Fatalf("second register (reload): unexpected error: %v", err)
}
if !r.IsRegistered(svcName) {
t.Fatal("expected service registered after reload")
}
}
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
)
@@ -24,6 +25,9 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -15,6 +15,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
)
@@ -27,6 +28,9 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+5
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
)
@@ -23,6 +24,9 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
@@ -50,6 +54,7 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
opts := []xbypass.Option{
xbypass.MatchersOption(cfg.Matchers),
xbypass.WhitelistOption(cfg.Reverse || cfg.Whitelist),
xbypass.NetworkOption(cfg.Network),
xbypass.ReloadPeriodOption(cfg.Reload),
xbypass.LoggerOption(logger.Default().WithFields(map[string]any{
"kind": "bypass",
+44
View File
@@ -0,0 +1,44 @@
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. Memory and Redis
// backends are 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(),
})
}
if cfg.Redis != nil {
return xcache.NewRedisCache(xcache.RedisOptions{
Addr: cfg.Redis.Addr,
DB: cfg.Redis.DB,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DefaultTTL: cfg.Redis.TTL,
KeyPrefix: cfg.Redis.Key,
Logger: logger.Default(),
})
}
return nil
}
+16
View File
@@ -19,6 +19,8 @@ import (
"github.com/go-gost/x/internal/plugin"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xplugin "github.com/go-gost/x/plugin"
xs "github.com/go-gost/x/selector"
)
// ParseHop converts a HopConfig into a hop.Hop. It resolves plugin backends
@@ -31,6 +33,9 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
@@ -158,10 +163,21 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
sel = selector_parser.DefaultNodeSelector()
}
maxFails, timeout := xs.DefaultMaxFails, xs.DefaultFailTimeout
if cfg.Selector != nil {
if cfg.Selector.MaxFails > 0 {
maxFails = cfg.Selector.MaxFails
}
if cfg.Selector.FailTimeout > 0 {
timeout = cfg.Selector.FailTimeout
}
}
opts := []xhop.Option{
xhop.NameOption(cfg.Name),
xhop.NodeOption(nodes...),
xhop.SelectorOption(sel),
xhop.FailFilterSettingsOption(maxFails, timeout),
xhop.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
xhop.ReloadPeriodOption(cfg.Reload),
xhop.LoggerOption(log.WithFields(map[string]any{
+4
View File
@@ -12,6 +12,7 @@ import (
hosts_plugin "github.com/go-gost/x/hosts/plugin"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseHostMapper converts a HostsConfig into a hosts.HostMapper. It resolves
@@ -23,6 +24,9 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -11,6 +11,7 @@ import (
ingress_plugin "github.com/go-gost/x/ingress/plugin"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseIngress converts an IngressConfig into an ingress.Ingress. It resolves
@@ -22,6 +23,9 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -15,6 +15,7 @@ import (
xrate "github.com/go-gost/x/limiter/rate"
xtraffic "github.com/go-gost/x/limiter/traffic"
traffic_plugin "github.com/go-gost/x/limiter/traffic/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseTrafficLimiter converts a LimiterConfig into a traffic.TrafficLimiter.
@@ -26,6 +27,9 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+12 -1
View File
@@ -1,6 +1,7 @@
package logger
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -44,10 +45,20 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
Compress: cfg.Log.Rotation.Compress,
}
} else {
// Nothing rotates this file, and it is the only place that sees both
// the path and the missing rotation — the default (an unconfigured
// logger) always carries one.
fmt.Fprintf(os.Stderr, "logger: %s has no rotation configured: it will grow without bound\n", cfg.Log.Output)
os.MkdirAll(filepath.Dir(cfg.Log.Output), 0755)
f, err := os.OpenFile(cfg.Log.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
logger.Default().Warn(err)
// Reported on stderr, not through logger.Default(): that logger
// is what this call is on its way to installing and is nil until
// it is — calling a method on it panics, which aborted the whole
// process on a log path that could not be opened (an app whose
// working directory is not writable, say). A log file that
// cannot be opened leaves the default output in place instead.
fmt.Fprintf(os.Stderr, "logger: open %s: %v (logging to stderr)\n", cfg.Log.Output, err)
} else {
out = f
}
+41 -1
View File
@@ -1,7 +1,10 @@
package logger
import (
"fmt"
"io"
"os"
"os/exec"
"testing"
"github.com/go-gost/core/logger"
@@ -9,8 +12,15 @@ import (
xlogger "github.com/go-gost/x/logger"
)
// probeEnv marks the child process TestParseLogger_UnopenableOutput spawns:
// that test needs a process in which no default logger was ever installed,
// which is the state the failure path used to dereference.
const probeEnv = "X_LOGGER_UNOPENABLE_PROBE"
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
if os.Getenv(probeEnv) != "1" {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
}
m.Run()
}
@@ -140,3 +150,33 @@ func TestList_WithNames(t *testing.T) {
t.Fatal("expected empty list for unregistered names")
}
}
// TestParseLogger_UnopenableOutput: a log file that cannot be opened must leave
// the process logging somewhere, not panic. The failure used to be reported
// through logger.Default(), which is nil until a caller installs one — and this
// call is usually the one doing the installing — so an unwritable path (an app
// whose working directory is not writable, a typo'd directory) dereferenced a
// nil logger and took the whole process down.
//
// The state that matters is process-wide and TestMain sets it for every other
// case here, so the assertion runs in a child process: a fresh one is the only
// place the default logger is still unset, exactly as it is at startup.
func TestParseLogger_UnopenableOutput(t *testing.T) {
if os.Getenv(probeEnv) == "1" {
lg := ParseLogger(&config.LoggerConfig{Log: &config.LogConfig{
Output: "/nonexistent-dir-for-test/wisper.log",
Level: "info",
}})
if lg == nil {
fmt.Fprintln(os.Stderr, "ParseLogger returned nil for an unopenable output")
os.Exit(1)
}
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestParseLogger_UnopenableOutput", "-test.v")
cmd.Env = append(os.Environ(), probeEnv+"=1")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("a log file that cannot be opened must not be fatal:\n%s", out)
}
}
+285 -59
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"
@@ -20,15 +22,160 @@ import (
tls_util "github.com/go-gost/x/internal/util/tls"
mdx "github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xp2p "github.com/go-gost/x/p2p"
"github.com/go-gost/x/registry"
"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
// filterToMatcherRule converts a deprecated NodeFilterConfig (host/protocol/
// path) into an equivalent matcher DSL rule. Empty fields are omitted; an
// empty result means the node is unconditional. Path maps to PathPrefix
// (the legacy filter is a prefix match, not exact).
func filterToMatcherRule(filter *config.NodeFilterConfig) string {
if filter == nil {
return ""
}
var parts []string
// Normalize the legacy host forms first: `*.example.com` and
// `.example.com` both meant "apex AND subdomains" in the legacy filter.
// Strip the wildcard so the leading-dot branch below handles both.
host := filter.Host
if strings.HasPrefix(host, "*") {
host = host[1:]
if !strings.HasPrefix(host, ".") {
host = "." + host
}
}
if host != "" {
// A leading-dot host matches the apex AND its subdomains in the
// legacy filter (HasSuffix on the dot-stripped form), but matcher
// Host(.x) only matches subdomains. Emit both to preserve apex
// coverage. Parenthesize the OR when other conjuncts follow, since
// && binds tighter than || in the matcher DSL.
if strings.HasPrefix(host, ".") {
h := "Host(`" + host[1:] + "`) || Host(`" + host + "`)"
if filter.Protocol != "" || filter.Path != "" {
h = "(" + h + ")"
}
parts = append(parts, h)
} else {
parts = append(parts, "Host(`"+host+"`)")
}
}
if prot := filter.Protocol; prot != "" {
parts = append(parts, "Proto(`"+prot+"`)")
}
if path := filter.Path; path != "" {
parts = append(parts, "PathPrefix(`"+path+"`)")
}
return strings.Join(parts, " && ")
}
// 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
js, ok := strings.CutPrefix(v.Match, "json:")
del := false
if !ok {
// json-:<path> deletes the matched JSON field instead of replacing it.
if js, ok = strings.CutPrefix(v.Match, "json-:"); ok {
del = true
}
}
if ok {
// json:<path>[=<value-regex>] 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
}
if del {
rewriteType = "json-:" + path
} else {
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 parseHeaderRewrites(vs []config.HTTPHeaderRewriteConfig, log logger.Logger) []chain.HTTPHeaderRewriteSettings {
var out []chain.HTTPHeaderRewriteSettings
for _, v := range vs {
var name, pattern *regexp.Regexp
if v.Name != "" {
name, _ = regexp.Compile(v.Name)
}
if v.Match != "" {
pattern, _ = regexp.Compile(v.Match)
}
rw := chain.HTTPHeaderRewriteSettings{
Name: name,
Pattern: pattern,
Replacement: []byte(v.Replacement),
}
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)
}
// Gate on the config string, not the compiled regexp: regexp.Compile("")
// returns a non-nil match-all regexp, so an empty Name with no rewriter
// would otherwise become a silent no-op rule.
if v.Name != "" || 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
@@ -123,6 +270,24 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
return nil, fmt.Errorf("unregistered dialer: %s", dialCfg.Type)
}
// p2p: wrap the dialer so the node's base transport (the path to this
// node) goes through a tunnel opened by the named p2p plugin instead of
// a direct network dial. The inner dialer's protocol (tls/ws/mux) runs
// unchanged on top of the tunnel. Only whitelisted dialers qualify:
// the wrapper's contract is "inner dials one plain TCP stream via
// options.Dialer". The wrap happens before Init so the chain inits
// exactly once, through the wrapper.
if name := mdutil.GetString(md, parsing.MDKeyP2P); name != "" {
provider := registry.P2PRegistry().Get(name)
if provider == nil {
return nil, fmt.Errorf("unregistered p2p: %s", name)
}
if !xp2p.SupportedDialer(dialCfg.Type) {
return nil, fmt.Errorf("dialer %q does not support p2p", dialCfg.Type)
}
d = xp2p.NewTunnelDialer(d, provider)
}
if err := d.Init(mdx.NewMetadata(dialCfg.Metadata)); err != nil {
dialerLogger.Error("init: ", err)
return nil, err
@@ -151,44 +316,53 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
chain.NetworkNodeOption(cfg.Network),
}
if filter := cfg.Filter; filter != nil {
// convert *.example.com to .example.com
// convert *example.com to example.com
host := filter.Host
if strings.HasPrefix(host, "*") {
host = host[1:]
if !strings.HasPrefix(host, ".") {
host = "." + host
}
}
var (
rule string
priority int
bodySize int
)
settings := &chain.NodeFilterSettings{
Protocol: filter.Protocol,
Host: host,
Path: filter.Path,
}
opts = append(opts, chain.NodeFilterOption(settings))
// Deprecated filter: normalize into an equivalent matcher DSL rule so the
// runtime keeps a single filtering logic. Explicit -1 priority opts out of
// auto-priority and the priority short-circuit, preserving the legacy
// filter behavior (always through the selector).
if filter := cfg.Filter; filter != nil {
rule = filterToMatcherRule(filter)
priority = -1
bodySize = DefaultMatcherBodySize
}
if cfg.Matcher != nil {
priority := cfg.Matcher.Priority
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)
if priority == 0 {
priority = len(cfg.Matcher.Rule)
}
opts = append(opts, chain.MatcherNodeOption(matcher))
} else {
log.Error(err)
priority = -1
}
}
opts = append(opts, chain.PriorityNodeOption(priority))
priority = cfg.Matcher.Priority
rule = cfg.Matcher.Rule
bodySize = cfg.Matcher.BodySize
}
if rule = strings.TrimSpace(rule); rule != "" {
if matcher, err := routing.NewMatcher(rule); err == nil {
log.Debugf("new matcher for node %s with rule %s", cfg.Name, 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(rule)
}
opts = append(opts, chain.MatcherNodeOption(matcher))
} else {
log.Error(err)
priority = -1
}
}
if bodySize <= 0 {
bodySize = DefaultMatcherBodySize
} else if bodySize > MaxMatcherBodySize {
bodySize = MaxMatcherBodySize
}
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
opts = append(opts, chain.PriorityNodeOption(priority))
if cfg.HTTP != nil {
settings := &chain.HTTPNodeSettings{
Host: cfg.HTTP.Host,
@@ -199,6 +373,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
settings.RequestHeader = cfg.HTTP.Header
}
if v := cfg.HTTP.HostPattern; v != "" {
settings.HostPattern, _ = regexp.Compile(v)
}
if auth := cfg.HTTP.Auth; auth != nil && auth.Username != "" {
settings.Auther = xauth.NewAuthenticator(
xauth.AuthsOption(map[string]string{auth.Username: auth.Password}),
@@ -222,33 +400,16 @@ 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)...)
settings.RewriteRequestHeader = parseHeaderRewrites(cfg.HTTP.RewriteRequestHeader, log)
settings.RewriteResponseHeader = parseHeaderRewrites(cfg.HTTP.RewriteResponseHeader, log)
if v := strings.TrimSpace(cfg.HTTP.FailCodes); v != "" {
settings.FailCodes = parseFailCodes(v, nodeLogger)
}
opts = append(opts, chain.HTTPNodeOption(settings))
}
@@ -265,5 +426,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
}
+118 -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",
@@ -345,6 +387,44 @@ func TestParseNode_WithHTTPDeprecatedHeader(t *testing.T) {
}
}
func TestParseNode_WithHeaderRewrite(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "hdr-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
RewriteRequestHeader: []config.HTTPHeaderRewriteConfig{
{Name: "(?i)^referer$", Match: ".*", Replacement: ""},
},
RewriteResponseHeader: []config.HTTPHeaderRewriteConfig{
{Name: "(?i)^location$", Match: "https://github.com", Replacement: "https://127.0.0.1:8080"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
settings := n.Options().HTTP
if settings == nil {
t.Fatal("expected HTTP settings")
}
if len(settings.RewriteRequestHeader) != 1 || len(settings.RewriteResponseHeader) != 1 {
t.Fatalf("rewrite header counts = %d/%d, want 1/1", len(settings.RewriteRequestHeader), len(settings.RewriteResponseHeader))
}
}
func TestParseHeaderRewrites_EmptyNameNoRewriter(t *testing.T) {
// Empty Name with no Rewriter must not be appended (silent no-op guard).
out := parseHeaderRewrites([]config.HTTPHeaderRewriteConfig{
{Name: "", Match: ".*", Replacement: ""},
}, testLogger())
if len(out) != 0 {
t.Fatalf("out = %d rules, want 0", len(out))
}
}
func TestParseNode_WithTLSNode(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "tls-node",
@@ -459,3 +539,38 @@ func TestParseNode_DeprecatedFilterFields(t *testing.T) {
t.Fatal("expected non-nil node")
}
}
func TestFilterToMatcherRule(t *testing.T) {
tests := []struct {
name string
filter *config.NodeFilterConfig
want string
}{
{"nil", nil, ""},
{"all empty", &config.NodeFilterConfig{}, ""},
{"host only", &config.NodeFilterConfig{Host: "example.com"}, "Host(`example.com`)"},
{"dot host", &config.NodeFilterConfig{Host: ".example.com"},
"Host(`example.com`) || Host(`.example.com`)"},
{"star host", &config.NodeFilterConfig{Host: "*.example.com"},
"Host(`example.com`) || Host(`.example.com`)"},
{"protocol only", &config.NodeFilterConfig{Protocol: "ssh"}, "Proto(`ssh`)"},
{"path only", &config.NodeFilterConfig{Path: "/api"}, "PathPrefix(`/api`)"},
{"combined", &config.NodeFilterConfig{
Host: ".example.com",
Protocol: "http",
Path: "/api",
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
{"star combined", &config.NodeFilterConfig{
Host: "*.example.com",
Protocol: "http",
Path: "/api",
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filterToMatcherRule(tt.filter); got != tt.want {
t.Errorf("filterToMatcherRule(%v) = %q, want %q", tt.filter, got, tt.want)
}
})
}
}
+35
View File
@@ -0,0 +1,35 @@
package node
import (
"testing"
"github.com/go-gost/x/config"
coreRouting "github.com/go-gost/core/routing"
"github.com/go-gost/x/routing"
)
func TestFilterToMatcherRule_Precedence(t *testing.T) {
for _, fc := range []struct{ name, host string }{
{"dot", ".example.com"},
{"star", "*.example.com"},
} {
t.Run(fc.name, func(t *testing.T) {
rule := filterToMatcherRule(&config.NodeFilterConfig{
Host: fc.host, Protocol: "http", Path: "/api",
})
m, err := routing.NewMatcher(rule)
if err != nil {
t.Fatalf("parse %q: %v", rule, err)
}
if m.Match(&coreRouting.Request{Host: "example.com", Protocol: "https", Path: "/other"}) {
t.Errorf("rule %q matched apex+https+/other (should reject)", rule)
}
if !m.Match(&coreRouting.Request{Host: "example.com", Protocol: "http", Path: "/api/v1"}) {
t.Errorf("rule %q rejected apex+http+/api/v1 (should accept)", rule)
}
if !m.Match(&coreRouting.Request{Host: "www.example.com", Protocol: "http", Path: "/api"}) {
t.Errorf("rule %q rejected www+http+/api (should accept)", rule)
}
})
}
}
+5
View File
@@ -4,10 +4,12 @@ import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
observer_plugin "github.com/go-gost/x/observer/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseObserver converts an ObserverConfig into an observer.Observer. It only
@@ -18,6 +20,9 @@ func ParseObserver(cfg *config.ObserverConfig) observer.Observer {
return nil
}
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+47
View File
@@ -0,0 +1,47 @@
// Package p2p converts a [config.P2PConfig] into a p2p.Tunnel.
package p2p
import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xp2p "github.com/go-gost/x/p2p"
p2p_plugin "github.com/go-gost/x/p2p/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseP2P converts a P2PConfig into a p2p.Tunnel backed by an
// external gRPC plugin. Returns nil when cfg is nil or no plugin is
// configured. The HTTP plugin variant is not implemented; configuring it
// logs a deprecation notice and falls back to gRPC.
func ParseP2P(cfg *config.P2PConfig) (p xp2p.Tunnel) {
if cfg == nil || cfg.Plugin == nil {
return nil
}
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
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":
logger.Default().Warn("p2p: http plugin type is deprecated/unimplemented, falling back to grpc")
fallthrough
default:
return p2p_plugin.NewGRPCPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
)
}
}
+8
View File
@@ -92,4 +92,12 @@ const (
// MDKeyDialTimeout sets the dial timeout for outbound connections.
MDKeyDialTimeout = "dialTimeout"
// MDKeyLabels holds static key/value labels attached to a service's
// records and logs.
MDKeyLabels = "labels"
// MDKeyP2P selects the p2p tunnel provider for a chain node's base
// transport. The value is the name of a registered p2ps: component.
MDKeyP2P = "p2p"
)
+256 -3
View File
@@ -1,6 +1,11 @@
package parser
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-gost/x/config"
@@ -47,6 +52,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
SDs: []*config.SDConfig{{Name: "sd1"}},
Recorders: []*config.RecorderConfig{{Name: "rec1"}},
Limiters: []*config.LimiterConfig{{Name: "l1"}},
Quotas: []*config.QuotaConfig{{Name: "q1"}},
CLimiters: []*config.LimiterConfig{{Name: "cl1"}},
RLimiters: []*config.LimiterConfig{{Name: "rl1"}},
Loggers: []*config.LoggerConfig{{Name: "log1"}},
@@ -66,6 +72,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
SDs: []*config.SDConfig{{Name: "sd2"}},
Recorders: []*config.RecorderConfig{{Name: "rec2"}},
Limiters: []*config.LimiterConfig{{Name: "l2"}},
Quotas: []*config.QuotaConfig{{Name: "q2"}},
CLimiters: []*config.LimiterConfig{{Name: "cl2"}},
RLimiters: []*config.LimiterConfig{{Name: "rl2"}},
Loggers: []*config.LoggerConfig{{Name: "log2"}},
@@ -111,6 +118,9 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
if len(got.Limiters) != 2 {
t.Fatal("limiters not properly appended")
}
if len(got.Quotas) != 2 {
t.Fatal("quotas not properly appended")
}
if len(got.CLimiters) != 2 {
t.Fatal("climiters not properly appended")
}
@@ -194,7 +204,7 @@ func TestMergeConfig_ScalarKeepsCfg1WhenCfg2Nil(t *testing.T) {
func TestInit(t *testing.T) {
Init(Args{
CfgFile: "test.yml",
CfgFiles: []string{"test.yml"},
Services: []string{"s1"},
Nodes: []string{"n1"},
Debug: true,
@@ -203,8 +213,8 @@ func TestInit(t *testing.T) {
MetricsAddr: ":9090",
})
if defaultParser.args.CfgFile != "test.yml" {
t.Fatalf("CfgFile not set: got %q", defaultParser.args.CfgFile)
if len(defaultParser.args.CfgFiles) != 1 || defaultParser.args.CfgFiles[0] != "test.yml" {
t.Fatalf("CfgFiles not set: got %v", defaultParser.args.CfgFiles)
}
if len(defaultParser.args.Services) != 1 || defaultParser.args.Services[0] != "s1" {
t.Fatal("Services not set")
@@ -225,3 +235,246 @@ func TestInit(t *testing.T) {
t.Fatal("MetricsAddr not set")
}
}
func TestParse_MultiFile(t *testing.T) {
// Create two temporary config files.
cfg1 := &config.Config{
Services: []*config.ServiceConfig{{Name: "s1", Addr: ":8080"}},
}
cfg2 := &config.Config{
Services: []*config.ServiceConfig{{Name: "s2", Addr: ":8081"}},
Log: &config.LogConfig{Level: "debug"},
Quotas: []*config.QuotaConfig{{Name: "q1"}},
}
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "cfg1.yml")
file2 := filepath.Join(tmpDir, "cfg2.yml")
for _, entry := range []struct {
path string
cfg *config.Config
}{
{file1, cfg1},
{file2, cfg2},
} {
f, err := os.Create(entry.path)
if err != nil {
t.Fatal(err)
}
if err := entry.cfg.Write(f, "yaml"); err != nil {
f.Close()
t.Fatal(err)
}
f.Close()
}
Init(Args{
CfgFiles: []string{file1, file2},
})
got, err := Parse()
if err != nil {
t.Fatalf("Parse(): %v", err)
}
if len(got.Services) != 2 {
t.Fatalf("expected 2 services, got %d", len(got.Services))
}
if got.Log == nil || got.Log.Level != "debug" {
t.Fatal("Log.Level not set from cfg2")
}
if len(got.Quotas) != 1 || got.Quotas[0].Name != "q1" {
t.Fatal("Quotas not merged from cfg2")
}
}
func TestIsHTTPURL(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"http://example.com/config.yaml", true},
{"https://example.com/config.json", true},
{"ftp://example.com/config.yaml", false},
{"config.yaml", false},
{"/path/to/config.yml", false},
{"http://[::1]:8080/cfg", true},
}
for _, tt := range tests {
if got := isHTTPURL(tt.input); got != tt.expected {
t.Errorf("isHTTPURL(%q) = %v, want %v", tt.input, got, tt.expected)
}
}
}
func TestDetectFormat(t *testing.T) {
tests := []struct {
contentType string
url string
want string
}{
{"", "http://example.com/config.json", "json"},
{"", "http://example.com/config.yml", "yaml"},
{"", "http://example.com/config.yaml", "yaml"},
{"", "http://example.com/config", "yaml"}, // default
{"application/json", "http://example.com/config", "json"},
{"application/yaml", "http://example.com/config", "yaml"},
{"application/x-yaml", "http://example.com/config", "yaml"},
{"text/yaml", "http://example.com/config", "yaml"},
{"text/x-yaml", "http://example.com/config", "yaml"},
{"text/plain; charset=utf-8", "http://example.com/config.json", "json"}, // content-type ignored, extension wins
{"text/plain", "http://example.com/config", "yaml"}, // unknown -> default
}
for _, tt := range tests {
if got := detectFormat(tt.contentType, tt.url); got != tt.want {
t.Errorf("detectFormat(%q, %q) = %q, want %q", tt.contentType, tt.url, got, tt.want)
}
}
}
func TestSanitizeURL(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"http://user:pass@example.com/path", "http://example.com/path"},
{"https://example.com/path", "https://example.com/path"},
{"not a url", "not%20a%20url"},
{"http://user@example.com/path", "http://example.com/path"}, // user with no password
}
for _, tt := range tests {
if got := sanitizeURL(tt.input); got != tt.expected {
t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestReadConfigFromURL_YAML(t *testing.T) {
// Write a YAML config and serve it via httptest.
cfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "svc", Addr: ":8080"}},
Log: &config.LogConfig{Level: "debug"},
}
var buf bytes.Buffer
if err := cfg.Write(&buf, "yaml"); err != nil {
t.Fatal(err)
}
body := buf.Bytes()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(body)
}))
defer ts.Close()
got := &config.Config{}
if err := readConfigFromURL(ts.URL, got); err != nil {
t.Fatalf("readConfigFromURL: %v", err)
}
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
t.Fatalf("unexpected services: %+v", got.Services)
}
if got.Log == nil || got.Log.Level != "debug" {
t.Fatalf("unexpected log: %+v", got.Log)
}
}
func TestReadConfigFromURL_JSON(t *testing.T) {
// Remote serving JSON with explicit Content-Type.
cfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "json-svc", Addr: ":9090"}},
}
var buf bytes.Buffer
if err := cfg.Write(&buf, "json"); err != nil {
t.Fatal(err)
}
body := buf.Bytes()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}))
defer ts.Close()
got := &config.Config{}
if err := readConfigFromURL(ts.URL, got); err != nil {
t.Fatalf("readConfigFromURL: %v", err)
}
if got.Services[0].Name != "json-svc" {
t.Fatalf("unexpected service name: %s", got.Services[0].Name)
}
}
func TestReadConfigFromURL_Non200(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
err := readConfigFromURL(ts.URL, &config.Config{})
if err == nil {
t.Fatal("expected error for 404 response")
}
}
func TestReadConfigFromURL_OverSize(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(make([]byte, maxConfigSize+1))
}))
defer ts.Close()
err := readConfigFromURL(ts.URL, &config.Config{})
if err == nil {
t.Fatal("expected error for oversized response")
}
}
func TestParse_FileAndURL(t *testing.T) {
// Create a local file.
fileCfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "file-svc", Addr: ":8080"}},
}
tmpDir := t.TempDir()
f, err := os.Create(filepath.Join(tmpDir, "local.yml"))
if err != nil {
t.Fatal(err)
}
if err := fileCfg.Write(f, "yaml"); err != nil {
f.Close()
t.Fatal(err)
}
f.Close()
// Serve a second config over HTTP.
urlCfg := &config.Config{
Services: []*config.ServiceConfig{{Name: "url-svc", Addr: ":8081"}},
Log: &config.LogConfig{Level: "debug"},
}
var buf bytes.Buffer
if err := urlCfg.Write(&buf, "yaml"); err != nil {
t.Fatal(err)
}
body := buf.Bytes()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(body)
}))
defer ts.Close()
Init(Args{
CfgFiles: []string{filepath.Join(tmpDir, "local.yml"), ts.URL},
})
got, err := Parse()
if err != nil {
t.Fatalf("Parse(): %v", err)
}
if len(got.Services) != 2 {
t.Fatalf("expected 2 services, got %d", len(got.Services))
}
if got.Log == nil || got.Log.Level != "debug" {
t.Fatal("Log.Level not set from URL config")
}
}
+147 -28
View File
@@ -2,9 +2,17 @@ package parser
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
@@ -35,9 +43,10 @@ func Parse() (*config.Config, error) {
// Args holds the raw CLI flags and positional arguments that Init will merge
// into the final configuration.
type Args struct {
// CfgFile is the path to a YAML or JSON config file, "-" for stdin, or
// an inline JSON string.
CfgFile string
// CfgFiles is the list of YAML or JSON config sources: file paths,
// HTTP(S) URLs, "-" for stdin, or an inline JSON string.
// Multiple sources are merged left-to-right.
CfgFiles []string
// Services is the list of service definitions from -L flags.
Services []string
@@ -65,31 +74,16 @@ type parser struct {
func (p *parser) Parse() (*config.Config, error) {
cfg := &config.Config{}
if cfgFile := strings.TrimSpace(p.args.CfgFile); cfgFile != "" {
if cfgFile == "-" { // stdin
br := bufio.NewReader(os.Stdin)
b, err := br.Peek(1)
if err != nil {
return nil, err
}
if b[0] == '{' {
if err := cfg.Read(br, "json"); err != nil {
return nil, err
}
} else {
if err := cfg.Read(br, "yaml"); err != nil {
return nil, err
}
}
} else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
return nil, err
}
} else {
if err := cfg.ReadFile(cfgFile); err != nil { // file
return nil, err
}
for _, cfgFile := range p.args.CfgFiles {
cfgFile = strings.TrimSpace(cfgFile)
if cfgFile == "" {
continue
}
fcfg, err := readConfig(cfgFile)
if err != nil {
return nil, err
}
cfg = mergeConfig(cfg, fcfg)
}
cmdCfg, err := cmd.BuildConfigFromCmd(p.args.Services, p.args.Nodes)
@@ -98,7 +92,8 @@ func (p *parser) Parse() (*config.Config, error) {
}
cfg = mergeConfig(cfg, cmdCfg)
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil {
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil &&
len(p.args.CfgFiles) == 0 {
if err := cfg.Load(); err != nil {
return nil, err
}
@@ -193,6 +188,126 @@ func (p *parser) Parse() (*config.Config, error) {
return cfg, nil
}
// readConfig reads a single configuration source, which may be "-" (stdin),
// an HTTP(S) URL, an inline JSON string starting with "{", or a file path.
func readConfig(cfgFile string) (*config.Config, error) {
cfg := &config.Config{}
if cfgFile == "-" { // stdin
br := bufio.NewReader(os.Stdin)
b, err := br.Peek(1)
if err != nil {
return nil, err
}
if b[0] == '{' {
if err := cfg.Read(br, "json"); err != nil {
return nil, err
}
} else {
if err := cfg.Read(br, "yaml"); err != nil {
return nil, err
}
}
} else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
return nil, err
}
} else if isHTTPURL(cfgFile) { // URL
if err := readConfigFromURL(cfgFile, cfg); err != nil {
return nil, err
}
} else {
if err := cfg.ReadFile(cfgFile); err != nil { // file
return nil, err
}
}
return cfg, nil
}
// isHTTPURL reports whether s is an HTTP or HTTPS URL.
func isHTTPURL(s string) bool {
u, err := url.Parse(s)
return err == nil && u.Scheme != "" && u.Host != "" &&
(u.Scheme == "http" || u.Scheme == "https")
}
// maxConfigSize is the maximum response body size for remote config files.
const maxConfigSize = 10 << 20 // 10 MiB
// sharedHTTPClient is reused across config URL fetches to benefit from
// connection pooling.
var sharedHTTPClient = &http.Client{
Timeout: 30 * time.Second,
}
// readConfigFromURL fetches a configuration from an HTTP(S) URL and reads it
// into cfg. The format (yaml or json) is detected from the Content-Type header
// or the URL path extension.
func readConfigFromURL(urlStr string, cfg *config.Config) error {
resp, err := sharedHTTPClient.Get(urlStr)
if err != nil {
return fmt.Errorf("fetch config from %s: %w", sanitizeURL(urlStr), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body) // drain body for connection reuse; ignore error since we are already failing
return fmt.Errorf("fetch config from %s: %s", sanitizeURL(urlStr), resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxConfigSize+1))
if err != nil {
return fmt.Errorf("read config from %s: %w", sanitizeURL(urlStr), err)
}
if len(data) > maxConfigSize {
return fmt.Errorf("config from %s exceeds maximum size %d", sanitizeURL(urlStr), maxConfigSize)
}
format := detectFormat(resp.Header.Get("Content-Type"), urlStr)
if err := cfg.Read(bytes.NewReader(data), format); err != nil {
return fmt.Errorf("parse config from %s: %w", sanitizeURL(urlStr), err)
}
return nil
}
// sanitizeURL returns the URL with any embedded userinfo stripped for safe
// use in error messages.
func sanitizeURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
u.User = nil
return u.String()
}
// detectFormat determines the config format from the HTTP Content-Type header
// or the URL path extension. Returns "yaml" or "json".
func detectFormat(contentType, urlStr string) string {
if mediatype, _, err := mime.ParseMediaType(contentType); err == nil {
switch mediatype {
case "application/json":
return "json"
case "application/yaml", "application/x-yaml",
"text/yaml", "text/x-yaml":
return "yaml"
}
}
if u, err := url.Parse(urlStr); err == nil {
switch strings.ToLower(strings.TrimPrefix(path.Ext(u.Path), ".")) {
case "json":
return "json"
case "yaml", "yml":
return "yaml"
}
}
return "yaml"
}
func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
if cfg1 == nil {
return cfg2
@@ -213,7 +328,11 @@ 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...),
P2Ps: append(cfg1.P2Ps, cfg2.P2Ps...),
Caches: append(cfg1.Caches, cfg2.Caches...),
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
RLimiters: append(cfg1.RLimiters, cfg2.RLimiters...),
Loggers: append(cfg1.Loggers, cfg2.Loggers...),
+104
View File
@@ -0,0 +1,104 @@
package quota
import (
"strings"
"time"
"github.com/alecthomas/units"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xquota "github.com/go-gost/x/limiter/quota"
)
// ParseQuotaLimiter is best-effort: malformed fields are logged and treated as unset.
func ParseQuotaLimiter(cfg *config.QuotaConfig) *xquota.Limiter {
if cfg == nil {
return nil
}
log := logger.Default().WithFields(map[string]any{
"kind": "quota",
"quota": cfg.Name,
})
var limit uint64
if s := strings.TrimSpace(cfg.Limit); s != "" && s != "0" {
if v, err := units.ParseBase2Bytes(s); err != nil {
log.Warnf("quota: parse limit %q: %v", s, err)
} else if v > 0 {
limit = uint64(v)
}
}
var dir xquota.Direction
switch strings.ToLower(strings.TrimSpace(cfg.Direction)) {
case "in":
dir = xquota.DirectionIn
case "out":
dir = xquota.DirectionOut
default:
dir = xquota.DirectionTotal
}
var flush time.Duration
if s := strings.TrimSpace(cfg.Flush); s != "" {
if d, err := time.ParseDuration(s); err != nil {
log.Warnf("quota: parse flush %q: %v", s, err)
} else {
flush = d
}
}
return xquota.NewLimiter(cfg.Name, xquota.Options{
Limit: limit,
StartsAt: parseQuotaTime(cfg.StartsAt, log, "startsAt"),
ExpiresAt: parseQuotaTime(cfg.ExpiresAt, log, "expiresAt"),
Direction: dir,
Flush: flush,
Store: parseQuotaStore(cfg.Store, log),
Logger: log,
})
}
func parseQuotaTime(s string, log logger.Logger, field string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
log.Warnf("quota: parse %s %q: %v", field, s, err)
return time.Time{}
}
return t
}
func parseQuotaStore(sc *config.QuotaStoreConfig, log logger.Logger) xquota.Store {
const defaultFile = "gost-quota.json"
if sc == nil || sc.Type == "" || strings.EqualFold(sc.Type, "file") {
path := defaultFile
if sc != nil && strings.TrimSpace(sc.File) != "" {
path = sc.File
}
return xquota.NewFileStore(path)
}
if strings.EqualFold(sc.Type, "redis") {
log.Warnf("quota: redis store not implemented; counter will not be persisted")
var rc config.QuotaRedisConfig
if sc.Redis != nil {
rc = *sc.Redis
}
return xquota.NewRedisStore(xquota.RedisConfig{
Addr: rc.Addr,
Username: rc.Username,
Password: rc.Password,
DB: rc.DB,
Key: rc.Key,
})
}
log.Warnf("quota: unknown store type %q; using file store %q", sc.Type, defaultFile)
return xquota.NewFileStore(defaultFile)
}
+4
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-gost/core/recorder"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
xrecorder "github.com/go-gost/x/recorder"
recorder_plugin "github.com/go-gost/x/recorder/plugin"
"gopkg.in/natefinch/lumberjack.v2"
@@ -33,6 +34,9 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-gost/core/resolver"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
xresolver "github.com/go-gost/x/resolver"
resolver_plugin "github.com/go-gost/x/resolver/plugin"
@@ -24,6 +25,9 @@ func ParseResolver(cfg *config.ResolverConfig) (resolver.Resolver, error) {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+53
View File
@@ -0,0 +1,53 @@
package rewriter
import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/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 {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
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
}
+4
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
xrouter "github.com/go-gost/x/router"
router_plugin "github.com/go-gost/x/router/plugin"
)
@@ -23,6 +24,9 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+5
View File
@@ -4,9 +4,11 @@ import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/sd"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
sd_plugin "github.com/go-gost/x/sd/plugin"
)
@@ -17,6 +19,9 @@ func ParseSD(cfg *config.SDConfig) sd.SD {
return nil
}
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+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
+249 -24
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"io"
"runtime"
"strings"
"time"
@@ -11,6 +12,8 @@ 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/routing"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
@@ -27,17 +30,38 @@ import (
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
hop_parser "github.com/go-gost/x/config/parsing/hop"
logger_parser "github.com/go-gost/x/config/parsing/logger"
node_parser "github.com/go-gost/x/config/parsing/node"
selector_parser "github.com/go-gost/x/config/parsing/selector"
xhop "github.com/go-gost/x/hop"
tls_util "github.com/go-gost/x/internal/util/tls"
xrouting "github.com/go-gost/x/routing"
xs "github.com/go-gost/x/selector"
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xstats "github.com/go-gost/x/observer/stats"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
xservice "github.com/go-gost/x/service"
"github.com/vishvananda/netns"
)
// plaintextListeners are listener types that never terminate TLS on their
// accepted connections, so any certFile/keyFile/caFile configured for them is
// silently ignored. This catches the common footgun where a user writes e.g.
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
// new plaintext listeners are added.
var plaintextListeners = map[string]bool{
"tcp": true, "udp": true,
"ws": true, "mws": true,
"redirect": true, "tproxy": true,
"rtcp": true, "rudp": true,
"unix": true, "runix": true, "serial": true, "stdio": true,
}
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
// It defaults the listener to "tcp" and the handler to "auto", resolves named
// components from the registry (authers, admissions, bypasses, resolvers,
@@ -87,6 +111,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 {
@@ -115,6 +144,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
var observerPeriod time.Duration
var netnsIn, netnsOut string
var dialTimeout time.Duration
var labels map[string]string
var limiterRefreshInterval time.Duration
var limiterCleanupInterval time.Duration
@@ -150,6 +180,14 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
limiterRefreshInterval = mdutil.GetDuration(md, parsing.MDKeyLimiterRefreshInterval)
limiterCleanupInterval = mdutil.GetDuration(md, parsing.MDKeyLimiterCleanupInterval)
limiterScope = mdutil.GetString(md, parsing.MDKeyLimiterScope)
labels = mdutil.GetStringMapString(md, parsing.MDKeyLabels)
}
if len(labels) > 0 {
serviceLogger = serviceLogger.WithFields(map[string]any{
"labels": labels,
})
}
listenerLogger := serviceLogger.WithFields(map[string]any{
@@ -167,13 +205,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if !ignoreChain {
routerOpts = append(routerOpts,
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup)),
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup, log)),
)
}
lnRouter := xchain.NewRouter(routerOpts...)
// The routers may hold a chainGroup running probe goroutines, and the
// forwarder hop may run probe goroutines of its own; make sure they are
// closed if service construction fails after this point.
var hRouter *xchain.Router
var fwdCloser io.Closer
success := false
defer func() {
if !success {
lnRouter.Close()
if hRouter != nil {
hRouter.Close()
}
if fwdCloser != nil {
fwdCloser.Close()
}
}
}()
listenOpts := []listener.Option{
listener.AddrOption(cfg.Addr),
listener.RouterOption(xchain.NewRouter(routerOpts...)),
listener.RouterOption(lnRouter),
listener.AutherOption(auther),
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
listener.TLSConfigOption(tlsConfig),
@@ -237,6 +294,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
for _, qname := range cfg.Quotas {
ln = quota_wrapper.WrapListener(ln, strings.TrimSpace(qname))
}
handlerLogger := serviceLogger.WithFields(map[string]any{
"kind": "handler",
})
@@ -268,11 +329,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),
@@ -281,9 +375,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),
@@ -297,14 +400,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if !ignoreChain {
routerOpts = append(routerOpts,
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup)),
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup, log)),
)
}
var h handler.Handler
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
hRouter = xchain.NewRouter(routerOpts...)
h = rf(
handler.RouterOption(xchain.NewRouter(routerOpts...)),
handler.RouterOption(hRouter),
handler.AutherOption(auther),
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
@@ -313,6 +417,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),
@@ -322,10 +428,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if forwarder, ok := h.(handler.Forwarder); ok {
hop, err := parseForwarder(cfg.Forwarder, log)
hop, closer, err := parseForwarder(cfg.Forwarder, log)
if err != nil {
return nil, err
}
fwdCloser = closer
forwarder.Forward(hop)
}
@@ -338,6 +445,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
closers := []io.Closer{lnRouter, hRouter}
if fwdCloser != nil {
closers = append(closers, fwdCloser)
}
s := xservice.NewService(cfg.Name, ln, h,
xservice.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
xservice.PreUpOption(preUp),
@@ -349,15 +460,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
xservice.ObserverOption(registry.ObserverRegistry().Get(cfg.Observer)),
xservice.ObserverPeriodOption(observerPeriod),
xservice.LoggerOption(serviceLogger),
xservice.LabelsOption(labels),
xservice.ClosersOption(closers...),
)
success = true
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
return s, nil
}
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, error) {
// parseForwarder builds the forwarder hop for a service. The returned
// io.Closer is non-nil only when the hop is owned by the service (inline
// nodes or a hop group) and must be closed on service shutdown; hops from
// the HopRegistry are closed by the registry on unregister.
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, io.Closer, error) {
if cfg == nil {
return nil, nil
return nil, nil, nil
}
// HopGroup takes precedence over Hop / Name / Nodes.
if cfg.HopGroup != nil {
group, err := parseHopGroup(cfg.HopGroup, log)
if err != nil || group == nil {
return nil, nil, err
}
closer, _ := group.(io.Closer)
return group, closer, nil
}
hopName := cfg.Hop
@@ -365,7 +493,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
hopName = cfg.Name
}
if hopName != "" {
return registry.HopRegistry().Get(hopName), nil
return registry.HopRegistry().Get(hopName), nil, nil
}
hc := config.HopConfig{
@@ -407,35 +535,132 @@ 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,
})
}
return hop_parser.ParseHop(&hc, log)
// Inline nodes build a per-service hop, owned (and closed) by the service.
h, err := hop_parser.ParseHop(&hc, log)
if err != nil || h == nil {
return nil, nil, err
}
closer, _ := h.(io.Closer)
return h, closer, nil
}
func chainGroup(name string, group *config.ChainGroupConfig) chain.Chainer {
var chains []chain.Chainer
var sel selector.Selector[chain.Chainer]
if c := registry.ChainRegistry().Get(name); c != nil {
chains = append(chains, c)
func parseHopGroup(cfg *config.ForwardHopGroupConfig, log logger.Logger) (hop.Hop, error) {
if cfg == nil {
return nil, nil
}
if group != nil {
for _, s := range group.Chains {
if c := registry.ChainRegistry().Get(s); c != nil {
chains = append(chains, c)
var entries []*xhop.HopEntry
for _, hc := range cfg.Hops {
if hc == nil {
continue
}
h := registry.HopRegistry().Get(hc.Hop)
if h == nil {
log.Warnf("hop %q not found in hopGroup, skipping", hc.Hop)
continue
}
var m routing.Matcher
if hc.Matcher != nil && hc.Matcher.Rule != "" {
var err error
m, err = xrouting.NewMatcher(hc.Matcher.Rule)
if err != nil {
log.Warnf("hop %q: bad matcher rule %q: %v, skipping", hc.Hop, hc.Matcher.Rule, err)
continue
}
}
sel = selector_parser.ParseChainSelector(group.Selector)
entries = append(entries, xhop.NewHopEntry(h, m, node_parser.ParseProbeConfig(hc.Probe)))
}
if len(chains) == 0 {
if len(entries) == 0 {
return nil, nil
}
var hopSelector selector.Selector[hop.Hop]
if cfg.Selector != nil {
var strategy selector.Strategy[hop.Hop]
switch cfg.Selector.Strategy {
case "round", "rr":
strategy = xs.RoundRobinStrategy[hop.Hop]()
case "random", "rand":
strategy = xs.RandomStrategy[hop.Hop]()
case "fifo", "ha":
strategy = xs.FIFOStrategy[hop.Hop]()
case "hash":
strategy = xs.HashStrategy[hop.Hop]()
default:
strategy = xs.RoundRobinStrategy[hop.Hop]()
}
hopSelector = xs.NewSelector(
strategy,
xs.FailFilter[hop.Hop](cfg.Selector.MaxFails, cfg.Selector.FailTimeout),
xs.BackupFilter[hop.Hop](),
)
}
if hopSelector == nil {
hopSelector = xs.NewSelector(
xs.RoundRobinStrategy[hop.Hop](),
xs.FailFilter[hop.Hop](xs.DefaultMaxFails, xs.DefaultFailTimeout),
xs.BackupFilter[hop.Hop](),
)
}
return xhop.NewHopGroup(
xhop.WithEntriesOption(entries...),
xhop.WithGroupSelectorOption(hopSelector),
xhop.WithGroupLoggerOption(log.WithFields(map[string]any{
"kind": "hopGroup",
"service": "forwarder",
})),
), nil
}
func chainGroup(name string, group *config.ChainGroupConfig, log logger.Logger) chain.Chainer {
var entries []*xchain.ChainEntry
if c := registry.ChainRegistry().Get(name); c != nil {
entries = append(entries, xchain.NewChainEntry(c, nil, nil))
}
if group != nil {
for _, ge := range group.Chains {
c := registry.ChainRegistry().Get(ge.Chain)
if c == nil {
log.Warnf("chain %q not found in chainGroup, skipping", ge.Chain)
continue
}
var m routing.Matcher
if ge.Matcher != nil && ge.Matcher.Rule != "" {
var err error
m, err = xrouting.NewMatcher(ge.Matcher.Rule)
if err != nil {
log.Warnf("chain %q: bad matcher rule %q: %v, skipping", ge.Chain, ge.Matcher.Rule, err)
continue
}
}
entries = append(entries, xchain.NewChainEntry(c, m, node_parser.ParseProbeConfig(ge.Probe)))
}
}
if len(entries) == 0 {
return nil
}
var sel selector.Selector[chain.Chainer]
if group != nil {
sel = selector_parser.ParseChainSelector(group.Selector)
}
if sel == nil {
sel = selector_parser.DefaultChainSelector()
}
return xchain.NewChainGroup(chains...).
WithSelector(sel)
return xchain.NewChainGroup().
WithGroupEntries(entries...).
WithSelector(sel).
WithGroupLogger(log)
}
+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")
}
}
+328
View File
@@ -0,0 +1,328 @@
package masque
import (
"context"
"errors"
"net"
"os"
"sync"
"time"
"github.com/go-gost/core/logger"
)
const udpAssociationBufferSize = 64 * 1024
var errMissingDestination = errors.New("masque: destination address required")
type udpAssociationDialFunc func(ctx context.Context, addr net.Addr) (net.PacketConn, error)
type udpAssociationResult struct {
data []byte
addr net.Addr
}
type udpAssociationTunnel struct {
conn net.PacketConn
}
type udpAssociationConn struct {
ctx context.Context
cancel context.CancelFunc
localAddr net.Addr
dial udpAssociationDialFunc
closeIdle func() error
log logger.Logger
timeout time.Duration
closed chan struct{}
closeOnce sync.Once
closeErr error
results chan udpAssociationResult
mu sync.Mutex
tunnels map[string]*udpAssociationTunnel
readDeadline time.Time
readDeadlineChanged chan struct{}
}
func newUDPAssociationConn(
ctx context.Context,
localAddr net.Addr,
timeout time.Duration,
dial udpAssociationDialFunc,
closeIdle func() error,
log logger.Logger,
) *udpAssociationConn {
ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
c := &udpAssociationConn{
ctx: ctx,
cancel: cancel,
localAddr: localAddr,
dial: dial,
closeIdle: closeIdle,
log: log,
timeout: timeout,
closed: make(chan struct{}),
results: make(chan udpAssociationResult, 32),
tunnels: make(map[string]*udpAssociationTunnel),
readDeadlineChanged: make(chan struct{}),
}
return c
}
func (c *udpAssociationConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
for {
select {
case <-c.closed:
return 0, nil, net.ErrClosed
default:
}
c.mu.Lock()
deadline := c.readDeadline
deadlineChanged := c.readDeadlineChanged
c.mu.Unlock()
var (
timer *time.Timer
timeout <-chan time.Time
)
if !deadline.IsZero() {
remaining := time.Until(deadline)
if remaining <= 0 {
return 0, nil, os.ErrDeadlineExceeded
}
timer = time.NewTimer(remaining)
timeout = timer.C
}
select {
case result := <-c.results:
if timer != nil {
timer.Stop()
}
return copy(b, result.data), result.addr, nil
case <-deadlineChanged:
if timer != nil {
timer.Stop()
}
continue
case <-timeout:
return 0, nil, os.ErrDeadlineExceeded
case <-c.closed:
if timer != nil {
timer.Stop()
}
return 0, nil, net.ErrClosed
}
}
}
func (c *udpAssociationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
if addr == nil {
return 0, errMissingDestination
}
tunnel, err := c.tunnel(addr)
if err != nil {
if c.isClosed() {
return 0, net.ErrClosed
}
c.logError(addr, err)
return len(b), nil
}
n, err := tunnel.conn.WriteTo(b, addr)
if err == nil {
return n, nil
}
if c.isClosed() {
return 0, net.ErrClosed
}
if c.removeTunnel(addr.String(), tunnel) {
c.logError(addr, err)
}
return len(b), nil
}
func (c *udpAssociationConn) tunnel(addr net.Addr) (*udpAssociationTunnel, error) {
key := addr.String()
c.mu.Lock()
select {
case <-c.closed:
c.mu.Unlock()
return nil, net.ErrClosed
default:
}
if tunnel := c.tunnels[key]; tunnel != nil {
c.mu.Unlock()
return tunnel, nil
}
c.mu.Unlock()
ctx := c.ctx
if c.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, c.timeout)
defer cancel()
}
conn, err := c.dial(ctx, addr)
if err != nil {
return nil, err
}
if conn == nil {
return nil, errors.New("masque: nil UDP tunnel")
}
tunnel := &udpAssociationTunnel{conn: conn}
c.mu.Lock()
select {
case <-c.closed:
c.mu.Unlock()
conn.Close()
return nil, net.ErrClosed
default:
}
if current := c.tunnels[key]; current != nil {
c.mu.Unlock()
conn.Close()
return current, nil
}
c.tunnels[key] = tunnel
c.mu.Unlock()
go c.readTunnel(key, tunnel)
return tunnel, nil
}
func (c *udpAssociationConn) readTunnel(key string, tunnel *udpAssociationTunnel) {
buf := make([]byte, udpAssociationBufferSize)
for {
n, addr, err := tunnel.conn.ReadFrom(buf)
if err != nil {
if c.isClosed() {
return
}
if c.removeTunnel(key, tunnel) {
c.logError(key, err)
}
return
}
data := append([]byte(nil), buf[:n]...)
select {
case c.results <- udpAssociationResult{data: data, addr: addr}:
case <-c.closed:
return
}
}
}
func (c *udpAssociationConn) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func (c *udpAssociationConn) logError(addr any, err error) {
if c.log != nil {
c.log.Warnf("masque: UDP tunnel %v: %v", addr, err)
}
}
func (c *udpAssociationConn) removeTunnel(key string, tunnel *udpAssociationTunnel) bool {
c.mu.Lock()
removed := c.tunnels[key] == tunnel
if c.tunnels[key] == tunnel {
delete(c.tunnels, key)
}
c.mu.Unlock()
tunnel.conn.Close()
return removed
}
func (c *udpAssociationConn) Read(b []byte) (int, error) {
n, _, err := c.ReadFrom(b)
return n, err
}
func (c *udpAssociationConn) Write(b []byte) (int, error) {
return 0, errMissingDestination
}
func (c *udpAssociationConn) Close() error {
c.closeOnce.Do(func() {
close(c.closed)
c.cancel()
c.mu.Lock()
close(c.readDeadlineChanged)
tunnels := make([]*udpAssociationTunnel, 0, len(c.tunnels))
for _, tunnel := range c.tunnels {
tunnels = append(tunnels, tunnel)
}
clear(c.tunnels)
c.mu.Unlock()
var errs []error
for _, tunnel := range tunnels {
if err := tunnel.conn.Close(); err != nil {
errs = append(errs, err)
}
}
if c.closeIdle != nil {
if err := c.closeIdle(); err != nil {
errs = append(errs, err)
}
}
c.closeErr = errors.Join(errs...)
})
return c.closeErr
}
func (c *udpAssociationConn) LocalAddr() net.Addr {
return c.localAddr
}
func (c *udpAssociationConn) RemoteAddr() net.Addr {
return &net.UDPAddr{}
}
func (c *udpAssociationConn) SetDeadline(t time.Time) error {
if err := c.SetReadDeadline(t); err != nil {
return err
}
return c.SetWriteDeadline(t)
}
func (c *udpAssociationConn) SetReadDeadline(t time.Time) error {
c.mu.Lock()
defer c.mu.Unlock()
select {
case <-c.closed:
return net.ErrClosed
default:
}
c.readDeadline = t
close(c.readDeadlineChanged)
c.readDeadlineChanged = make(chan struct{})
return nil
}
func (c *udpAssociationConn) SetWriteDeadline(t time.Time) error {
select {
case <-c.closed:
return net.ErrClosed
default:
}
return nil
}
var (
_ net.Conn = (*udpAssociationConn)(nil)
_ net.PacketConn = (*udpAssociationConn)(nil)
)
+321
View File
@@ -0,0 +1,321 @@
package masque
import (
"context"
"errors"
"net"
"sync"
"testing"
"time"
)
func TestUDPAssociationConnRoutesByDestination(t *testing.T) {
type dialCall struct {
addr net.Addr
conn *fakePacketConn
}
var (
mu sync.Mutex
calls []dialCall
)
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
conn := newFakePacketConn()
mu.Lock()
calls = append(calls, dialCall{addr: addr, conn: conn})
mu.Unlock()
return conn, nil
}
ctx, cancel := context.WithCancel(context.Background())
conn := newUDPAssociationConn(ctx, &net.UDPAddr{}, 0, dial, nil, nil)
cancel() // The route dial context ends before the UDP association is used.
t.Cleanup(func() { conn.Close() })
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 53}
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
t.Fatalf("first WriteTo failed: %v", err)
}
if _, err := conn.WriteTo([]byte("second"), addr1); err != nil {
t.Fatalf("second WriteTo failed: %v", err)
}
if _, err := conn.WriteTo([]byte("third"), addr2); err != nil {
t.Fatalf("third WriteTo failed: %v", err)
}
mu.Lock()
gotCalls := append([]dialCall(nil), calls...)
mu.Unlock()
if len(gotCalls) != 2 {
t.Fatalf("expected one tunnel per destination, got %d", len(gotCalls))
}
if gotCalls[0].addr.String() != addr1.String() || gotCalls[1].addr.String() != addr2.String() {
t.Fatalf("unexpected tunnel destinations: %s, %s", gotCalls[0].addr, gotCalls[1].addr)
}
if got := gotCalls[0].conn.writtenData(); len(got) != 2 || string(got[0]) != "first" || string(got[1]) != "second" {
t.Fatalf("unexpected writes for first destination: %q", got)
}
if got := gotCalls[1].conn.writtenData(); len(got) != 1 || string(got[0]) != "third" {
t.Fatalf("unexpected writes for second destination: %q", got)
}
replyAddr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}
gotCalls[1].conn.queueRead([]byte("reply"), replyAddr)
buf := make([]byte, 32)
n, addr, err := conn.ReadFrom(buf)
if err != nil {
t.Fatalf("ReadFrom failed: %v", err)
}
if string(buf[:n]) != "reply" {
t.Fatalf("unexpected reply: %q", buf[:n])
}
if addr.String() != replyAddr.String() {
t.Fatalf("expected reply from %s, got %s", replyAddr, addr)
}
}
func TestUDPAssociationConnClose(t *testing.T) {
upstream := newFakePacketConn()
idleClosed := 0
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
0,
func(context.Context, net.Addr) (net.PacketConn, error) { return upstream, nil },
func() error {
idleClosed++
return nil
},
nil,
)
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}); err != nil {
t.Fatalf("WriteTo failed: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("second Close failed: %v", err)
}
if idleClosed != 1 {
t.Fatalf("expected idle stream to be closed once, got %d", idleClosed)
}
if !upstream.isClosed() {
t.Fatal("expected destination tunnel to be closed")
}
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{}); !errors.Is(err, net.ErrClosed) {
t.Fatalf("expected net.ErrClosed after Close, got %v", err)
}
}
func TestUDPAssociationConnTunnelFailureIsIsolated(t *testing.T) {
var (
mu sync.Mutex
calls []*fakePacketConn
)
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
0,
func(context.Context, net.Addr) (net.PacketConn, error) {
pc := newFakePacketConn()
mu.Lock()
calls = append(calls, pc)
mu.Unlock()
return pc, nil
},
nil,
nil,
)
t.Cleanup(func() { conn.Close() })
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443}
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 443}
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
t.Fatalf("first WriteTo failed: %v", err)
}
if _, err := conn.WriteTo([]byte("second"), addr2); err != nil {
t.Fatalf("second WriteTo failed: %v", err)
}
mu.Lock()
first, second := calls[0], calls[1]
mu.Unlock()
first.queueReadError(errors.New("stream reset"))
select {
case <-first.closed:
case <-time.After(time.Second):
t.Fatal("failed tunnel was not removed")
}
second.queueRead([]byte("reply"), addr2)
buf := make([]byte, 32)
n, addr, err := conn.ReadFrom(buf)
if err != nil {
t.Fatalf("healthy tunnel failed after peer reset: %v", err)
}
if string(buf[:n]) != "reply" || addr.String() != addr2.String() {
t.Fatalf("unexpected healthy tunnel reply %q from %v", buf[:n], addr)
}
if _, err := conn.WriteTo([]byte("retry"), addr1); err != nil {
t.Fatalf("retry WriteTo failed: %v", err)
}
mu.Lock()
callCount := len(calls)
mu.Unlock()
if callCount != 3 {
t.Fatalf("expected failed destination to redial, got %d dials", callCount)
}
}
func TestUDPAssociationConnDialTimeout(t *testing.T) {
dialErr := make(chan error, 1)
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
20*time.Millisecond,
func(ctx context.Context, _ net.Addr) (net.PacketConn, error) {
<-ctx.Done()
dialErr <- ctx.Err()
return nil, ctx.Err()
},
nil,
nil,
)
t.Cleanup(func() { conn.Close() })
payload := []byte("request")
n, err := conn.WriteTo(payload, &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
if err != nil || n != len(payload) {
t.Fatalf("timed out datagram was not dropped cleanly: n=%d err=%v", n, err)
}
if err := <-dialErr; !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected dial deadline, got %v", err)
}
}
func TestUDPAssociationConnDialDoesNotHoldAssociationLock(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
0,
func(context.Context, net.Addr) (net.PacketConn, error) {
close(started)
<-release
return newFakePacketConn(), nil
},
nil,
nil,
)
t.Cleanup(func() { conn.Close() })
writeDone := make(chan struct{})
go func() {
conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
close(writeDone)
}()
<-started
deadlineDone := make(chan error, 1)
go func() {
deadlineDone <- conn.SetReadDeadline(time.Now().Add(time.Second))
}()
select {
case err := <-deadlineDone:
if err != nil {
t.Fatalf("SetReadDeadline failed: %v", err)
}
case <-time.After(time.Second):
t.Fatal("dial held the association lock")
}
close(release)
<-writeDone
}
type fakePacket struct {
data []byte
addr net.Addr
err error
}
type fakePacketConn struct {
reads chan fakePacket
closed chan struct{}
closeOnce sync.Once
mu sync.Mutex
writes [][]byte
}
func newFakePacketConn() *fakePacketConn {
return &fakePacketConn{
reads: make(chan fakePacket, 1),
closed: make(chan struct{}),
}
}
func (c *fakePacketConn) queueRead(data []byte, addr net.Addr) {
c.reads <- fakePacket{data: append([]byte(nil), data...), addr: addr}
}
func (c *fakePacketConn) queueReadError(err error) {
c.reads <- fakePacket{err: err}
}
func (c *fakePacketConn) writtenData() [][]byte {
c.mu.Lock()
defer c.mu.Unlock()
result := make([][]byte, len(c.writes))
for i, data := range c.writes {
result[i] = append([]byte(nil), data...)
}
return result
}
func (c *fakePacketConn) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func (c *fakePacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
select {
case packet := <-c.reads:
if packet.err != nil {
return 0, nil, packet.err
}
return copy(b, packet.data), packet.addr, nil
case <-c.closed:
return 0, nil, net.ErrClosed
}
}
func (c *fakePacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
select {
case <-c.closed:
return 0, net.ErrClosed
default:
}
c.mu.Lock()
c.writes = append(c.writes, append([]byte(nil), b...))
c.mu.Unlock()
return len(b), nil
}
func (c *fakePacketConn) Close() error {
c.closeOnce.Do(func() { close(c.closed) })
return nil
}
func (c *fakePacketConn) LocalAddr() net.Addr { return &net.UDPAddr{} }
func (c *fakePacketConn) SetDeadline(time.Time) error { return nil }
func (c *fakePacketConn) SetReadDeadline(time.Time) error { return nil }
func (c *fakePacketConn) SetWriteDeadline(time.Time) error { return nil }
+82 -15
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/go-gost/core/connector"
@@ -19,6 +20,7 @@ import (
masque_dialer "github.com/go-gost/x/dialer/http3/masque"
masque_util "github.com/go-gost/x/internal/util/masque"
"github.com/go-gost/x/registry"
"github.com/quic-go/quic-go/http3"
)
func init() {
@@ -73,6 +75,16 @@ func (c *masqueConnector) Connect(ctx context.Context, conn net.Conn, network, a
return nil, fmt.Errorf("%w: %s", ErrUnsupportedNetwork, network)
}
func newTCPConnectRequest(address string) *http.Request {
return &http.Request{
Method: http.MethodConnect,
URL: &url.URL{},
Host: address,
Header: http.Header{},
ProtoMajor: 3,
}
}
func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address string, log logger.Logger) (net.Conn, error) {
log.Debugf("connect-tcp %s", address)
@@ -103,13 +115,7 @@ func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address
// Create standard HTTP/3 CONNECT request (RFC 9114)
// No :protocol pseudo-header for standard CONNECT
req := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{},
Host: address, // Target address goes in :authority
Header: http.Header{},
Proto: "HTTP/3.0",
}
req := newTCPConnectRequest(address)
// Add proxy authentication if configured
if c.options.Auth != nil {
@@ -172,9 +178,69 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
return nil, ErrInvalidConnection
}
// Get pre-opened stream from dialer (stream opening happens there for dead connection detection)
reqStream := masqueConn.GetRequestStream()
proxyHost := masqueConn.GetHost()
if address == "" {
return c.connectUDPAssociation(ctx, masqueConn, log), nil
}
return c.connectUDPTarget(
ctx,
conn.LocalAddr(),
masqueConn.GetHost(),
masqueConn.GetRequestStream(),
address,
nil,
log,
)
}
func (c *masqueConnector) connectUDPAssociation(ctx context.Context, conn *masque_dialer.MasqueConn, log logger.Logger) net.Conn {
var (
mu sync.Mutex
firstStream = conn.GetRequestStream()
)
nextStream := func(ctx context.Context) (*http3.RequestStream, error) {
mu.Lock()
defer mu.Unlock()
if firstStream != nil {
stream := firstStream
firstStream = nil
return stream, nil
}
return conn.OpenRequestStream(ctx)
}
closeIdle := func() error {
mu.Lock()
defer mu.Unlock()
if firstStream == nil {
return nil
}
err := firstStream.Close()
firstStream = nil
return err
}
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
stream, err := nextStream(ctx)
if err != nil {
return nil, err
}
return c.connectUDPTarget(ctx, conn.LocalAddr(), conn.GetHost(), stream, addr.String(), addr, log)
}
return newUDPAssociationConn(ctx, conn.LocalAddr(), c.md.connectTimeout, dial, closeIdle, log)
}
func (c *masqueConnector) connectUDPTarget(
ctx context.Context,
localAddr net.Addr,
proxyHost string,
reqStream *http3.RequestStream,
address string,
remoteAddr net.Addr,
log logger.Logger,
) (*masque_util.DatagramConn, error) {
// Apply connect timeout to the actual stream
if c.md.connectTimeout > 0 {
@@ -259,14 +325,15 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
// Get the underlying HTTP/3 stream for datagrams
stream := reqStream
// Resolve target address
raddr, err := net.ResolveUDPAddr("udp", address)
if err != nil {
return nil, err
if remoteAddr == nil {
remoteAddr, err = net.ResolveUDPAddr("udp", address)
if err != nil {
return nil, err
}
}
// Create datagram connection wrapping the request stream
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, conn.LocalAddr(), raddr)
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, localAddr, remoteAddr)
success = true // Prevent defer from closing stream - datagramConn now owns it
return datagramConn, nil
+24
View File
@@ -0,0 +1,24 @@
package masque
import (
"net/http"
"testing"
)
func TestNewTCPConnectRequestUsesStandardConnect(t *testing.T) {
req := newTCPConnectRequest("example.com:443")
if req.Method != http.MethodConnect {
t.Fatalf("expected CONNECT method, got %s", req.Method)
}
if req.Host != "example.com:443" {
t.Fatalf("expected target authority, got %s", req.Host)
}
if req.Proto != "" {
// quic-go treats any non-empty Proto as an extended CONNECT :protocol.
t.Fatalf("standard CONNECT must not set :protocol, got %q", req.Proto)
}
if req.ProtoMajor != 3 {
t.Fatalf("expected HTTP/3 request, got HTTP/%d", req.ProtoMajor)
}
}
+17 -4
View File
@@ -28,7 +28,7 @@ func (c *relayConnector) Bind(ctx context.Context, conn net.Conn, network, addre
}
switch network {
case "tcp", "tcp4", "tcp6":
case "tcp", "tcp4", "tcp6", "unix":
return c.bindTCP(ctx, conn, network, address, log)
case "udp", "udp4", "udp6":
return c.bindUDP(ctx, conn, network, address, &options, log)
@@ -52,6 +52,7 @@ func (c *relayConnector) bindTCP(ctx context.Context, conn net.Conn, network, ad
}
return &bindListener{
network: network,
addr: laddr,
session: session,
logger: log,
@@ -97,13 +98,19 @@ func (c *relayConnector) bind(conn net.Conn, cmd relay.CmdType, network, address
nid := relay.NetworkTCP
if network == "udp" || network == "udp4" || network == "udp6" {
nid = relay.NetworkUDP
} else if network == "unix" {
nid = relay.NetworkUnix
}
req.Features = append(req.Features, &relay.NetworkFeature{
Network: nid,
})
fa := &relay.AddrFeature{}
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
address = net.JoinHostPort("0.0.0.0", address)
if network != "unix" {
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)
@@ -125,7 +132,11 @@ func (c *relayConnector) bind(conn net.Conn, cmd relay.CmdType, network, address
for _, f := range resp.Features {
if f.Type() == relay.FeatureAddr {
if fa, ok := f.(*relay.AddrFeature); ok {
addr = net.JoinHostPort(fa.Host, strconv.Itoa(int(fa.Port)))
if network == "unix" {
addr = fa.Host
} else {
addr = net.JoinHostPort(fa.Host, strconv.Itoa(int(fa.Port)))
}
}
}
}
@@ -137,6 +148,8 @@ func (c *relayConnector) bind(conn net.Conn, cmd relay.CmdType, network, address
baddr, err = net.ResolveTCPAddr(network, addr)
case "udp", "udp4", "udp6":
baddr, err = net.ResolveUDPAddr(network, addr)
case "unix":
baddr, err = net.ResolveUnixAddr(network, addr)
default:
err = fmt.Errorf("unknown network %s", network)
}
+8 -2
View File
@@ -15,7 +15,7 @@ import (
type bindListener struct {
network string
addr net.Addr
session *mux.Session
session mux.Session
logger logger.Logger
}
@@ -62,7 +62,13 @@ func (p *bindListener) getPeerConn(conn net.Conn) (net.Conn, error) {
}
}
raddr, err := net.ResolveTCPAddr("tcp", address)
var raddr net.Addr
var err error
if p.network == "unix" {
raddr, err = net.ResolveUnixAddr("unix", address)
} else {
raddr, err = net.ResolveTCPAddr("tcp", address)
}
if err != nil {
return nil, err
}
+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)
}
}
+15 -6
View File
@@ -7,7 +7,8 @@ import (
"net"
"time"
"github.com/go-gost/core/common/bufpool"
"strings"
"github.com/go-gost/core/connector"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/go-shadowsocks2/core"
@@ -15,6 +16,7 @@ import (
"github.com/go-gost/go-shadowsocks2/utils"
"github.com/go-gost/gosocks5"
ctxvalue "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/util/ss/none"
"github.com/go-gost/x/registry"
)
@@ -51,6 +53,11 @@ func (c *ssConnector) Init(md md.Metadata) (err error) {
method := c.options.Auth.Username()
password, _ := c.options.Auth.Password()
if strings.EqualFold(method, "none") || strings.EqualFold(method, "dummy") {
c.client = core.NewTCPClient(core.ClientConfig{Cipher: none.Cipher})
return
}
clientConfig, err := utils.NewClientConfig(method, password)
if err != nil {
return err
@@ -89,10 +96,13 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
log.Error(err)
return nil, err
}
rawaddr := bufpool.Get(512)
defer bufpool.Put(rawaddr)
// The wrapped conn stores this target and only writes it on first I/O,
// so it must be owned by this connection, not a shared pooled buffer
// (see go-gost/gost#896).
const maxAddrLen = 1 + 1 + 255 + 2 // SOCKS5 addr: atyp + len + host + port
n, err := addr.Encode(rawaddr)
target := make([]byte, maxAddrLen)
n, err := addr.Encode(target)
if err != nil {
log.Error("encoding addr: ", err)
return nil, err
@@ -103,8 +113,7 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
defer conn.SetDeadline(time.Time{})
}
target := socks.Addr(rawaddr[:n])
conn, err = c.client.WrapConn(conn, target)
conn, err = c.client.WrapConn(conn, socks.Addr(target[:n]))
if err != nil {
return nil, err
}
+81
View File
@@ -0,0 +1,81 @@
package ss
import (
"bytes"
"context"
"io"
"net"
"net/url"
"testing"
"time"
"github.com/go-gost/core/connector"
"github.com/go-gost/go-shadowsocks2/socks"
"github.com/go-gost/x/internal/util/ss/none"
xlogger "github.com/go-gost/x/logger"
mdx "github.com/go-gost/x/metadata"
)
// captureConn records everything written to it, so a test can inspect the
// bytes a connector actually puts on the wire.
type captureConn struct {
buf bytes.Buffer
}
func (c *captureConn) Read(b []byte) (int, error) { return 0, io.EOF }
func (c *captureConn) Write(b []byte) (int, error) { return c.buf.Write(b) }
func (c *captureConn) Close() error { return nil }
func (c *captureConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *captureConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *captureConn) SetDeadline(time.Time) error { return nil }
func (c *captureConn) SetReadDeadline(time.Time) error { return nil }
func (c *captureConn) SetWriteDeadline(time.Time) error { return nil }
// TestConnectTargetNotOverwrittenByPool is a regression test for
// https://github.com/go-gost/gost/issues/896: the connector previously passed
// a slice aliasing the bufpool buffer to WrapConn, and the buffer was returned
// to the pool before the wrapped conn lazily wrote the target on first I/O. A
// second Connect could then overwrite the bytes and the first conn would send
// the second conn's target. The none cipher keeps the wire plaintext so the
// written target is directly assertable.
func TestConnectTargetNotOverwrittenByPool(t *testing.T) {
c := NewConnector(
connector.AuthOption(url.UserPassword("none", "x")),
connector.LoggerOption(xlogger.NewLogger()),
)
if err := c.Init(mdx.NewMetadata(nil)); err != nil {
t.Fatalf("Init: %v", err)
}
ctx := context.Background()
cap1 := &captureConn{}
conn1, err := c.Connect(ctx, cap1, "tcp", "1.2.3.4:80")
if err != nil {
t.Fatalf("first Connect: %v", err)
}
// Second Connect reuses the same 512-byte bufpool buffer that conn1's
// target used to alias (single pool tier, same goroutine).
if _, err := c.Connect(ctx, &captureConn{}, "tcp", "5.6.7.8:443"); err != nil {
t.Fatalf("second Connect: %v", err)
}
if _, err := conn1.Write([]byte("X")); err != nil {
t.Fatalf("first Write: %v", err)
}
// initWriter frames the target as [salt][2-byte length][addr].
data := cap1.buf.Bytes()
payload := data[none.Cipher.SaltSize():]
if len(payload) < 2 {
t.Fatalf("captured payload too short: %d bytes: %x", len(payload), data)
}
n := int(payload[0])<<8 | int(payload[1])
if len(payload) < 2+n {
t.Fatalf("captured payload too short: len=%d, need %d: %x", len(payload), 2+n, data)
}
addr := socks.SplitAddr(payload[2 : 2+n])
if got, want := addr.String(), "1.2.3.4:80"; got != want {
t.Fatalf("first conn wrote target %q, want %q (captured %x)", got, want, data)
}
}
+9
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
"strings"
"time"
"github.com/go-gost/core/connector"
@@ -16,6 +17,7 @@ import (
ctxvalue "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/util/relay"
"github.com/go-gost/x/internal/util/ss"
ssnone "github.com/go-gost/x/internal/util/ss/none"
"github.com/go-gost/x/registry"
)
@@ -52,6 +54,13 @@ func (c *ssuConnector) Init(md md.Metadata) (err error) {
method := c.options.Auth.Username()
password, _ := c.options.Auth.Password()
if strings.EqualFold(method, "none") || strings.EqualFold(method, "dummy") {
c.clientCfg = core.ClientConfig{Cipher: ssnone.Cipher, UDPTimeout: time.Minute}
c.tcpClient = core.NewTCPClient(core.ClientConfig{Cipher: ssnone.Cipher})
return
}
clientConfig, err := utils.NewClientConfig(method, password)
if err != nil {
return err
+4 -2
View File
@@ -74,8 +74,10 @@ func (c *sshdConnector) Bind(ctx context.Context, conn net.Conn, network, addres
return nil, errors.New("ssh: invalid connection")
}
if host, port, _ := net.SplitHostPort(address); host == "" {
address = net.JoinHostPort("0.0.0.0", port)
if network != "unix" {
if host, port, err := net.SplitHostPort(address); err == nil && host == "" {
address = net.JoinHostPort("0.0.0.0", port)
}
}
return cc.Client().Listen(network, address)
+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
}
+16
View File
@@ -1,6 +1,7 @@
package tunnel
import (
"context"
"encoding/binary"
"errors"
"fmt"
@@ -11,6 +12,7 @@ import (
"github.com/go-gost/core/common/bufpool"
mdata "github.com/go-gost/core/metadata"
"github.com/go-gost/relay"
ictx "github.com/go-gost/x/internal/ctx"
xrelay "github.com/go-gost/x/internal/util/relay"
)
@@ -94,6 +96,14 @@ func (c *bindConn) Metadata() mdata.Metadata {
return c.md
}
// Context implements xctx.Context, exposing the connection metadata (e.g. the
// tunneled hostname) to the handler's context. The service accept loop derives
// the handler context from a conn that implements this interface, which lets
// reverse-forward handlers (rtcp) route on the hostname carried by the tunnel.
func (c *bindConn) Context() context.Context {
return ictx.ContextWithMetadata(context.Background(), c.md)
}
type bindUDPConn struct {
net.Conn
localAddr net.Addr
@@ -162,6 +172,12 @@ func (c *bindUDPConn) Metadata() mdata.Metadata {
return c.md
}
// Context implements xctx.Context, exposing the connection metadata (e.g. the
// tunneled hostname) to the handler's context. See bindConn.Context.
func (c *bindUDPConn) Context() context.Context {
return ictx.ContextWithMetadata(context.Background(), c.md)
}
type bindAddr struct {
network string
addr string
+45
View File
@@ -0,0 +1,45 @@
package tunnel
import (
"net"
"testing"
mdata "github.com/go-gost/core/metadata"
ictx "github.com/go-gost/x/internal/ctx"
xmd "github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
)
// TestBindConnContext verifies that a bindConn exposes its connection metadata
// (the tunneled hostname) via the xctx.Context interface, so the service accept
// loop forwards it into the handler context for filter.host routing (gost#898).
func TestBindConnContext(t *testing.T) {
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
cn := &bindConn{
Conn: &net.TCPConn{},
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
md: md,
}
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
if got != "example.local:80" {
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
}
}
// TestBindUDPConnContext is the same check for the UDP reverse-tunnel conn.
func TestBindUDPConnContext(t *testing.T) {
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
cn := &bindUDPConn{
Conn: &net.TCPConn{},
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
md: md,
}
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
if got != "example.local:80" {
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
}
}
+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
+50
View File
@@ -100,3 +100,53 @@ func ClientIDFromContext(ctx context.Context) ClientID {
v, _ := ctx.Value(clientIDKey{}).(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{}
// ContextWithLabels returns a new context carrying the given service labels.
func ContextWithLabels(ctx context.Context, labels map[string]string) context.Context {
return context.WithValue(ctx, labelsKey{}, labels)
}
// LabelsFromContext returns the service labels stored in the context, or nil.
func LabelsFromContext(ctx context.Context) map[string]string {
v, _ := ctx.Value(labelsKey{}).(map[string]string)
return v
}
+94
View File
@@ -3,6 +3,7 @@ package ctx
import (
"context"
"net"
"reflect"
"testing"
)
@@ -149,6 +150,72 @@ 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)
got := LabelsFromContext(ctx)
if !reflect.DeepEqual(got, labels) {
t.Errorf("LabelsFromContext() = %v, want %v", got, labels)
}
}
func TestLabelsFromContext_Empty(t *testing.T) {
if got := LabelsFromContext(context.Background()); got != nil {
t.Errorf("LabelsFromContext(empty) = %v, want nil", got)
}
}
func TestLabelsFromContext_WrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), labelsKey{}, "not-a-map")
if got := LabelsFromContext(ctx); got != nil {
t.Errorf("LabelsFromContext(wrong type) = %v, want nil", got)
}
}
func TestMultipleValuesInContext(t *testing.T) {
ctx := context.Background()
ctx = ContextWithSrcAddr(ctx, &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 8080})
@@ -194,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
+9
View File
@@ -102,6 +102,15 @@ func (c *MasqueConn) GetRequestStream() *http3.RequestStream {
return c.reqStream
}
// OpenRequestStream opens another request stream on the shared HTTP/3 connection.
// CONNECT-UDP associations use one stream per destination.
func (c *MasqueConn) OpenRequestStream(ctx context.Context) (*http3.RequestStream, error) {
if c.clientConn == nil {
return nil, errors.New("masque: connection is closed")
}
return c.clientConn.OpenRequestStream(ctx)
}
// GetHost returns the proxy host.
func (c *MasqueConn) GetHost() string {
return c.host
+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
+14 -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
}
@@ -23,6 +26,15 @@ func (session *quicSession) GetConn() (*quicConn, error) {
}, nil
}
func (session *quicSession) IsClosed() bool {
select {
case <-session.session.Context().Done():
return true
default:
return false
}
}
func (session *quicSession) Close() error {
return session.session.CloseWithError(quic.ApplicationErrorCode(0), "closed")
}
+17 -4
View File
@@ -75,9 +75,12 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Lock()
defer d.sessionMutex.Unlock()
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
ok = false
}
if !ok {
options := &dialer.DialOptions{}
for _, opt := range opts {
@@ -91,6 +94,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
pc, err = icmp_pkg.ListenPacket("ip4:icmp", "")
}
if err != nil {
d.sessionMutex.Unlock()
return
}
@@ -105,16 +109,22 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
if err != nil {
d.logger.Error(err)
pc.Close()
d.sessionMutex.Unlock()
return nil, err
}
d.sessions[addr] = session
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
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
}
@@ -133,7 +143,10 @@ func (d *icmpDialer) initSession(ctx context.Context, addr net.Addr, conn net.Pa
}
tlsCfg := d.options.TLSConfig
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
tlsCfg = tlsCfg.Clone()
if len(tlsCfg.NextProtos) == 0 {
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
}
session, err := quic.DialEarly(ctx, conn, addr, tlsCfg, quicConfig)
if err != nil {
+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()
}
+5 -1
View File
@@ -61,6 +61,10 @@ func (d *mtcpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
session.Close()
if session.conn != nil {
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
}
delete(d.sessions, addr) // session is dead
ok = false
}
@@ -115,7 +119,7 @@ func (d *mtcpDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
session, ok := d.sessions[opts.Addr]
if session != nil && session.conn != conn {
conn.Close()
return nil, errors.New("mtls: unrecognized connection")
return nil, errors.New("mtcp: unrecognized connection")
}
if !ok || session.session == nil {
+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()
}
+4
View File
@@ -62,6 +62,10 @@ func (d *mtlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
session.Close()
if session.conn != nil {
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
}
delete(d.sessions, addr) // session is dead
ok = false
}
+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()
}
+4
View File
@@ -76,6 +76,10 @@ func (d *mwsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
session.Close()
if session.conn != nil {
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
}
delete(d.sessions, addr) // session is dead
ok = false
}
+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
+14 -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
}
@@ -23,6 +26,15 @@ func (session *quicSession) GetConn() (*quicConn, error) {
}, nil
}
func (session *quicSession) IsClosed() bool {
select {
case <-session.session.Context().Done():
return true
default:
return false
}
}
func (session *quicSession) Close() error {
return session.session.CloseWithError(quic.ApplicationErrorCode(0), "closed")
}
+18 -4
View File
@@ -58,9 +58,12 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Lock()
defer d.sessionMutex.Unlock()
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
ok = false
}
if !ok {
options := &dialer.DialOptions{}
for _, opt := range opts {
@@ -69,11 +72,13 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
c, err := options.Dialer.Dial(ctx, "udp", "")
if err != nil {
d.sessionMutex.Unlock()
return nil, err
}
pc, ok := c.(net.PacketConn)
if !ok {
c.Close()
d.sessionMutex.Unlock()
return nil, errors.New("quic: wrong connection type")
}
@@ -85,16 +90,22 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
if err != nil {
d.logger.Error(err)
pc.Close()
d.sessionMutex.Unlock()
return nil, err
}
d.sessions[addr] = session
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
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
}
@@ -115,7 +126,10 @@ func (d *quicDialer) initSession(ctx context.Context, addr net.Addr, conn net.Pa
}
tlsCfg := d.options.TLSConfig
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
tlsCfg = tlsCfg.Clone()
if len(tlsCfg.NextProtos) == 0 {
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
}
session, err := quic.DialEarly(ctx, conn, addr, tlsCfg, quicConfig)
if err != nil {

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