Compare commits

..

368 Commits

Author SHA1 Message Date
wenyifan 5b1e38e2c0 新增dnst隧道 2026-07-04 13:48:22 +08:00
wenyifan 74fef8838e 新增dnst隧道 2026-07-04 13:01:21 +08:00
wenyifan bf85966600 切换为默认的dns解析;增加resolver=system的支持 2026-07-02 20:50:24 +08:00
wenyifan a24d79fd45 fix android dns query 2026-06-28 15:01:58 +08:00
wenyifan cb5d5c590b 增加connect host伪装 2026-06-28 12:41:23 +08:00
wenyifan 3993c95700 UTLS兼容 2026-06-28 12:08:30 +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
ginuerzh 31878a72ec docs(handler/tunnel): add architecture documentation and fix observeStats event loss
- Add package-level architecture doc in handler.go covering:
  - NAT traversal reverse proxy architecture
  - CmdBind/CmdConnect roles and data flow
  - Connector lifecycle and waitClose semantics
  - Entrypoint protocol dispatch (first-byte sniffing)
  - SD fallback behavior
- Add data-flow-oriented doc comments to all business files:
  - entrypoint.go: protocol dispatch, dial flow
  - connector.go: Connector/ConnectorPool semantics
  - dialer.go: two-phase dial strategy
  - tunnel.go: MaxWeight semantics, selection algorithm
  - bind.go: 6-step CmdBind flow
  - connect.go: CmdConnect flow with relay framing
  - ephttp.go, eptls.go, eprelay.go: per-protocol entrypoint flow
- Fix observeStats: after successful error retry, also flush new events
  instead of skipping the current tick (handler.go:261-271)
- Add test for observeStats retry-then-flush (handler_test.go)
2026-06-02 18:36:52 +08:00
ginuerzh e791ba47e2 refactor(handler/tunnel): split 794-line entrypoint.go into 6 files, fix 3 bugs, add 48 tests
Breaking Change: moves Connector, ConnectorPool, parseTunnelID from
tunnel.go into dedicated connector.go and id.go.

Bug Fixes:
- dialer.go: clear stale retry err so SD fallback is not masked (nil err
  from pool.Get() left previous iteration's error live)
- connect.go: check WriteTo errors in both mux and non-mux paths
- eprelay.go: use fresh relay.Response for features sent over mux
  (previously reused same resp struct after resp.WriteTo(muxConn))

Refactor:
- entrypoint.go: split ~790 lines into 6 files (ephttp, eptls, eprelay,
  epwebsocket, eplistener, entrypointsvc), moved to dedicated package
- handler.go: moved initEntrypoints/createEntrypointService to
  entrypointsvc.go
- tunnel.go: moved Connector/ConnectorPool/parseTunnelID to
  connector.go and id.go
- connect.go: moved entrypoint-originated handleConnect to eprelay.go
  (handler-side handleConnect kept in connect.go)

Tests (48 new, 88 total):
- id_test.go: parseTunnelID variants (empty, UUID, private '$', invalid)
- tunnel_test.go: Tunnel lifecycle (AddConnector, GetConnector
  weighted/closed filtering, CloseOnIdle, clean),
  Connector (NewConnector nil-opts, GetConn nil/closed session, Close,
  IsClosed), ConnectorPool (Add/Get/Close, idle cleanup, concurrency)
- dialer_test.go: SD error paths (sd.Get error, empty address)
- handler_test.go: observeStats (nil observer, cancelled context loop),
  initEntrypoints (no entrypoints configured)
- helpers_test.go: testLogger, fakeConn for handler tests
- eprelay_test.go: handleConnect ingress/dial/SD round-trip
2026-06-01 22:20:43 +08:00
ginuerzh 382d47ea12 fix: nil HTTP recorder ref in WebSocket goroutines, add tunnel loop detection
- handler/http/websocket, forwarder/sniffer_ws, sniffing/sniffer_ws: nil
  ro2.HTTP in WebSocket copy goroutines to avoid data races on the shared
  HTTP recorder object
- handler/tunnel/bind: remove ingress rule check that incorrectly
  overrode the endpoint host for non-matching ingress rules
- handler/tunnel/entrypoint: add forwarding loop detection by checking
  Gost-Forwarded-Node header for the entrypoint's own node ID
2026-06-01 18:28:01 +08:00
ginuerzh 5a09a48b2d feat: add file upload support for file handler (issue #854)
Add PUT method support to the file:// handler, allowing file uploads via
curl -T. Upload is opt-in, disabled by default — enable with ?put=true
metadata flag. Includes path traversal protection.
2026-05-31 22:48:17 +08:00
ginuerzh dd19e4387a feat(tls): add rejectUnknownSNI support for TLS listeners
Adds RejectUnknownSNI and ServerNames fields to TLSConfig. When enabled,
TLS handshakes with missing or non-allowlisted SNI are rejected at the
handshake level via GetConfigForClient before any certificate is sent.

Works across all TLS-based listener types (tls, mtls, ws, mws, http2,
grpc, http3) since they share the same *tls.Config from LoadServerConfig.
2026-05-31 22:11:29 +08:00
ginuerzh 7cf7284339 fix(hop): respect backup metadata in priority shortcut selection
Priority short-circuit now checks for backup nodes before bypassing the
selector. When any node has backup:true the selector (FailFilter,
BackupFilter, strategy) runs instead, ensuring BackupFilter can separate
primary from failover nodes. Previously backup was silently ignored when
all nodes shared the same non-zero priority from matcher rule length.
2026-05-31 21:32:52 +08:00
ginuerzh a9d3ffa496 fix(handler/masque): add recorder stats, idle timeout, reorder auth/DNS/200
Wire pStats into sub-handlers to capture real byte counts from QUIC
streams instead of the dummy listener conn. Resolve target address and
validate capsule-protocol after authentication instead of before 200
response to prevent dead tunnels and unauthenticated probing. Merge
pending observer events rather than replacing on consecutive failures.
Add idle read timeout to TCP tunnel relay. Add doc comments on exported
symbols and NewHandler.
2026-05-31 21:00:00 +08:00
ginuerzh cf89192211 feat(forwarder/sniffer): add HTTP request body rewriting, deprecate rewriteBody in favor of rewriteResponseBody
- Add RewriteRequestBody field to HTTPNodeConfig (yaml: rewriteRequestBody)
- Add RewriteRequestBody field to HTTPNodeSettings in core chain
- Add rewriteReqBody function symmetric to rewriteRespBody in sniffer_rewrite.go
- Wire up rewriteReqBody call in httpRoundTrip before body wrapping for recording
- Add new RewriteResponseBody config field, deprecate old RewriteBody
- Parse both RewriteRequestBody and RewriteResponseBody in node config parsing
- Add 10 test cases covering nil, skip, content-type filter, and chained rewrites
- Bump core dependency to v0.4.1
- Fix: clone response header map in internal/util/sniffing/sniffer_http.go
2026-05-31 19:46:41 +08:00
ginuerzh 62ce7e189e fix(forwarder/sniffer_http): clone response header map and sync request header to recorder
Fix two data races in Sniffer.httpRoundTrip:
- resp.Header was assigned directly to ro.HTTP.Response.Header without
  cloning, creating shared mutable state between the response and recorder.
- httpSettings.RequestHeader was set on req.Header but not propagated to
  ro.HTTP.Request.Header, causing the recorder to miss configured headers.
2026-05-31 18:02:13 +08:00
ginuerzh 722dde5cfc fix(handler/forward): split readTimeout from pipe idleTimeout to prevent 15s download abort
The readTimeout field (default 15s) was being applied via xnet.Pipe to both
directions of a bidirectional proxy connection. During asymmetric transfers
(e.g. HTTP file download), the direction reading from the tunnel sees no
data after the initial request is forwarded, causing SetReadDeadline to fire
after 15s and abort the entire transfer.

Fix: add a separate idleTimeout field (default 0=disabled) to the metadata
structs in both forward/local and forward/remote handlers, and switch
xnet.Pipe to use idleTimeout instead of readTimeout. The readTimeout field
now only applies to the initial protocol sniffing/handshake phase.

Also document readTimeout vs idleTimeout semantics across all 24 locations
in the x/ module where these timeouts appear:
- readTimeout: handshake sniffing deadline (handlers), upstream response
  header timeout (http.Transport), or transport-level read deadline
- idleTimeout: idle read deadline per Pipe direction (0=disabled)
- ReadTimeout on Sniffer/SnifferBuilder: upstream response header/TLS
  handshake read timeout during sniffing
2026-05-31 17:05:21 +08:00
ginuerzh b8d9c79f72 fix: handle empty host in relay AddrFeature ParseFrom for bind addresses
When rtcp listener calls Router.Bind with ":8000" (port-only address),
the relay AddrFeature.ParseFrom fails because net.SplitHostPort returns
an empty host. This leaves AType at default 0, causing AddrFeature.Encode
to return ErrBadAddrType.

Fix by normalizing port-only addresses to "0.0.0.0:<port>" in both tunnel
and relay connectors before ParseFrom. Also fix error check on
resp.WriteTo in the tunnel handler's handleBind to prevent silent
protocol desync.
2026-05-31 16:00:14 +08:00
ginuerzh e543c0b0fd fix(handler/http2): apply traffic limiter + stats to forward proxy path, decouple auth I/O, add idle timeout
- Wrap non-CONNECT upstream with traffic_wrapper and stats_wrapper (was only on CONNECT path)
- Write 503 error response on forwardRequest failure instead of silent connection close
- Decouple resp.Header from w.Header() to prevent metadata header leakage/doubling in 407 responses
- Return pipeTo signal from authenticate instead of dialing inline (matches http handler pattern)
- Add idleTimeout metadata field and pass to xnet.Pipe for CONNECT tunnels
- Add 4 tests for probe resistance host forwarding and knock bypass
2026-05-30 20:06:56 +08:00
ginuerzh 6ccaae0573 refactor(handler/http2): split handler into 3 focused files with 52 tests
Extract authenticate/probe-resistance to auth.go and roundTrip/forwarding
to proxy.go, following the handler/http/ pattern. Export sentinel errors
(ErrAuthFailed, ErrWrongConnType) for precise test assertions. Fix
flushWriter panic recovery to handle non-string/non-error panics. Default
HTTPS port to 443 when no port is specified in the Host header.
2026-05-30 18:01:19 +08:00
ginuerzh a33c6f4a92 test(handler/http): add 25+ tests for probe resistance, MITM, bypass, and edge cases
+698/-3 across auth_test, connect_test, handler_test, metadata_test,
proxy_test, and websocket_test. Covers probeResistanceResponse (web/file/
host/code types), handleRequest (bypass/UDP-disabled/CONNECT edge cases),
setupTrafficLimiter with observer, observeStats lifecycle, MITM metadata,
HTTP/1.0 keep-alive proxyRoundTrip, websocket body recording, and sniff
timeout handling. Fixes SetDeadline signatures from any→time.Time.
2026-05-30 17:54:27 +08:00
ginuerzh f8ddb193cb refactor(handler/forward/remote): split handler into 6 focused files with 41 tests
Split the 339-line handler monolith and 1169-line test blob into 11 files
(5 source + 6 test) following the handler/forward/local/ pattern.

Source files: handler.go (core), forward.go (raw forwarding),
sniffing.go (SnifferBuilder + protocol dispatch), util.go (errors +
recorder + rate limit), metadata.go (config parsing).

Fixes: add sync.Mutex for hop access (race), use sentinel errors for
errors.Is compatibility, add nil-addr guard in checkRateLimit, reuse
SnifferBuilder via Build() instead of per-call construction.
2026-05-30 16:27:20 +08:00
ginuerzh 32c3d33afd refactor(handler/forward/local): split handler into 6 focused files with 40 tests at 100% coverage
Extract Handle into handleRawForwarding, handleSniffedProtocol, SnifferBuilder,
newRecorderObject, and checkRateLimit across forward.go, sniffing.go, and
util.go. Replace redundant x509.ParseCertificate with tlsCert.Leaf (already
populated by tls.LoadX509KeyPair since Go 1.23).
2026-05-30 16:09:48 +08:00
ginuerzh 7723119d80 fix(loader): set logger level before registering services so -D/-DD take effect
Service registration (register) calls ParseService, which captures
logger.Default() into each service/handler/listener. Since
logger.SetDefault was called after register, all components captured
the initial Info-level logger, making -D/-DD ineffective for service
logging.
2026-05-29 00:26:00 +08:00
ginuerzh 23b58ddd23 refactor(handler/http): extract Authenticator, SnifferBuilder, and normalizeRequest as testable units
Extract three pure-logic components from httpHandler to eliminate I/O
coupling from auth and sniffing construction, enabling synchronous unit
tests and reducing per-request allocation in sniffAndHandle:

- Authenticator struct with AuthResult return type — auth decisions no
  longer write to the connection; callers handle response I/O. Auth
  tests drop net.Pipe/goroutines for direct return-value assertions.
- SnifferBuilder pre-built in Init and reused per-connection via Build(),
  replacing inline sniffing.Sniffer{} construction in sniffAndHandle.
- normalizeRequest extracted to util.go with NormalizedRequest type,
  collapsing 20 lines of inline URL/normalisation into a single call.
- knockMatch extracted as standalone pure function.
- clampBodySize exported as ClampBodySize for cross-package use.
- util_test.go with 22 tests covering utility functions.
- helpers_test.go consolidates shared test fakes (logger, observer, conn).
2026-05-29 00:14:44 +08:00
ginuerzh c246a1d4fa feat(handler/http,http2): support comma-separated hostnames in probeResistance knock
Knock previously accepted a single hostname. Now it accepts multiple
comma-separated hostnames; probe resistance is bypassed if the request
hostname matches any entry in the list. Matching is case-insensitive
and whitespace around entries is trimmed.
2026-05-28 23:20:27 +08:00
ginuerzh 3246b39c93 refactor(handler/http): split handler monolith into 7 focused files with 96 unit tests
Extract the 1024-line handler.go into concern-focused files:
  util.go       — 5 pure functions (decodeServerName, basicProxyAuth, upgradeType,
                  normalizeHostPort, buildConnectResponse) — 100% coverage
  auth.go       — authenticate (5 probe-resistance strategies + knock) + checkRateLimit
  connect.go    — handleConnect, sniffAndHandle, dial, setupTrafficLimiter
  proxy.go      — handleProxy keep-alive loop, proxyRoundTrip, handleUpgradeResponse
  websocket.go  — WebSocket frame sniffing/recording with rate-limited sampling
  udp.go        — UDP-over-HTTP relay with SOCKS5 tunnel
  metadata.go   — metadata struct, parseMetadata, probeResistance type

Key improvements:
- Transport injection (http.RoundTripper field) enables testing without real network
- Nil Router guards in dial, handleUDP, and nil Addr guard in checkRateLimit
- Pure functions converted from methods to package-level (no state dependency)
- setupTrafficLimiter returns cleanup closure to preserve defer lifetime
- handleConnect accepts caller's resp for correct recorder status capture
- Comprehensive package doc with full request-flow documentation

96 tests: 100% on pure functions, 86% on metadata parsing, 55% overall (integration
paths verified by e2e tests).
2026-05-28 23:06:37 +08:00
ginuerzh f4be111420 fix(handler/http): host validation with port split, sniffer ownership guard, body leak, probe resistance hardening
- Split host:port in Host header before DNS/IP validation, accept bare IPs
- Guard sniffer connection ownership with snifferHandled flag to prevent
  double-close when sniffers take over the tunnel
- Close req.Body in handleProxy keep-alive loop on both error and success
  paths to prevent goroutine leaks from idle HTTP transport connections
- Log all resp.Write errors (5 call sites) instead of silently discarding
- Respect resp.Close for all responses, not just ContentLength >= 0,
  fixing unnecessarily closed chunked keep-alive connections
- Harden probe resistance: validate strconv.Atoi result, add 15s timeout
  to web redirect HTTP client, document pr.Knock empty/set/mismatch logic
2026-05-28 21:32:06 +08:00
ginuerzh 776f045896 refactor(handler/forward): extract 6 helpers from remote Handle, add 44 unit tests
Extract newRecorderObject, selectTarget, sniffingDial, buildSniffer,
handleSniffedProtocol, and handleRawForwarding from Handle (200→87 lines).
Move nil Router guard before sniffing for early failure. Log previously
silent sniff and pipe errors at Debug level. Apply readTimeout to Pipe.
Append :0 when target addr lacks a port. Reorder metadata fields and
add doc comments on all exported symbols.
2026-05-28 20:40:13 +08:00
ginuerzh 9eeb88e6a2 refactor(handler/forward): extract 5 helpers from Handle, add 39 unit tests 2026-05-28 20:19:19 +08:00
ginuerzh 19aa429bea fix(handler/forward): add nil Router guard, log discarded sniff/pipe errors, defer sniffer alloc
- Return clear error when Router is nil instead of panicking
- Capture and log sniffing.Sniff and xnet.Pipe errors at Debug level
- Move dial closure and Sniffer construction into HTTP/TLS switch branch
  to avoid unnecessary allocation on non-HTTP/TLS traffic
- Merge ProtoHTTP and ProtoTLS cases, branch internally on proto
- Add doc comments on NewHandler and Handle
- Add comment explaining nil hop is valid (no forwarder config)
- Pass readTimeout to xnet.Pipe via WithReadTimeout
2026-05-27 23:05:32 +08:00
ginuerzh 7e3183488b fix(handler/file): fix deadlock, conn leak, and nil Addr; add 39 tests
Fix bugs in the file handler: unbuffered send channel deadlock on Close,
conn leak when send drops on done, and Addr() returning nil. Add done-
priority select in send(), explicit ln.Close() in Close(), Unwrap() on
responseWriter for http.ResponseController, and nil guards.

Also add package doc, NewHandler doc, and 39 unit tests covering the
listener, responseWriter, handler lifecycle, auth, and file serving.
2026-05-27 22:49:59 +08:00
ginuerzh a2cef9639d test(sniffing): add 20 unit tests, coverage from 34.2% to 58.9%
Add tests for Sniff/isHTTP protocol detection, setHeader, h2Handler.ServeHTTP
(mock RoundTripper), HandleHTTP bypass/body-recording, HandleTLS error path,
handleUpgradeResponse websocket path, and copyWebsocketFrame read error.
2026-05-27 21:30:52 +08:00
ginuerzh 4f526c2aee refactor(sniffing): split sniffer monolith into focused files, extract testable helpers
Split ~850-line sniffer.go into 6 focused files (http, h2, tls, ws,
rewrite, test), extract pure helpers (clampBodySize, normalizeHost,
effectiveReadTimeout), fix "DeafultSampleRate" typo, add doc comments
on all exported symbols, and add 22 unit tests.
2026-05-27 21:21:40 +08:00
ginuerzh 87b055a83b fix(icmp): ICMP tunnel not working on Windows due to wildcard bind (#98)
On Windows, icmp.ListenPacket("ip4:icmp", "0.0.0.0") does not reliably
deliver ICMP packets (golang/go#38427). Add a platform-specific
ListenPacket helper that binds to a discovered interface address and
enables SIO_RCVALL via WSAIoctl for promiscuous receive.

Fixes go-gost/x#36
2026-05-27 21:02:26 +08:00
ginuerzh 618cc7fd9c fix(plugin): CloseIdleConnections silent no-op when tokenTransport wraps HTTP transport
tokenTransport now delegates CloseIdleConnections to the underlying
*http.Transport so http.Client.CloseIdleConnections() works through
the wrapper. Three plugin Close() methods updated to unwrap via
HTTPClientTransport before calling CloseIdleConnections.

RoundTrip now short-circuits when caller already set Authorization,
avoiding an unnecessary req.Clone allocation on the pass-through path.
2026-05-27 20:37:14 +08:00
ginuerzh 3646c23398 refactor(forwarder): extract resolveHTTPNode and resolveTLSNode helpers, add tests
Pull node-resolution logic out of dial/dialTLS into pure resolveHTTPNode
and resolveTLSNode helpers. This separates node selection (bypass, hop,
fallback) from connection establishment, making both independently
testable. Add 21 new tests covering bypass, hop selection, nil-hop,
addr fallback, upgrade response type mismatch, websocket frames,
response body rewriting, and h2 preface validation.
2026-05-26 23:15:15 +08:00
ginuerzh aba7e2a256 refactor(forwarder): split sniffer monolith into focused files, extract testable helpers
Split ~1250-line sniffer.go into sniffer_http.go (HTTP handling, dial, round-trip),
sniffer_tls.go (TLS MITM, termination), sniffer_h2.go (HTTP/2), sniffer_ws.go
(WebSocket frame copy/record), and sniffer_rewrite.go (upgrade, body rewrite).

Extract clampBodySize, normalizeHost, effectiveReadTimeout as package-level
functions to simplify testing. Add 22 tests covering pure functions, option
setters, HTTP proxy integration, WebSocket frame copy, and edge cases.
2026-05-26 22:48:27 +08:00
ginuerzh 92564a0c8c fix(forwarder): ReadTimeout race, goroutine leak, PeerCertificates panic, stale error return
- Use local readTimeout copy in HandleOptions instead of mutating shared
  Sniffer.ReadTimeout, eliminating a data race under concurrent calls
- Close both connections in sniffingWebsocketFrame when one direction
  errors, preventing indefinite goroutine blockage
- Add bounds check on PeerCertificates before indexing in terminateTLS
- Discard non-fatal ParseServerHello error instead of returning it after
  successful xnet.Pipe
- Log res.Write errors in bypass/bad-gateway/connect-failure paths
- Add doc comments to all 15 exported symbols
2026-05-26 21:58:30 +08:00
ginuerzh fb0ee8ead4 refactor(matcher): replace cidranger with zero-alloc trie, migrate to netip
Replace cidranger third-party dependency with a custom binary trie for
zero-allocation CIDR containment lookups. Migrate ipMatcher from
string-keyed map to netip.Addr for correct IPv4/IPv6 normalization.
Extract shared splitHostPort helper to eliminate repeated host:port
parsing boilerplate across all matchers. Add comprehensive benchmarks.
2026-05-26 21:38:55 +08:00
ginuerzh 79718ee19e fix(handler/dns): buffer pool leak, write deadline, async error logging, EDNS0 buffer
Fix bufpool leak when PackBuffer fails by extracting packResponse helper.
Add write deadline before conn.Write to prevent goroutine hangs on slow
clients. Log async exchange errors instead of silently discarding them.
Raise defaultBufferSize from 1024 to 4096 to match EDNS0 standard.
Deduplicate lookupHosts TypeA/TypeAAAA branches.
2026-05-26 21:22:19 +08:00
ginuerzh dd76e9f685 fix(handler/dns): apply readTimeout, fix async context leak, add 40 tests
- Apply parsed readTimeout as connection read deadline (was dead code)
- Use context.WithoutCancel for async cache refresh goroutine
- Add doc comments to all exported symbols
- Add 40 tests covering metadata parsing, init, rate limiting,
  exchanger selection, host lookup, cache, bypass, async refresh,
  and concurrent request handling
2026-05-26 20:55:55 +08:00
ginuerzh b3424b769e fix(handler/api): add nil guard, propagate Shutdown error, doc comments, 20 tests
- Return errHandlerNotInitialized when Handle called before Init
- Propagate s.Shutdown(ctx) error instead of silently discarding
- Add doc comments to all exported symbols
- Add 20 tests covering constructor, init, metadata parsing, HTTP
  endpoints via httptest, Handle lifecycle, singleConnListener, and
  compile-time interface assertions
2026-05-26 20:44:23 +08:00
ginuerzh 1510f841c3 refactor(bypass): named decision types, consolidated pattern set, remove dead code
Replace boolean bypass logic with bypassDecision enum (decisionBypass/decisionProxy),
consolidate four matcher fields into a single patternSet struct for atomic swaps,
extract classifyPatterns as a pure function, and remove unused matched() method
and slices import. Add 204 lines of tests covering decisions, pattern sets,
and group evaluation logic.
2026-05-26 20:27:21 +08:00
ginuerzh 1c07cf6394 fix(service): default nil logger, fix Events() allocation, collect Close errors
- Default nil logger to Nop() in NewService to prevent panics
- Fix Events() returning 20-element slice padded with zero values
- Collect and join all errors in Close() instead of discarding
- Remove unnecessary variable shadow in execCmds
- Add doc comments to all 22 exported symbols
- Add 34 tests covering status, serve loop, close, and edge cases
2026-05-25 23:47:47 +08:00
ginuerzh aabebd047b fix(selector): safe type assertion, clean up debug logging, add doc comments and 64 tests
Replace bare type assertion in ParallelStrategy with comma-ok check to
prevent panic on non-Node inputs. Downgrade noisy Infof hash-selection
log to Tracef and remove Chinese-language debug output. Add doc comments
to all exported symbols in weighted.go.
2026-05-25 23:38:35 +08:00
ginuerzh 18f39f940b fix(sd): add nil service guards, improve error messages, remove unused logger
- Add nil service guards to Register/Deregister/Renew in gRPC and HTTP
  plugins to prevent nil pointer dereferences
- Remove unused log field from grpcPlugin and httpPlugin
- Prefix HTTP error messages with operation context (sd register/deregister/renew/get)
- Return gRPC Register error directly instead of logging + returning
- Add 33 unit tests covering all operations, nil guards, and error paths
2026-05-25 23:04:27 +08:00
ginuerzh b7a4cb0251 fix(routing): fix wildcard host suffix matching and PathRegexp error message
- Fix Host(".example.com") incorrectly matching bare "example.com" by
  using HasSuffix(reqHost, host) instead of HasSuffix(reqHost, host[1:])
- Fix PathRegexp matcher error message saying "PathPrefix" instead of
  "PathRegexp"
- Add doc comments to NewMatcher and Tree types
- Add 28 unit tests covering all matcher types, boolean operators,
  edge cases, and parameter validation
2026-05-25 23:03:53 +08:00
ginuerzh 4443b0b964 fix(router): close httpLoader on shutdown, log parse errors, default nil logger
- Add missing httpLoader.Close() in localRouter.Close() to prevent
  resource leak
- Log parse errors from file/redis/http loaders instead of silently
  discarding them
- Add nil reader guards before calling parseRoutes in load()
- Default nil logger to xlogger.Nop() to prevent nil panics
- Use context.Background() instead of context.TODO() for root context
- Use strings.ReplaceAll instead of deprecated strings.Replace with -1
- Add doc comments to all exported symbols
- Add 27 unit tests + 1 benchmark
2026-05-25 23:03:19 +08:00
ginuerzh f20e151cfa refactor(resolver): extract preference constants and shouldReturn helper
Replace 10 raw "ipv4"/"ipv6" string literals with preferIPv4/preferIPv6
constants, extract the duplicated fallback-condition check (repeated 4x
across resolve and lookupCache) into shouldReturn, and fix a misleading
doc comment on resolvePreference's default return.
2026-05-25 21:54:54 +08:00
ginuerzh 039bcc9af9 fix(resolver): fix IPv4/IPv6 fallback asymmetry, data race, and caller mutation bugs
Normalize Only/Prefer values (ip4→ipv4, ip6→ipv6) so downstream
comparisons match correctly. Fix asymmetric fallback where caller
network=ip6 fell through to IPv4 but ip4 didn't fall through to IPv6
— both branches now check hard constraints (server.Only || callerPref)
only, allowing soft Prefer to fall back. Fix data race on cache item.ts
by reading it inside RLock. Fix Store mutating caller's dns.Msg by
copying before TTL override. Add singleflight dedup, semaphore-bounded
async refresh, DNS rcode error checking, and bump EDNS0 UDPSize to
4096. Add 25 tests.
2026-05-25 21:44:58 +08:00
ginuerzh 28d3ffa310 fix(resolver): fix async context leak, gRPC error swallowing, add 48 tests
Fix async resolve goroutine using caller's cancelable context by
switching to context.WithoutCancel. Fix NewGRPCPlugin silently
returning broken resolver on connection failure. Fix "resolover"
typo in log field. Add doc comments to all exported symbols.
2026-05-25 20:52:19 +08:00
ginuerzh acce86c0e4 fix(registry): log Unregister Close errors, add doc comments and 59 unit tests
Log close errors in Unregister instead of silently discarding them.
Add doc comments to all exported symbols across 20 registry files.
Add comprehensive tests covering base registry, all wrapper types,
hot-reload delegation, nil-fallback semantics, and global accessors.
2026-05-25 20:23:06 +08:00
ginuerzh b1b7500c2f test(recorder): add 55 unit tests for file, HTTP, TCP recorders and HandlerRecorderObject
Covers Record, Close idempotency, nil guards, write errors, concurrent writes,
context propagation, short-write loop, JSON round-trip, and all option functions.
2026-05-25 00:15:35 +08:00
ginuerzh 64a16fafbc fix(recorder): propagate context, handle short writes, add nil guards, idempotent Close, and doc comments
- httpRecorder: use http.NewRequestWithContext instead of http.NewRequest
- tcpRecorder: loop Write to avoid short-write data loss
- fileRecorder: guard against nil out in Record and Close
- plugin/grpc, plugin/http: handle json.Marshal errors instead of ignoring
- fileRecorder, redis*Recorder: idempotent Close via sync.Once
- All exported symbols: add doc comments
2026-05-24 23:58:28 +08:00
ginuerzh 689ba36e92 fix(observer): remove dead nil checks from udpConn, add unit tests
The WrapUDPConn constructor returns nil when either arg is nil, so
c.stats is always non-nil in all udpConn methods. Remove 8 redundant
nil guards that were inconsistent with packetConn (which already
skipped them).

Add 65 unit tests across plugin, stats, and wrapper packages covering
nil safety, race conditions, double-close, event marshaling, and
reset-traffic semantics. Replace magic numbers in tests with named
Kind constants.
2026-05-24 23:47:48 +08:00
ginuerzh 7c95d40e05 fix(observer): add nil guards, fix resource leak, silent event drops, and missing doc comments
- WrapUDPConn, WrapPacketConn, WrapListener: add nil guards for pc/ln params
- NewGRPCPlugin: return no-op observer instead of nil on connection error
- gRPC plugin: fix resource leak by wiring Close() through observerWrapper
  and service.Close() lifecycle
- Both plugins: log unknown event types instead of silently dropping them
- httpPlugin: remove dead nil check for always-non-nil client field
- Stats.Reset: update core interface doc to match implementation behavior
- Add doc comments to all exported symbols across the observer package
2026-05-24 23:13:52 +08:00
ginuerzh bc3d12ec2c fix(metrics): add doc comments, fix labels mutation and nil returns, add idempotent Close, add nil guards, add tests
- Fix promMetrics.Gauge/Counter/Observer mutating caller's labels map by
  using maps.Copy instead of direct assignment
- Fix Gauge/Counter/Observer returning nil for unknown metric names
  (now return noop implementations)
- Fix metricService.Close() race with sync.Once for safe concurrent calls
- Fix SetDSCP returning nil instead of errUnsupport on unsupported ops
- Add nil guards to WrapConn, WrapPacketConn, WrapUDPConn, WrapListener
- Add doc comments to all exported symbols
- Add 45 unit tests (metrics, noop, prom, service, wrapper)
2026-05-24 22:50:41 +08:00
ginuerzh 64f9a048f8 feat(limiter/traffic): add burst support, dropped-packet counters, and cache fixes
- Add NewLimiterWithBurst(rate, burst) for independent burst control
- Extend parseLimit to accept optional 4th field as burst size
- Add DroppedPacketCounter interface with DroppedPackets() for packet/udp conns
- Export ErrRateLimited sentinel for rate-limit error checks
- Fix IP-level cache expiration (NoExpiration → defaultExpiration) to prevent
  unbounded growth; reset TTL on access in In/Out
- Short-circuit single-limiter path to avoid limiterGroup allocation
- Add nil guard in cachedTrafficLimiter when both old and new values are nil
- Remove unused ctx field from wrapper structs
2026-05-24 21:52:02 +08:00
ginuerzh a9d94e7009 fix(metadata): nil-Metadata from NewMetadata(nil), missing float32 in GetFloat; add doc comments and tests
NewMetadata(nil) previously returned a nil Metadata interface, causing
panics on direct .Get()/.IsExists() calls. Now returns an empty map.
GetFloat added float32→float64 promotion, matching GetString behavior.
Doc comments for all 13 exported symbols across both packages.
41 unit tests (11 metadata + 30 util) at 100% coverage.
2026-05-24 20:59:30 +08:00
ginuerzh dc99f4731f docs(limiter/traffic): add doc comments, unit tests 2026-05-24 20:54:34 +08:00
ginuerzh e9372ea44a fix(limiter/traffic): infinite Write loop on zero burst, missing httpLoader.Close, stale reload values, ignored WaitN error, silent UDP drops, SplitHostPort fallback 2026-05-24 20:20:01 +08:00
ginuerzh 9bbd7da526 fix(limiter/rate): short-circuit Allow on denial, add missing httpLoader.Close, doc comments, tests
- limiterGroup.Allow(): return false immediately on first denial instead of
  continuing through all limiters (wasting tokens on subsequent limiters).
  Sort limiters by Limit() so most restrictive fires first.
- rateLimiter.Close(): add missing httpLoader.Close() to prevent resource leak.
- Add doc comments for all exported symbols (package, constants, Options,
  NewRateLimiter, NewLimiter, ErrRateLimit, generators).
- Add rate_test.go with 59 unit tests covering generators, limiter groups,
  parsing (limit/line/patterns), reload, loader merge, Close behavior,
  and priority ordering (exact IP > CIDR > $$ > $).
2026-05-24 20:09:26 +08:00
ginuerzh 05fe690f67 test(limiter/conn): add unit tests for conn limiter and wrappers 2026-05-24 19:38:54 +08:00
ginuerzh fa708f4b5f docs(limiter/conn): add doc comments, fix nil guards and missing Close
Add package and exported symbol doc comments across conn, generator,
limiter, and wrapper packages. Fix nil receiver guard in
connLimitSingleGenerator.Limiter, add missing httpLoader.Close call,
return context.Background instead of nil in serverConn.Context, return
explicit error on connection limit exceeded, and simplify Allow logic.
2026-05-24 19:38:07 +08:00
ginuerzh 0e96f602fa fix(matcher,plugin): case-insensitive matching, port accumulation, nil guards, doc comments
matcher: fix IPv6 normalization via netip.ParseAddr, port-range overwrite
by switching to []*PortRange map, case-insensitive domain/host matching,
glob.MustCompile→Compile to avoid panic on invalid patterns.

plugin: guard nil *Options in NewGRPCConn and NewHTTPClient; add
HTTPClientTransport helper for idle-connection cleanup.

net/*: add doc comments for all exported symbols (no code changes).

Add 30 tests (matcher_test.go 22, plugin_test.go 8) covering all
matcher types, nil safety, case insensitivity, IPv6 normalization,
port ranges, and plugin option composition.
2026-05-24 18:18:42 +08:00
ginuerzh 51455da96f fix(loader): wire context into HTTP request, guard nil opts in Redis list/hash
- HTTPLoader.Load: http.NewRequest → NewRequestWithContext so caller
  cancellation/deadlines propagate to the outgoing request
- RedisListLoader, RedisHashLoader: add nil-guard before calling opt()
  (already present in StringLoader, SetLoader, and HTTPLoader)
- Add doc comments for all 10 exported symbols (package, interfaces,
  constructors, option types, option funcs, DefaultRedisKey)
- Add loader_test.go with 32 tests covering FileLoader, HTTPLoader
  (context cancel, error status, empty/large body), Redis options,
  nil-guard safety, and interface satisfaction checks
2026-05-24 18:04:02 +08:00
ginuerzh b991baaf72 fix(io,tunnel): nil deref in RecorderObjectFromContext, CloseRead checked Writer, SetReadDeadline no unwrap
- entrypoint.go: guard ictx.RecorderObjectFromContext with nil checks on
  ClientID and Redirect assignments
- io.go: fix CloseRead checking Writer instead of Reader, add Reader
  fallback to CloseWrite, unwrap *readWriter/*readWriteCloser in
  SetReadDeadline to reach underlying net.Conn
- docs: add package doc and exported symbol comments to internal/ctx
  and internal/io
2026-05-24 17:51:16 +08:00
ginuerzh 501051ce4d fix(ingress): nil deref in parseRules, missing httpLoader.Close, swallowed SetRule error
- parseRules: add nil guard before accessing rule.Hostname (parseLine
  returns nil for comments, blanks, and invalid lines)
- Close: call httpLoader.Close() alongside fileLoader and redisLoader
- grpcPlugin.SetRule: log error and return false instead of discarding it
- Add 28 unit tests covering parseLine, parseRules, GetRule wildcard
  matching, reload, Close, and all loader types
2026-05-24 17:27:17 +08:00
ginuerzh 011759d888 docs(hosts,hop): add doc comments, fix missing httpLoader.Close, accumulate reload errors
- hosts/hosts.go: add package and symbol doc comments, fix missing
  httpLoader.Close() in Close()
- hosts/plugin: add doc comments for HTTP and gRPC plugin types
- hop/hop.go: add doc comments, accumulate reload/parse errors via errors.Join
  instead of silently discarding them
- hop/plugin/http.go: add doc comments and missing Close() method
- Add unit tests for hosts (580 lines) and hop (991 lines)
2026-05-24 16:00:53 +08:00
ginuerzh 419f4c4c73 docs(ctx): add doc comments, remove dead WithContext interface, simplify HashFromContext
Remove unused WithContext interface (zero references in codebase), add
doc comments for all 14 exported symbols, and simplify HashFromContext
to match the one-liner pattern used by the other four getters.
Add 22 unit tests covering round-trip, empty context, wrong type, nil
values, multiple values, independent keys, and the Context interface.
2026-05-24 14:26:45 +08:00
ginuerzh 4efb4b9974 fix(direct): guard nil dialer and nil logger in Connect, add doc comments
Add nil checks for connect-options dialer and logger to prevent
nil-pointer panics when Connect is called without a DialerConnectOption
or LoggerOption. Add package and NewConnector godoc comments.

Add 10 unit tests covering construction, Init metadata parsing,
Connect success/reject/dial-error paths, nil dialer, nil logger,
and the reject conn stub.
2026-05-24 14:19:08 +08:00
ginuerzh effba3b690 docs(parsing): add package and symbol doc comments
Add godoc comments for all 62 exported symbols across 20 files in
config/parsing/: 23 MDKey constants, 35 Parse*/List/Default* functions,
3 TLS helpers, 1 Args struct with 7 fields, and the package doc.
2026-05-24 13:58:37 +08:00
ginuerzh 2b1e4ca3a7 test(parsing): add 229 unit tests across 18 config/parsing packages
Covering nil-input safety, plugin backends (HTTP/gRPC/default), loaders
(File/HTTP/Redis), selector strategies, edge cases (empty/invalid inputs),
and hop-to-node metadata inheritance. All tests passing.
2026-05-24 13:34:33 +08:00
ginuerzh 9febb23bc9 fix(config): stop mutating shared config structs during parsing
Prevent data loss and nil panics across 5 parse functions:
- chain: nil-guard hop entries to avoid deref on empty YAML lists
- hop: build merged metadata map instead of writing inherited values
  into the original NodeConfig; swap+restore around ParseNode call
- node: use local connCfg/dialCfg instead of overwriting cfg.Connector
  and cfg.Dialer fields; only fill in Type when empty
- parser: preserve existing config file fields when env vars override
  Level/Addr (was replacing the entire struct)
- router: only update ipNet from dst CIDR if parse succeeds, keeping
  the route.Net fallback instead of overwriting with nil
2026-05-24 12:56:54 +08:00
ginuerzh 8390ccadab test(config): add unit tests for loader, doc comments, fix fmt.Errorf misuse
- Add 808-line loader_test.go covering registerGroup, register, and Load
- Add package and exported symbol doc comments to loader.go
- Fix fmt.Errorf(resp.Status) → errors.New(resp.Status) in sd/plugin/http.go
  (resp.Status is a plain string, not a format string)
2026-05-24 12:36:51 +08:00
ginuerzh 82e7e50120 fix(config): nil deref on Load, SoMark/Metadata gating, netns copy-paste bug, and partial-reg-failure side effects
- loader: guard cfg == nil in Load() to prevent nil-pointer dereference on
  cfg.Log / cfg.TLS access.
- loader: defer logger.SetDefault and parsing.SetDefaultTLSConfig until
  after register() succeeds, so a failed reload does not leave global
  state (logger, default TLS) pointing at the new config while components
  remain from the old config.
- loader: extract registerGroup[T] helper to eliminate repeated
  unregister-then-register boilerplate.  Entries are parsed and collected
  before unregistering; a parse error now preserves the previous group
  (old code unregistered first, then parsed, leaving an empty registry on
  parse failure).
- hop: move cfg.SockOpts.Mark extraction outside the cfg.Metadata != nil
  block so the socket mark is read even when hop-level metadata is absent.
- hop: fix copy-paste error in netns inheritance: v.Name was incorrectly
  stored as MDKeyNetns instead of v.Netns (introduced in f71351f).
2026-05-24 10:15:37 +08:00
ginuerzh 5a838a7d47 fix(dtls): upgrade pion/dtls v2.2.6 to v3.1.1 fixing CVE-2026-26014
Upgrade github.com/pion/dtls from v2.2.6 to v3.1.1 to fix nonce reuse
vulnerability in AES-GCM ciphers (GHSA-9f3f-wv7r-qc8r). Migrate from
deprecated Config-struct API to ListenWithOptions/ClientWithOptions
options-based API.

v2 Config fields mapped to v3 functional options:
- Certificates, InsecureSkipVerify, ExtendedMasterSecret, ServerName,
  RootCAs, ClientCAs, ClientAuth, FlightInterval, MTU

ConnectContextMaker removed — no longer exists in v3; explicit
HandshakeContext(ctx) on *Conn is the replacement for timeout control.
2026-05-23 18:48:21 +08:00
ginuerzh 982ffa8149 test(cmd): add unit tests achieving 93.8% coverage 2026-05-23 18:36:24 +08:00
ginuerzh 1f62219c1a docs(cmd): add package and symbol doc comments 2026-05-23 18:14:05 +08:00
ginuerzh ee57a383c5 fix(cmd): shallow-copy leaks in node/service expansion, inconsistent auth priority
Three bugs in CLI-to-config conversion:

1. Comma-separated -F nodes shared Connector/Dialer/Metadata pointers
   due to struct value copy; add deep-copy helpers per node.

2. Port-range-expanded -L services shared Handler/Listener/Metadata
   pointers; deep-copy per service so metadata mutations don't cross-talk.

3. buildServiceConfig's ?auth= query param unconditionally overwrote
   URL-embedded auth; buildNodeConfig used fallback-only. Align both
   on URL-auth-first semantics (query param as fallback).
2026-05-23 17:44:39 +08:00
ginuerzh c59566d96f test(chain): add unit tests achieving 86.7% coverage
112 tests covering Transport, Chain, Route, Router, packetConn wrapper,
routePath, and all interface satisfaction checks.
2026-05-23 17:17:38 +08:00
ginuerzh c7f58a400b docs(chain): add package and symbol doc comments
Cover the three main abstractions (Router, Chain, Transport), route traversal
lifecycle (Dial → Handshake → Connect), and multiplexing sub-route splitting.
2026-05-23 16:36:47 +08:00
ginuerzh 569572d6f7 fix(chain): Copy returns wrong pointer, connection leaks in Connect/Handshake, deferred cancel accumulation in retry loops
- transport.go: Copy() returned original pointer instead of the allocated copy,
  causing data races and incorrect routing for multiplexed chains.
- route.go: Connect and Handshake failures in intermediate nodes leaked the
  returned connection. Handshake fix preserves the original Connect result
  (guaranteed non-nil) and closes both the Handshake return value and input.
- router.go: defer cancel() inside retry loops accumulated context timers
  across iterations. Wrapped each loop body in an IIFE so defer scopes
  per-iteration and timers are released immediately.
2026-05-23 16:30:53 +08:00
ginuerzh d015378654 test(bypass): add unit tests achieving 96.4% total coverage
- 88 tests: 70 for core (96.8%), 12 for gRPC plugin, 14 for HTTP plugin
- Cover all matchers (IP, CIDR, wildcard, IP range), loader sources,
  bypassGroup logic, periodReload, and plugin fail-open semantics
- Add nil-logger guard in httpPlugin.Contains to prevent panic
  when logger is nil
2026-05-23 16:04:30 +08:00
ginuerzh bf4af78ebf docs(bypass): add package and symbol doc comments
Add Go doc comments for all exported symbols, option functions, and
internal types across the bypass package and plugin sub-package,
following the style of the admission package.
2026-05-23 15:55:52 +08:00
ginuerzh 54ca9161f7 fix(bypass): httpLoader.Close leak, httpPlugin fail-closed, missing Close method
- Add missing httpLoader.Close() in localBypass.Close (was leaking HTTP loader resources)
- Change httpPlugin.Contains to fail-open on errors, matching gRPC plugin
- Add error logging on all httpPlugin error paths (previously silent)
- Add httpPlugin.Close method to drain idle HTTP connections
- Document bypassGroup.Contains whitelist AND / blacklist OR logic
2026-05-23 15:52:22 +08:00
ginuerzh e99ef1c951 test(auth): add unit tests achieving 100% coverage for core, 100% for gRPC plugin, 96.6% for HTTP plugin 2026-05-22 22:59:30 +08:00
ginuerzh 91cae5bdeb fix(auth): redisLoader copy-paste bug, httpLoader.Close leak, reload data wipe, silent parse errors
- redisLoader branch used fileLoader instead of redisLoader (copy-paste bug)
- Close() was missing httpLoader.Close() (resource leak vs admission pattern)
- reload() now preserves kvs on load() error instead of wiping to static-only
- load() propagates first loader error, reload() returns early on load failure
- parseAuths scanner errors now logged instead of silently discarded
- http plugin drains body on non-200 to allow keep-alive connection reuse
- Replace manual map-copy loops with maps.Copy
2026-05-22 22:35:55 +08:00
ginuerzh 3f73c82d00 fix(ss): address review findings for PR #96 — nil guards, resource leaks, dead code
- Bump go-shadowsocks2 to v0.1.3 (new API symbols)
- Return error instead of nil when Target() is empty in TCP handler
- Reset wbuf unconditionally on write error to prevent unbounded growth
- Add nil guard on targetAddr before WriteTo to prevent panic
- Remove duplicate NewClientConfig call and dead ClientConfig literal
- Remove unused noDelay metadata field
- Fix buffer-size guard to catch negative values (len(b)==0 → bufSize<=0)
- Add address context to parse/session error messages
2026-05-22 21:40:33 +08:00
ginuerzh 5433ca580c Merge remote-tracking branch 'origin/pr/96' 2026-05-22 21:07:43 +08:00
ginuerzh 0c37224530 test(admission): add unit tests achieving 100% coverage for wrapper, 97.6% for core, 96.4% for plugin 2026-05-22 19:03:47 +08:00
ginuerzh b9d74c1b8e docs(admission): add package and symbol doc comments
Add package-level documentation and Go doc comments for all exported
types, functions, and methods across the admission package, its plugin
sub-packages, and connection/listener wrappers.
2026-05-22 17:19:48 +08:00
ginuerzh 174bc082d1 fix(tunnel): data race in CloseOnIdle and goroutine leak in sniffingWebsocketFrame
- Use write lock (Lock) instead of read lock (RLock) in CloseOnIdle since
  it modifies the close channel, preventing a race with Close() that could
  panic on double-close of channel.
- Buffer sniffingWebsocketFrame errc to capacity 2 and close both
  connections on first error to ensure the remaining copy goroutine
  unblocks and exits cleanly.
2026-05-22 16:31:37 +08:00
ginuerzh ed93d60004 fix(admission): use client network, fail-open on nil gRPC client, close http resources
- Use client address network instead of listener network in admission wrapper
- Return true (allow) when gRPC admission plugin client is nil to fail-open
- Add Close() to httpPlugin to close idle HTTP connections
- Close httpLoader in localAdmission.Close()
- Remove dead commented-out code in periodReload
2026-05-22 15:58:24 +08:00
kLiHz 3c8995027a adapt newest go-shadowsocks2 lib 2026-05-22 13:35:01 +08:00
ginuerzh 70dee081a4 chore(deps): bump Go 1.26.3, update gosocks4/5, plugin, relay, tls-dissector 2026-05-21 22:59:12 +08:00
ginuerzh 01a2bbaa11 fix(masque): address review findings for PR #76
- Return errors from MasqueConn.Read/Write instead of silently
  succeeding (prevents infinite busy-loops and silent data loss
  if these methods are accidentally called).
- Set recorder Network field in protocol dispatch switch rather
  than hardcoding "udp", so early errors don't misreport type.
- Clarify that DatagramConn context-ID handling only supports
  context ID 0 (sufficient for CONNECT-UDP per RFC 9298).
2026-05-21 22:50:29 +08:00
ginuerzh 66b53580af merge: PR #76 — MASQUE CONNECT-UDP and CONNECT-TCP support (RFC 9298/9114)
Extend MASQUE implementation to support TCP tunneling via standard
HTTP/3 CONNECT method, in addition to UDP via CONNECT-UDP.
2026-05-21 22:50:23 +08:00
ginuerzh 8435716166 merge: PR #93 — configurable TCP keepalives for all TCP-based listeners and dialers 2026-05-21 21:47:15 +08:00
ginuerzh 3402418573 merge: PR #92 — make pipe idle timeout configurable via idleTimeout metadata
Merge beezly/fix/configurable-pipe-timeout with conflict resolution
in pipe.go (PipeOption/WithReadTimeout functional options pattern).

Fix pipe_test.go: update 4 pipeHalf() calls to pass readTimeout arg
matching the new signature.
2026-05-21 21:38:32 +08:00
ginuerzh 238a0b95b7 feat: upgrade core to v0.4.0, pass network to admission, add dial options
Upgrade github.com/go-gost/core from v0.3.3 to v0.4.0, adapting to
interface changes:
- Admit(ctx, network, addr, opts) now accepts a network parameter
- Router.Dial now accepts variadic chain.DialOption, forwarded to
  the route's Dial call along with router-level options
- gRPC/HTTP admission plugins include the new Network field

Also fix two typos: ResoloverNodeOption -> ResolverNodeOption,
WithHostOpton -> WithHostOption.
2026-05-21 21:26:49 +08:00
ginuerzh ef58909fb7 fix(dialer): correct inverted setMark on FreeBSD/OpenBSD
Fix setMark early-return conditions that were inverted relative to
Linux: on FreeBSD (SO_USER_COOKIE) and OpenBSD (SO_RTABLE), a
non-zero mark was short-circuited instead of applied, disabling
socket marking entirely.

Also fix GetClientIP to trim leading whitespace from X-Forwarded-For
entries (per RFC 7239), and fix Body.Read to subtract len(b) instead
of n from recordSize when a single read exceeds the remaining quota.

Add unit tests for internal/net/ (addr, dialer, http, ip, net, pipe,
transport, resolve, proxyproto, udp).
2026-05-21 20:58:48 +08:00
ginuerzh d189dc7967 fix(pipe): prevent error swallowing and goroutine leak in Pipe/Transport
Pipe: remove context.WithCancel that caused premature ctx.Done()
before the second pipeHalf could return its error. Replace with a
completed counter that drains both errors even on cancellation.

Transport: increase error channel buffer from 1 to 2 to prevent
goroutine leak when both CopyBuffer goroutines complete.
2026-05-21 10:28:55 +08:00
Andrew Beresford ac99425002 feat: extend TCP keepalive support to all TCP-based listeners and dialers
Add configurable TCP keepalive (keepalive, keepalive.idle,
keepalive.interval, keepalive.count) to all TCP-based transport types:
tls, mtls, mtcp, ws/wss, mws/mwss, http2, ssh, sshd listeners and
tls, mtls, mtcp, ws/wss, mws/mwss, ssh, sshd dialers.

For types that already use "keepalive" for an app-level protocol
(grpc listener/dialer, ws/mws dialers, ssh/sshd dialers), the TCP
keepalive parameters are exposed under the tcp.keepalive.* prefix to
avoid ambiguity.

Also extract shared helpers into internal/net/keepalive.go:
- WrapKeepaliveListener: applies KeepAliveConfig on every accepted TCPConn
- ApplyKeepalive: applies KeepAliveConfig to a single TCPConn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:32:53 +01:00
Andrew Beresford cf70a0cbe1 Add configurable TCP keepalives to TCP listener and dialer
Exposes TCP keepalive configuration via listener and dialer metadata:

  keepalive: true
  keepalive.idle: 30s
  keepalive.interval: 10s
  keepalive.count: 6

When enabled, the OS sends keepalive probes after the idle period and
tears down the socket if the remote does not respond. This causes any
blocked Read() on a dead connection to return promptly, releasing
goroutines and memory — covering both legs of a CONNECT tunnel (the
inbound client connection via the listener, and the outbound upstream
connection via the dialer).

This is the correct alternative to a hard application-level read
deadline (SetReadDeadline) for detecting dead connections, as it
distinguishes truly dead connections from legitimately idle ones.

Relates to: https://github.com/go-gost/x/issues/91
2026-05-08 11:33:54 +01:00
Andrew Beresford 290e9c37d5 Make pipe read timeout configurable via idleTimeout metadata
The 30s hardcoded readTimeout in Pipe() caused all CONNECT tunnel
connections to be hard-closed after 30s of inactivity, breaking
WebSocket and long-polling connections through the proxy.

Pipe() now accepts a WithReadTimeout(d) option. When d is 0 (the
default) no read deadline is set, relying on TCP keepalives or context
cancellation to detect dead connections instead.

The HTTP handler exposes this as the idleTimeout metadata key.

Fixes: https://github.com/go-gost/x/issues/91
2026-05-08 11:31:53 +01:00
dependabot[bot] 07ca57055a Bump google.golang.org/grpc from 1.67.1 to 1.79.3
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.67.1 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.67.1...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 21:37:46 +08:00
ginuerzh 2acc669976 fix webtransport listener 2026-04-21 21:34:50 +08:00
misakydrip 6c32dda058 fix a udp bug
fix udp bug error: use of closed network connection
2026-04-21 21:07:00 +08:00
RMT 2a2643a6b7 WIP 2026-04-21 21:05:43 +08:00
Evsyukov Denis 802943bd28 fix(ss): propagate cipher errors and decode SIP002 base64 auth
- Return err instead of nil in SS TCP/UDP connector Init when
  NewClientConfig fails, preventing nil pointer dereference in WrapConn
- Initialize tcpClient in UDP connector for UDP-over-TCP path
- Add decodeSIP002Auth to handle ss://BASE64(method:password)@host:port
  URL format with RawURLEncoding and StdEncoding fallback
- Update buildServiceConfig and buildNodeConfig to decode SIP002 auth
  for ss* schemes before falling back to standard userinfo parsing
- Support RawURLEncoding in parseAuthFromCmd with StdEncoding fallback
- Add tests for decodeSIP002Auth, parseAuthFromCmd, and integration
  tests for buildNodeConfig/buildServiceConfig
2026-04-21 20:55:45 +08:00
pak.wzq f373f2f4b9 fix 2026-04-21 20:53:10 +08:00
Vasileios Papadimas 96f35bd7aa feat: add custom header support to PHT protocol
- Add Header field to PHT Client and clientConn structs
- Support custom headers in authorize, push, and pull requests
- Add metadata parsing for header configuration as map[string]string
- Enables PHT usage with header-based authentication (e.g., Cloudflare Access)

Backwards compatible - headers are optional.
2026-04-21 20:51:17 +08:00
Jason Lyu cd7bf9521f fix sshd connection type 2026-04-21 20:49:28 +08:00
21paradox 180145189f add SetReadDeadline in Pipe, to prevent memory not auto release in mws/mtcp 2026-04-21 20:02:32 +08:00
David Manouchehri 7625973ca1 Add MASQUE/CONNECT-UDP support (RFC 9298)
Implement UDP tunneling over HTTP/3 using HTTP Datagrams (RFC 9297):
- Add masque handler for server-side CONNECT-UDP
- Add masque connector and h3-masque dialer for client-side
- Add enableDatagrams option to HTTP/3 listener
- Add shared utilities for datagram connections and path parsing

Resource management and connection caching:
- Add deferred stream cleanup in connector on error paths
- Add IsClosed() and Close() methods to Client for proper session management
- Clean up stale cached clients in dialer before reuse
- Close underlying stream when DatagramConn is closed
- Move RequestStream opening from connector to dialer to enable dead
  connection detection and cache invalidation (follows QUIC dialer pattern)
2026-04-21 19:53:07 +08:00
Rehtt b3b5986b63 fix command line resolution path-based protocols (such as unix socket) address resolution problem 2026-04-21 19:47:38 +08:00
dependabot[bot] 254699e176 Bump github.com/quic-go/quic-go from 0.54.1 to 0.57.0
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.54.1 to 0.57.0.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.54.1...v0.57.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 19:42:58 +08:00
RMT 6389378610 Remove usage of ShadowConn for ssHandler and ssConnector (#68)
The new ss library has integrated the function of ShadowConn.
2025-12-29 16:22:42 +08:00
David Manouchehri c5f91232cb Add TCP CONNECT support to MASQUE (RFC 9114)
Extend MASQUE implementation to support TCP tunneling via standard HTTP/3
CONNECT method, in addition to existing UDP support via CONNECT-UDP:

- Add StreamConn type for TCP data transfer over HTTP/3 stream body
- Update handler to dispatch based on :protocol pseudo-header:
  - "connect-udp" for UDP (RFC 9298)
  - Empty/"HTTP/3.0" for TCP (RFC 9114)
- Add handleConnectTCP() using bidirectional stream relay
- Update connector to support both TCP and UDP networks
- Add connectTCP() for standard CONNECT requests

TCP uses :authority for target address and stream body for data.
UDP uses path template and HTTP/3 datagrams for data.
2025-12-28 23:47:51 +00:00
David Manouchehri 1752b29df9 Add MASQUE/CONNECT-UDP support (RFC 9298)
Implement UDP tunneling over HTTP/3 using HTTP Datagrams (RFC 9297):
- Add masque handler for server-side CONNECT-UDP
- Add masque connector and h3-masque dialer for client-side
- Add enableDatagrams option to HTTP/3 listener
- Add shared utilities for datagram connections and path parsing

Resource management and connection caching:
- Add deferred stream cleanup in connector on error paths
- Add IsClosed() and Close() methods to Client for proper session management
- Clean up stale cached clients in dialer before reuse
- Close underlying stream when DatagramConn is closed
- Move RequestStream opening from connector to dialer to enable dead
  connection detection and cache invalidation (follows QUIC dialer pattern)
2025-12-28 22:16:54 +00:00
dependabot[bot] 77eda4ce00 Bump golang.org/x/crypto from 0.40.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.40.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.40.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-22 22:33:55 +08:00
RMT a280193dac Bump github.com/go-gost/go-shadowsocks2 from 0.1.0 to 0.1.1 2025-11-22 22:33:24 +08:00
RMT 0c97db3c05 Adapt new shadowsocks library.
1. Modify ss/ssu handler and connector
2. Add users to metadata of ss and ssu handler
2025-11-11 12:37:33 +08:00
Simon Hang d2cc07d3ac Pass username to new redis client 2025-10-30 13:26:11 +08:00
dependabot[bot] a0c46cec6e Bump github.com/quic-go/quic-go from 0.53.0 to 0.54.1
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.53.0 to 0.54.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.53.0...v0.54.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-11 22:02:16 +08:00
eWloYW8 eae322c7ba feat: support custom ComponentID for Windows TAP device configuration 2025-10-11 22:01:24 +08:00
ginuerzh 1c1f092a14 fix metrics wrapper 2025-10-11 21:54:16 +08:00
ginuerzh a69a759e17 tunnel: add multiple entrypoints 2025-10-09 22:32:03 +08:00
ginuerzh 007ff0bae9 fix panic for channel close 2025-09-07 14:20:05 +08:00
ginuerzh e2072399dd fix sniffing for websocket 2025-09-04 21:30:49 +08:00
ginuerzh c7d16962ec add service option for plugin 2025-08-29 23:36:31 +08:00
Denis Galeev a12ed27dfa fix handler/socks5: UDP IP address filtering 2025-08-28 16:54:03 +08:00
Denis Galeev bf70f07419 fix recorder: writes order 2025-08-25 10:03:26 +08:00
ginuerzh a65b7a059a try to fix http2 panic (#54) 2025-08-21 22:12:43 +08:00
Denis Galeev d5683e9138 prevent panic 2025-08-21 21:21:23 +08:00
ginuerzh 52289bea6c add network param for sniffing 2025-08-13 21:25:35 +08:00
ginuerzh d0ff5358df fix panic for nil pointer 2025-08-13 21:24:05 +08:00
ginuerzh 0e5c285eb7 fix empty hop nodes 2025-08-10 18:36:43 +08:00
ginuerzh a5f66b0615 node: add bypass and admission matcher 2025-08-10 17:59:17 +08:00
ginuerzh f3d3d2231e fix tun route for ipv6 2025-08-09 19:19:04 +08:00
ginuerzh 4eb006f1b0 tungo: debug exec.Command output 2025-08-09 16:34:04 +08:00
ginuerzh 2b9f795972 logger: fix panic when ouput is none 2025-08-09 14:03:26 +08:00
ginuerzh 589c185053 forwarder: connect to sni host for empty node 2025-08-09 12:43:09 +08:00
ginuerzh 404aaae5f1 without cancel the context 2025-08-05 20:24:00 +08:00
ginuerzh fd9dc6408a fix ws listener 2025-08-05 00:13:48 +08:00
ginuerzh b597467858 add context for conn 2025-08-04 19:29:38 +08:00
ginuerzh ad5cf6fd61 add proxyProtocol support for dialer 2025-08-03 15:36:29 +08:00
ginuerzh f71351f5ef add interface xnet.SrcAddr/DstAddr 2025-08-03 10:15:53 +08:00
ginuerzh 79203e407c add scoped options for cmd 2025-08-02 12:07:18 +08:00
ginuerzh db21de831a fix xnet.Pipe 2025-08-01 23:00:50 +08:00
ginuerzh a5309eee97 add buffer size option for udp connection 2025-07-30 21:40:53 +08:00
ginuerzh b64e5901b6 http: fix response for connect request 2025-07-30 21:40:31 +08:00
ginuerzh 87e454a6f1 tungo: additional metadata options 2025-07-30 10:07:18 +08:00
ginuerzh 3bbc10796c http2: added non-connect request support 2025-07-30 10:06:52 +08:00
ginuerzh 208d18125c replace xnet.Transport by xnet.Pipe 2025-07-28 20:52:15 +08:00
ginuerzh 35a049fb03 rename vtun to tungo 2025-07-26 16:17:04 +08:00
ginuerzh 281295d02f fix tun device 2025-07-23 22:44:02 +08:00
ginuerzh acae52ab89 add vtun listener and handler 2025-07-23 21:30:21 +08:00
ginuerzh afcf4a5252 parse real client IP 2025-07-15 19:59:10 +08:00
ginuerzh d9a2f44a78 add src field for logger and recorder 2025-07-02 21:35:02 +08:00
ginuerzh 97ed5080a6 service: reset tempDelay when retrying after accept error #52 2025-06-28 18:11:13 +08:00
ginuerzh c8d905dea5 read config from stdin 2025-06-28 18:09:04 +08:00
ginuerzh fe096d6e74 add clientIP for ws logger #51 2025-06-28 18:02:21 +08:00
ginuerzh 3ec2f9d487 update quic 2025-06-25 21:06:23 +08:00
ginuerzh ce3c28baf5 rm deprecated library shadowsocks-go/shadowsocks 2025-06-25 21:05:39 +08:00
ginuerzh 11d982e062 add new APIs 2025-06-25 21:02:55 +08:00
ginuerzh 6caad5e37e add file option for AuthConfig 2025-06-24 21:16:17 +08:00
ginuerzh d819fd3041 support http-100 continue for sniffer 2025-06-24 21:15:38 +08:00
ginuerzh 8cacfad9eb fix metrics for listener 2025-06-24 21:13:30 +08:00
dependabot[bot] a71b9d79fd Bump golang.org/x/net from 0.33.0 to 0.38.0 (#50)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ginuerzh <ginuerzh@users.noreply.github.com>
2025-06-24 11:21:19 +08:00
dependabot[bot] 9a380d3fd8 Bump golang.org/x/crypto from 0.31.0 to 0.35.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.31.0 to 0.35.0.
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.35.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.35.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-24 11:01:46 +08:00
Denis Galeev 4c99e11d81 fix parse 2025-06-24 11:01:26 +08:00
Denis Galeev 1eaf08c9c1 fix recorders 2025-06-24 11:01:26 +08:00
Denis Galeev 0e59ccc64b add new metric 2025-06-24 11:01:26 +08:00
ginuerzh 04d1587a77 set dns for tun 2025-02-25 20:39:33 +08:00
ginuerzh 69a0aa8853 use fixed buffer size 2025-02-18 21:04:04 +08:00
ginuerzh e0915affda use fixed buffer size 2025-02-18 17:38:27 +08:00
ginuerzh d610e2dc01 add traffic limiter for relay bind 2025-02-18 17:37:13 +08:00
ginuerzh 2495b13053 fix traffic logging for socks5 udp 2025-02-18 17:30:36 +08:00
wzxjohn 11bca5bc3d fix(http): set Proxy-Agent header after authenticate to avoid leak #42 2025-02-18 15:30:56 +08:00
Denis Galeev e4b1c3c2b5 feat recorder: add rotate options 2025-02-18 15:30:42 +08:00
ginuerzh 75a106ee84 fix tun device 2025-02-11 23:12:46 +08:00
ginuerzh 2132268780 Bump golang.zx2c4.com/wireguard 2025-02-11 22:13:57 +08:00
ginuerzh ef440e8b4a router: router.id is optional 2025-02-11 20:54:52 +08:00
ginuerzh fc12e33786 router handler: add cache for sd service 2025-02-09 16:16:44 +08:00
ginuerzh 08ad260606 add entrypoint for router handler 2025-02-08 23:07:54 +08:00
ginuerzh 9cd03b6b59 update go.mod 2025-02-07 15:45:14 +08:00
ginuerzh 355c72477a add router handler & connector 2025-02-07 15:44:31 +08:00
ginuerzh 379d9a73ad service: add limiter.scope option 2025-01-20 23:22:58 +08:00
ginuerzh bf36bdc680 update go.mod 2025-01-10 21:48:05 +08:00
ginuerzh aa7528fc1e add reload for web api 2025-01-10 21:39:21 +08:00
ginuerzh e480039691 update go.mod 2025-01-08 23:12:56 +08:00
ginuerzh 489a762811 service graceful shutdown 2025-01-08 23:12:06 +08:00
ginuerzh eef3bd7f25 fix panic for stats wrapper 2024-12-27 20:42:20 +08:00
ginuerzh 06d993023f add traffic reset for stats 2024-12-24 20:11:22 +08:00
TimWhite 914a4622fd Update dialer.go 2024-12-24 17:22:13 +08:00
ginuerzh a5d4774682 add ip range matcher for bypass 2024-12-23 21:06:55 +08:00
ginuerzh be8e0555e5 handle tls for tunnel entrypoint 2024-12-23 21:05:55 +08:00
ginuerzh 2693ba61c4 forwarder: fix ip matcher for tls 2024-12-18 19:05:19 +08:00
ginuerzh fcb54bd576 fix observer for http2 2024-12-16 20:48:11 +08:00
dependabot[bot] 5206d1bd58 Bump golang.org/x/crypto from 0.30.0 to 0.31.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.30.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 19:09:33 +08:00
ginuerzh 98f5a5f7eb fix webtransport 2024-12-11 22:25:51 +08:00
zhangchunyun ff6807815f consider byte order for IPv6 original dst address 2024-12-11 22:00:03 +08:00
dependabot[bot] 8d962398ab Bump github.com/quic-go/quic-go from 0.45.0 to 0.48.2
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.45.0 to 0.48.2.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Changelog](https://github.com/quic-go/quic-go/blob/master/Changelog.md)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.45.0...v0.48.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-11 21:57:24 +08:00
ginuerzh c1e6164a83 add unix domain socket support for api & metrics services 2024-11-22 22:49:10 +08:00
ginuerzh 6f001a779a tunnel: fix tunnel routing for public entrypoint 2024-11-22 22:04:44 +08:00
ginuerzh 2e8a5d6f51 fix http keepalive for forwarding 2024-11-17 16:16:20 +08:00
ginuerzh c2aedbabd9 add routing rule for node 2024-11-15 20:28:47 +08:00
ginuerzh 79b6b9138e add limiterRefreshInterval option for limiter 2024-11-14 20:16:09 +08:00
ginuerzh 3db20563d2 add custom http response header for reverse proxy 2024-11-07 11:04:42 +08:00
ginuerzh a12870e766 add compression flag option for http request 2024-11-02 20:41:23 +08:00
ginuerzh edb9a992e2 http: fix http/1.0 connection close 2024-11-01 18:17:50 +08:00
ginuerzh f4d9a5f79b tunnel: checking closed connector 2024-11-01 15:25:45 +08:00
ginuerzh 88ba01b3dd add redirect for handler recorder object 2024-10-31 19:14:21 +08:00
ginuerzh d8126e8e30 update go.mod 2024-10-22 22:54:04 +08:00
ginuerzh a97ac1f30a fix http body recorder 2024-10-22 22:51:33 +08:00
ginuerzh 3b4fd643de fix udp port forwarding 2024-10-22 22:50:40 +08:00
ginuerzh 3ca253537b fix race condition for sniffing 2024-10-20 19:19:16 +08:00
ginuerzh 41f527aa46 sniffing: full websocket frame recording 2024-10-19 20:40:07 +08:00
ginuerzh 6b932e35bf sniffing websocket frame 2024-10-19 19:36:06 +08:00
ginuerzh a0cbee8817 use http.transport for non-connect http tunnel 2024-10-17 21:57:20 +08:00
ginuerzh 7e51404ae5 fix handler sniffing 2024-10-16 23:01:12 +08:00
ginuerzh 618d042001 add input/ouput fields for log 2024-10-16 22:16:34 +08:00
ginuerzh 24547b4332 add ClientAddr for websocket conn 2024-10-16 20:46:45 +08:00
ginuerzh ea179a0ee6 tunnel: fix http switching protocols 2024-10-15 19:36:20 +08:00
ginuerzh 51ca9f620a add transport for tunnel entrypoint 2024-10-15 18:47:36 +08:00
ginuerzh 5bba004324 add keepalive option for tunnel entrypoint 2024-10-14 23:09:07 +08:00
ginuerzh 0ffed81601 add sniffer for forwarding handler 2024-10-14 21:18:38 +08:00
ginuerzh 27242d0b66 recorder: add input/output traffic stats for handler recorder object 2024-10-11 20:32:56 +08:00
ginuerzh 8f994ab632 add handle options for sniffer 2024-10-10 22:49:56 +08:00
ginuerzh 9a56c8fba3 add keepalive option for http handler 2024-10-10 16:52:01 +08:00
ginuerzh c42a44abb6 add sniffer utility 2024-10-02 22:51:23 +08:00
ginuerzh 3c8add4b82 add traffic sniffing for handler 2024-09-27 15:55:28 +08:00
ginuerzh dfb6cb95d0 add username for redis 2024-09-25 22:05:19 +08:00
ginuerzh e22aa91571 add route for recorder 2024-09-24 20:20:15 +08:00
ginuerzh dd179bc951 bypass: wildcard matcher for ip 2024-09-24 20:19:48 +08:00
ginuerzh 8934cb6b1c add tls handshake for recorder 2024-09-20 21:26:21 +08:00
ginuerzh e09ec08881 add sid for connector log 2024-09-20 18:20:57 +08:00
ginuerzh e37213ac01 add http body for handler recorder 2024-09-16 19:44:15 +08:00
ginuerzh a618c5556e fix tls host for handler recorder 2024-09-15 21:12:10 +08:00
ginuerzh ce2da421fa fix recorder clientIP 2024-09-15 18:37:49 +08:00
ginuerzh b39aa63f75 add clientIP for handler recorder object 2024-09-15 18:28:24 +08:00
ginuerzh f681f7aa03 update handler recorder object 2024-09-15 10:51:23 +08:00
ginuerzh 69455ace9d add handler recorder 2024-09-14 23:21:25 +08:00
ginuerzh e7581f0a9e add sniffing.fallback option for red handler 2024-09-04 22:44:05 +08:00
ginuerzh 31c482c649 add scope param for traffic limiter 2024-08-31 09:30:04 +08:00
ginuerzh 9405d1fb48 Merge branch 'master' of github.com:go-gost/x 2024-08-31 09:21:14 +08:00
yorks 5a19a33f51 sshd:// add read env SSH_AUTH_SOCK for ssh-agent auth 2024-08-30 20:04:56 +08:00
ginuerzh ff20711c25 add stats support for tunnel handler 2024-08-28 13:34:05 +08:00
ginuerzh bc0d6953bc fix timeout in router 2024-08-06 21:11:27 +08:00
ginuerzh 22e522e933 fix udp connection timeout 2024-08-06 18:33:29 +08:00
ginuerzh 5e8a8a4b4d compatible with HTTP/1.0 2024-08-06 18:31:29 +08:00
ginuerzh 1a776dc759 fix connection state in tunnel entrypoint 2024-08-01 20:52:08 +08:00
ginuerzh 12ef82e41f fix ssu handler port exhaustion 2024-07-31 20:55:24 +08:00
ginuerzh 3656ba9315 add http body rewrite for forward handler 2024-07-19 20:45:04 +08:00
ginuerzh c0a80400d2 add support for icmpv6 2024-07-15 20:34:59 +08:00
705 changed files with 92973 additions and 6644 deletions
+139
View File
@@ -0,0 +1,139 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this directory.
## Build & Verify
```bash
cd x && go build ./... # verify compilation
cd x && go vet ./... # static analysis
cd x && go build ./... && go vet ./... # both
```
There are no tests in this module. Build + vet is the verification path.
## Architecture
`x/` is the **implementation layer** for the GOST proxy framework. Every interface defined in `core/` is implemented here. The module ties the whole system together: it provides ~25 handler implementations, ~25 listener implementations, ~20 dialer implementations, ~15 connector implementations, plus config parsing, registry management, and all cross-cutting concerns.
### Component pattern (applies to every handler, listener, dialer, connector)
Every component follows this exact pattern:
```go
// 1. init() self-registration via blank import
func init() {
registry.HandlerRegistry().Register("http", NewHandler)
}
// 2. Private struct holding options + parsed metadata
type httpHandler struct {
md metadata
options handler.Options
}
// 3. Functional options constructor
func NewHandler(opts ...handler.Option) handler.Handler {
options := handler.Options{}
for _, opt := range opts {
opt(&options)
}
return &httpHandler{options: options}
}
// 4. Init extracts typed values from generic metadata
func (h *httpHandler) Init(md md.Metadata) error {
return h.parseMetadata(md)
}
```
Each component package also defines a private `metadata` struct and a `parseMetadata(md)` method that uses `mdutil.GetBool/GetInt/GetString/GetDuration(md, keys...)` to extract configuration. Metadata keys are lowercased and multiple fallback keys are tried (e.g., `"observePeriod"`, `"observer.period"`, `"observer.observePeriod"`).
### Config parsing and service composition
`config/parsing/service/parse.go` is the critical wiring function. `ParseService(cfg)` does:
1. Defaults listener type to `"tcp"`, handler type to `"auto"`
2. Sets up TLS config, network namespace, authentication, admission, bypass
3. Looks up the listener and handler factories from their registries, calls them with composed options
4. Creates a `service.Service` (which runs the accept loop calling `handler.Handle` per connection)
5. Wraps the listener in order: proxyproto → metrics → stats → admission → traffic limiter → connection limiter
Each config sub-package (`config/parsing/{auth,bypass,admission,chain,hop,limiter,...}/`) has a `parse.go` that converts config structs into registry lookups and constructed objects.
### Metadata system (`metadata/`)
`metadata.NewMetadata(map[string]any)` wraps a raw config map. All keys are lowercased on lookup. The `mdutil` package (`metadata/util/`) provides typed accessors used everywhere:
- `mdutil.GetBool(md, keys...)`, `GetInt`, `GetFloat`, `GetString`, `GetStrings`
- `mdutil.GetDuration(md, keys...)` — int values treated as seconds; strings parsed with `time.ParseDuration`
- `mdutil.GetStringMap(md, keys...)`, `GetStringMapString`
Metadata key constants are defined in `config/parsing/parse.go` (e.g., `MDKeyProxyProtocol`, `MDKeySoMark`, `MDKeyInterface`, `MDKeyEnableStats`).
### Internal packages (`internal/`)
**Do not import from external modules.** Go's `internal/` restriction enforces this. Key internal packages:
| Package | Purpose |
|---------|---------|
| `internal/ctx/` | Context values for session state during requests |
| `internal/io/` | Custom IO readers/writers |
| `internal/loader/` | `Loader`, `Lister`, `Mapper` interfaces for hot-reloadable components |
| `internal/matcher/` | Pattern matching utilities |
| `internal/net/` | Network utilities (resolver, dialer, proxyproto, HTTP helpers, UDP listener) |
| `internal/plugin/` | External plugin process management |
| `internal/util/sniffing/` | Protocol sniffing (TLS, HTTP, WebSocket) |
| `internal/util/stats/` | Connection stats tracking |
| `internal/util/tls/` | TLS config loading, cert management |
| `internal/util/ws/` | WebSocket utilities |
### Context propagation (`ctx/`)
`x/ctx/value.go` provides typed context key/value helpers used to pass per-request data through the handler chain:
- `ContextWithSid` / `SidFromContext` — Session ID
- `ContextWithSrcAddr` / `SrcAddrFromContext` — Source address
- `ContextWithDstAddr` / `DstAddrFromContext` — Destination address
- `ContextWithClientID` / `ClientIDFromContext` — Client ID (for hash-based load balancing)
- `ContextWithHash` / `HashFromContext` — Hash source for selector
### Hot-reloadable components
Auth, bypass, admission, hosts, ingress, limiter, recorder, router, and SD components all support periodic reload from file/redis/http sources. The pattern:
1. Options include `fileLoader`, `redisLoader`, `httpLoader` plus a `period` duration
2. A background goroutine runs `periodReload(ctx)` calling `loader.Load()` on each tick
3. The component swaps its internal data structure (e.g., `ipMatcher`, `hostMatcher`) atomically
### Listener wrapper ordering
Listeners wrap `net.Listener` in a fixed order. Getting this wrong breaks metrics/admission/limiting:
```
proxyproto → metrics → stats → admission → traffic_limiter → conn_limiter → (protocol-specific)
```
### Registry
All registries are in `x/registry/registry.go` — a single file with ~25 typed global singletons backed by `sync.Map`. Each is accessed via an exported function (e.g., `registry.HandlerRegistry()`, `registry.ListenerRegistry()`). Registration returns `ErrDup` on name collision; empty names are silently ignored.
### Selectors (`selector/`)
Load-balancing strategies for hops and chain groups:
| Strategy | Behavior |
|----------|----------|
| `RoundRobinStrategy` | Atomic counter, modulo selection |
| `RandomStrategy` | Weighted random using `RandomWeighted` |
| `FIFOStrategy` | Always first element (stickiness) |
| `HashStrategy` | CRC32 of client ID or hash context value |
Filters (`FailFilter`, `BackupFilter`) are applied before the strategy, reducing the candidate pool.
### API (`api/`)
Uses Gin framework. Registers CRUD endpoints under `/config` for all component types. Supports basic auth when an auther is configured. Swagger docs at `/docs`.
### Metrics (`metrics/`)
Prometheus-style metrics following `_total`, `_seconds` naming conventions. Constants for all metric names (e.g., `MetricServiceRequestsCounter`, `MetricNodeConnectDurationObserver`). `Enable(bool)` toggles globally. When disabled, `GetCounter/Gauge/Observer` return noop implementations.
+33 -1
View File
@@ -1 +1,33 @@
# The extended (may be experimental) features outside the main gost tree.
# x
The implementation layer for the GOST proxy framework. Every interface defined in [core/](../core/) is implemented here — handlers, listeners, dialers, connectors, plus config parsing, registries, and all cross-cutting concerns.
## Package layout
| Directory | Purpose |
|-----------|---------|
| `handler/` | Protocol handlers (http, socks4/5, ss, ssh, tunnel, tun, dns, redirect, etc.) |
| `listener/` | Protocol listeners (tcp, tls, ws, http2/3, quic, kcp, icmp, tun, udp, etc.) |
| `dialer/` | Outbound dialers (tcp, tls, ws, http2/3, quic, grpc, ssh, wg, kcp, icmp, etc.) |
| `connector/` | Destination connectors (http, socks4/5, ss, ssh, relay, tunnel, direct, etc.) |
| `config/` | Config struct, YAML/JSON parsing, and service construction |
| `registry/` | Typed global registries for all component types |
| `service/` | Accept loop wiring listener + handler |
| `chain/` | Multi-hop forwarding chain and route implementation |
| `router/` | Route table router (destination-based routing) |
| `hop/` | Node group with load-balanced selection |
| `selector/` | Load-balancing strategies (round-robin, random, weighted, hash) |
| `auth/`, `bypass/`, `admission/` | Authentication, bypass rules, admission control |
| `resolver/`, `hosts/` | DNS resolution and host mapping |
| `limiter/` | Traffic, connection, and rate limiters |
| `recorder/`, `observer/` | Traffic recording and observability |
| `ingress/`, `sd/`, `routing/` | Ingress control, service discovery, routing rules |
| `logger/`, `metrics/`, `api/` | Logging, Prometheus metrics, Web API |
| `metadata/`, `ctx/` | Metadata key-value system and context propagation |
| `internal/` | Shared internals — not importable externally |
## Component pattern
Every handler, listener, dialer, and connector follows the same pattern: `init()` registers a constructor into a global registry, the constructor takes functional options, and `Init(metadata.Metadata)` extracts typed configuration from the metadata key-value map.
See [CLAUDE.md](CLAUDE.md) for detailed architecture and conventions.
+167 -31
View File
@@ -1,3 +1,20 @@
// Package admission implements access control for incoming connections.
// Before a connection is handled, the admission controller checks whether
// the client's address is allowed to use the service.
//
// The package provides:
// - NewAdmission: a local admission controller backed by IP/CIDR matchers,
// with optional periodic reload from file, Redis, or HTTP sources.
// - AdmissionGroup: composes multiple admission controllers; all must
// admit for the connection to proceed.
// - Plugin-based admission (gRPC and HTTP) in the plugin sub-package.
// - Connection wrappers in the wrapper sub-package that enforce
// admission checks per-read (for TCP) and per-packet (for UDP).
//
// Matching modes:
// - Whitelist (whitelist=true): only addresses matching a rule are admitted.
// - Blacklist (whitelist=false): addresses matching a rule are denied;
// everything else is admitted. This is the default.
package admission
import (
@@ -13,95 +30,160 @@ import (
"github.com/go-gost/core/logger"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/matcher"
xlogger "github.com/go-gost/x/logger"
)
// options holds the configuration for a localAdmission instance.
type options struct {
whitelist bool
matchers []string
fileLoader loader.Loader
// whitelist toggles between blacklist and whitelist mode.
// When false (default): matching addresses are denied.
// When true: only matching addresses are allowed.
whitelist bool
// matchers holds static patterns provided at construction time
// (e.g. from config file or command-line arguments).
matchers []string
// fileLoader loads patterns from a file or directory.
// Supports hot-reload via file watching if the loader implements Lister.
fileLoader loader.Loader
// redisLoader loads patterns from a Redis set or key.
// Supports hot-reload via periodic polling if the loader implements Lister.
redisLoader loader.Loader
httpLoader loader.Loader
period time.Duration
logger logger.Logger
// httpLoader loads patterns from an HTTP endpoint.
// Supports hot-reload via periodic polling.
httpLoader loader.Loader
// period controls the interval between automatic reloads.
// Values less than 1 second are clamped to 1 second.
// A value <= 0 disables periodic reload (load once at startup).
period time.Duration
// logger is used for debug and warning messages.
// Falls back to a no-op logger if nil.
logger logger.Logger
}
// Option is a functional option for configuring an admission controller.
type Option func(opts *options)
// WhitelistOption sets whether the admission controller operates in
// whitelist mode. In whitelist mode, only addresses matching one or more
// rules are admitted; all others are denied.
func WhitelistOption(whitelist bool) Option {
return func(opts *options) {
opts.whitelist = whitelist
}
}
// MatchersOption sets static match patterns that are combined with
// dynamically loaded patterns on each reload. Patterns may be:
// - Bare IP addresses: "192.168.1.1", "::1"
// - CIDR networks: "10.0.0.0/8", "fd00::/8"
// - Hostnames: resolved to IP addresses via DNS
func MatchersOption(matchers []string) Option {
return func(opts *options) {
opts.matchers = matchers
}
}
// ReloadPeriodOption sets the interval for automatically reloading
// patterns from external loaders (file, Redis, HTTP). A value <= 0
// disables periodic reloading.
func ReloadPeriodOption(period time.Duration) Option {
return func(opts *options) {
opts.period = period
}
}
// FileLoaderOption sets the file-based pattern loader.
func FileLoaderOption(fileLoader loader.Loader) Option {
return func(opts *options) {
opts.fileLoader = fileLoader
}
}
// RedisLoaderOption sets the Redis-based pattern loader.
func RedisLoaderOption(redisLoader loader.Loader) Option {
return func(opts *options) {
opts.redisLoader = redisLoader
}
}
// HTTPLoaderOption sets the HTTP-based pattern loader.
func HTTPLoaderOption(httpLoader loader.Loader) Option {
return func(opts *options) {
opts.httpLoader = httpLoader
}
}
// LoggerOption sets the logger for the admission controller.
func LoggerOption(logger logger.Logger) Option {
return func(opts *options) {
opts.logger = logger
}
}
// localAdmission is the in-process admission controller. It maintains
// two matchers — one for individual IP addresses and one for CIDR
// networks — and checks incoming addresses against them.
//
// The matchers are rebuilt atomically on each reload: new patterns
// are loaded, parsed into IPs and CIDRs, and the matchers are swapped
// under a write lock.
type localAdmission struct {
ipMatcher matcher.Matcher
cidrMatcher matcher.Matcher
mu sync.RWMutex
ipMatcher matcher.Matcher // matches individual IP addresses
cidrMatcher matcher.Matcher // matches CIDR network ranges
mu sync.RWMutex // protects ipMatcher and cidrMatcher
cancelFunc context.CancelFunc
options options
logger logger.Logger
}
// NewAdmission creates and initializes a new Admission using matcher patterns as its match rules.
// The rules will be reversed if the reverse is true.
// NewAdmission creates a local admission controller with the given options.
// It starts a background goroutine that periodically reloads patterns
// from configured external sources (file, Redis, HTTP).
//
// By default the controller operates in blacklist mode: any address
// matching the loaded patterns is denied. Use WhitelistOption(true)
// to invert this.
func NewAdmission(opts ...Option) admission.Admission {
var options options
for _, opt := range opts {
opt(&options)
}
ctx, cancel := context.WithCancel(context.TODO())
ctx, cancel := context.WithCancel(context.Background())
p := &localAdmission{
cancelFunc: cancel,
options: options,
ipMatcher: matcher.NopMatcher(),
cidrMatcher: matcher.NopMatcher(),
cancelFunc: cancel,
options: options,
logger: options.logger,
}
if p.logger == nil {
p.logger = xlogger.Nop()
}
if err := p.reload(ctx); err != nil {
options.logger.Warnf("reload: %v", err)
}
if p.options.period > 0 {
go p.periodReload(ctx)
}
go p.periodReload(ctx)
return p
}
func (p *localAdmission) Admit(ctx context.Context, addr string, opts ...admission.Option) bool {
// Admit decides whether the given network address is allowed.
// It returns true if the address should be accepted, false otherwise.
//
// The address is first stripped of its port (if present), then matched
// against the IP and CIDR matchers. The whitelist/blacklist mode
// determines how the match result is interpreted:
//
// Blacklist (whitelist=false): admit if NOT matched
// Whitelist (whitelist=true): admit only if matched
//
// An empty address or a nil receiver always returns true.
func (p *localAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
if addr == "" || p == nil {
return true
}
@@ -117,13 +199,22 @@ func (p *localAdmission) Admit(ctx context.Context, addr string, opts ...admissi
p.options.whitelist && matched
if !b {
p.options.logger.Debugf("%s is denied", addr)
p.logger.Debugf("%s is denied", addr)
}
return b
}
// periodReload triggers an immediate reload, then — if a positive period
// is configured — reloads on each tick. It exits when ctx is cancelled.
func (p *localAdmission) periodReload(ctx context.Context) error {
if err := p.reload(ctx); err != nil {
p.logger.Warnf("reload: %v", err)
}
period := p.options.period
if period <= 0 {
return nil
}
if period < time.Second {
period = time.Second
}
@@ -134,8 +225,7 @@ func (p *localAdmission) periodReload(ctx context.Context) error {
select {
case <-ticker.C:
if err := p.reload(ctx); err != nil {
p.options.logger.Warnf("reload: %v", err)
// return err
p.logger.Warnf("reload: %v", err)
}
case <-ctx.Done():
return ctx.Err()
@@ -143,6 +233,8 @@ func (p *localAdmission) periodReload(ctx context.Context) error {
}
}
// reload loads patterns from all configured sources, parses them into
// IP addresses and CIDR networks, and atomically swaps the matchers.
func (p *localAdmission) reload(ctx context.Context) error {
v, err := p.load(ctx)
if err != nil {
@@ -161,8 +253,9 @@ func (p *localAdmission) reload(ctx context.Context) error {
inets = append(inets, inet)
continue
}
// Try DNS resolution as a last resort.
if ipAddr, _ := net.ResolveIPAddr("ip", pattern); ipAddr != nil {
p.options.logger.Debugf("resolve IP: %s -> %s", pattern, ipAddr)
p.logger.Debugf("resolve IP: %s -> %s", pattern, ipAddr)
ips = append(ips, ipAddr.IP)
}
}
@@ -176,12 +269,15 @@ func (p *localAdmission) reload(ctx context.Context) error {
return nil
}
// load collects patterns from all configured external loaders
// (file, Redis, HTTP). It prefers the Lister interface when available,
// falling back to loading raw content via Load and parsing line-by-line.
func (p *localAdmission) load(ctx context.Context) (patterns []string, err error) {
if p.options.fileLoader != nil {
if lister, ok := p.options.fileLoader.(loader.Lister); ok {
list, er := lister.List(ctx)
if er != nil {
p.options.logger.Warnf("file loader: %v", er)
p.logger.Warnf("file loader: %v", er)
}
for _, s := range list {
if line := p.parseLine(s); line != "" {
@@ -191,7 +287,7 @@ func (p *localAdmission) load(ctx context.Context) (patterns []string, err error
} else {
r, er := p.options.fileLoader.Load(ctx)
if er != nil {
p.options.logger.Warnf("file loader: %v", er)
p.logger.Warnf("file loader: %v", er)
}
if v, _ := p.parsePatterns(r); v != nil {
patterns = append(patterns, v...)
@@ -202,13 +298,13 @@ func (p *localAdmission) load(ctx context.Context) (patterns []string, err error
if lister, ok := p.options.redisLoader.(loader.Lister); ok {
list, er := lister.List(ctx)
if er != nil {
p.options.logger.Warnf("redis loader: %v", er)
p.logger.Warnf("redis loader: %v", er)
}
patterns = append(patterns, list...)
} else {
r, er := p.options.redisLoader.Load(ctx)
if er != nil {
p.options.logger.Warnf("redis loader: %v", er)
p.logger.Warnf("redis loader: %v", er)
}
if v, _ := p.parsePatterns(r); v != nil {
patterns = append(patterns, v...)
@@ -219,17 +315,19 @@ func (p *localAdmission) load(ctx context.Context) (patterns []string, err error
if p.options.httpLoader != nil {
r, er := p.options.httpLoader.Load(ctx)
if er != nil {
p.options.logger.Warnf("http loader: %v", er)
p.logger.Warnf("http loader: %v", er)
}
if v, _ := p.parsePatterns(r); v != nil {
patterns = append(patterns, v...)
}
}
p.options.logger.Debugf("load items %d", len(patterns))
p.logger.Debugf("load items %d", len(patterns))
return
}
// parsePatterns reads a newline-delimited stream and returns a list of
// non-empty, trimmed lines after stripping comments.
func (p *localAdmission) parsePatterns(r io.Reader) (patterns []string, err error) {
if r == nil {
return
@@ -246,6 +344,9 @@ func (p *localAdmission) parsePatterns(r io.Reader) (patterns []string, err erro
return
}
// parseLine strips inline comments (everything after the first '#')
// and trims surrounding whitespace. It returns an empty string if
// the line was a comment-only or blank line.
func (p *localAdmission) parseLine(s string) string {
if n := strings.IndexByte(s, '#'); n >= 0 {
s = s[:n]
@@ -253,6 +354,8 @@ func (p *localAdmission) parseLine(s string) string {
return strings.TrimSpace(s)
}
// matched returns true if addr is matched by either the IP or CIDR
// matcher. Caller must hold at least a read lock.
func (p *localAdmission) matched(addr string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
@@ -261,6 +364,9 @@ func (p *localAdmission) matched(addr string) bool {
p.cidrMatcher.Match(addr)
}
// Close stops the background reload goroutine and closes all external
// loaders. It is safe to call after the admission controller is no
// longer needed.
func (p *localAdmission) Close() error {
p.cancelFunc()
if p.options.fileLoader != nil {
@@ -269,5 +375,35 @@ func (p *localAdmission) Close() error {
if p.options.redisLoader != nil {
p.options.redisLoader.Close()
}
if p.options.httpLoader != nil {
p.options.httpLoader.Close()
}
return nil
}
// admissionGroup composes multiple admission controllers into one.
// All controllers in the group must admit for the overall result to
// be true (logical AND).
type admissionGroup struct {
admissions []admission.Admission
}
// AdmissionGroup creates a composite admission controller that requires
// every member to admit the address. If any member denies, the address
// is denied immediately (short-circuit evaluation).
//
// Nil members are skipped.
func AdmissionGroup(admissions ...admission.Admission) admission.Admission {
return &admissionGroup{
admissions: admissions,
}
}
func (p *admissionGroup) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
for _, admission := range p.admissions {
if admission != nil && !admission.Admit(ctx, network, addr, opts...) {
return false
}
}
return true
}
+727
View File
@@ -0,0 +1,727 @@
package admission
import (
"context"
"io"
"strings"
"testing"
"time"
"github.com/go-gost/core/admission"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/internal/loader"
xlogger "github.com/go-gost/x/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Option tests ---
func TestWhitelistOption(t *testing.T) {
var opts options
WhitelistOption(true)(&opts)
assert.True(t, opts.whitelist)
WhitelistOption(false)(&opts)
assert.False(t, opts.whitelist)
}
func TestMatchersOption(t *testing.T) {
var opts options
MatchersOption([]string{"192.168.1.1", "10.0.0.0/8"})(&opts)
assert.Equal(t, []string{"192.168.1.1", "10.0.0.0/8"}, opts.matchers)
}
func TestReloadPeriodOption(t *testing.T) {
var opts options
ReloadPeriodOption(5 * time.Second)(&opts)
assert.Equal(t, 5*time.Second, opts.period)
}
func TestFileLoaderOption(t *testing.T) {
var opts options
fl := &mockLoader{}
FileLoaderOption(fl)(&opts)
assert.Equal(t, fl, opts.fileLoader)
}
func TestRedisLoaderOption(t *testing.T) {
var opts options
rl := &mockLoader{}
RedisLoaderOption(rl)(&opts)
assert.Equal(t, rl, opts.redisLoader)
}
func TestHTTPLoaderOption(t *testing.T) {
var opts options
hl := &mockLoader{}
HTTPLoaderOption(hl)(&opts)
assert.Equal(t, hl, opts.httpLoader)
}
func TestLoggerOption(t *testing.T) {
var opts options
l := xlogger.Nop()
LoggerOption(l)(&opts)
assert.Equal(t, l, opts.logger)
}
// newSyncedAdmission creates an admission controller and blocks until the initial
// background reload completes, so matchers are ready for immediate assertions.
func newSyncedAdmission(opts ...Option) *localAdmission {
a := NewAdmission(opts...)
la := a.(*localAdmission)
// Force a synchronous reload so tests don't race with the background goroutine.
la.reload(context.Background())
return la
}
// --- NewAdmission tests ---
func TestNewAdmission_Defaults(t *testing.T) {
adm := newSyncedAdmission()
require.NotNil(t, adm)
defer adm.Close()
// Should admit by default (blacklist mode with no rules)
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestNewAdmission_WithLogger(t *testing.T) {
adm := newSyncedAdmission(LoggerOption(xlogger.Nop()))
require.NotNil(t, adm)
defer adm.Close()
}
// --- Admit tests ---
func TestAdmit_EmptyAddr(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
defer adm.Close()
assert.True(t, adm.Admit(context.Background(), "tcp", ""))
}
func TestAdmit_NilReceiver(t *testing.T) {
var p *localAdmission
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmit_Blacklist_Matched(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
defer adm.Close()
// In blacklist mode, matching IP is denied
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmit_Blacklist_NotMatched(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
defer adm.Close()
// In blacklist mode, non-matching IP is admitted
assert.True(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
}
func TestAdmit_Whitelist_Matched(t *testing.T) {
adm := newSyncedAdmission(
WhitelistOption(true),
MatchersOption([]string{"192.168.1.1"}),
)
defer adm.Close()
// In whitelist mode, matching IP is admitted
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmit_Whitelist_NotMatched(t *testing.T) {
adm := newSyncedAdmission(
WhitelistOption(true),
MatchersOption([]string{"192.168.1.1"}),
)
defer adm.Close()
// In whitelist mode, non-matching IP is denied
assert.False(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
}
func TestAdmit_StripsPort(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
defer adm.Close()
// Address with port should have port stripped before matching
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1:8080"))
}
func TestAdmit_CIDRMatch(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"10.0.0.0/8"}))
defer adm.Close()
assert.False(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmit_InvalidAddr(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
defer adm.Close()
// Invalid IP should not match and be admitted in blacklist mode
assert.True(t, adm.Admit(context.Background(), "tcp", "invalid"))
}
// --- parseLine tests ---
func TestParseLine_Normal(t *testing.T) {
adm := &localAdmission{}
assert.Equal(t, "192.168.1.1", adm.parseLine("192.168.1.1"))
}
func TestParseLine_WithComment(t *testing.T) {
adm := &localAdmission{}
assert.Equal(t, "192.168.1.1", adm.parseLine("192.168.1.1 # a comment"))
}
func TestParseLine_CommentOnly(t *testing.T) {
adm := &localAdmission{}
assert.Equal(t, "", adm.parseLine("# comment only"))
}
func TestParseLine_Empty(t *testing.T) {
adm := &localAdmission{}
assert.Equal(t, "", adm.parseLine(""))
}
func TestParseLine_Whitespace(t *testing.T) {
adm := &localAdmission{}
assert.Equal(t, "192.168.1.1", adm.parseLine(" 192.168.1.1 "))
}
// --- parsePatterns tests ---
func TestParsePatterns_NilReader(t *testing.T) {
adm := &localAdmission{}
patterns, err := adm.parsePatterns(nil)
assert.NoError(t, err)
assert.Nil(t, patterns)
}
func TestParsePatterns_Normal(t *testing.T) {
adm := &localAdmission{}
r := strings.NewReader("192.168.1.1\n10.0.0.1\n# comment\n\n fd00::1 \n")
patterns, err := adm.parsePatterns(r)
assert.NoError(t, err)
assert.Equal(t, []string{"192.168.1.1", "10.0.0.1", "fd00::1"}, patterns)
}
// --- matched tests ---
func TestMatched_IP(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
defer adm.Close()
assert.True(t, adm.matched("192.168.1.1"))
assert.False(t, adm.matched("10.0.0.1"))
}
func TestMatched_CIDR(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"10.0.0.0/8"}))
defer adm.Close()
assert.True(t, adm.matched("10.0.0.1"))
assert.False(t, adm.matched("192.168.1.1"))
}
func TestMatched_BothIPAndCIDR(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1", "10.0.0.0/8"}))
defer adm.Close()
assert.True(t, adm.matched("192.168.1.1"))
assert.True(t, adm.matched("10.0.0.1"))
assert.False(t, adm.matched("172.16.0.1"))
}
// --- reload tests ---
func TestReload_WithHostnameResolution(t *testing.T) {
adm := newSyncedAdmission(
MatchersOption([]string{"localhost"}),
LoggerOption(xlogger.Nop()),
)
defer adm.Close()
// After reload, localhost should be resolved to 127.0.0.1 or ::1
err := adm.reload(context.Background())
assert.NoError(t, err)
}
// --- load tests ---
func TestLoad_AllNilLoaders(t *testing.T) {
adm := &localAdmission{
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Nil(t, patterns)
}
func TestLoad_FileLoaderWithList(t *testing.T) {
adm := &localAdmission{
options: options{
fileLoader: &mockListerLoader{list: []string{"192.168.1.1", "# comment", "10.0.0.1"}},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"192.168.1.1", "10.0.0.1"}, patterns)
}
func TestLoad_FileLoaderWithLoad(t *testing.T) {
adm := &localAdmission{
options: options{
fileLoader: &mockLoader{data: "192.168.1.1\n10.0.0.1\n"},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"192.168.1.1", "10.0.0.1"}, patterns)
}
func TestLoad_RedisLoaderWithList(t *testing.T) {
adm := &localAdmission{
options: options{
redisLoader: &mockListerLoader{list: []string{"redis-host-1", "redis-host-2"}},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"redis-host-1", "redis-host-2"}, patterns)
}
func TestLoad_RedisLoaderWithLoad(t *testing.T) {
adm := &localAdmission{
options: options{
redisLoader: &mockLoader{data: "192.168.2.1\n"},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"192.168.2.1"}, patterns)
}
func TestLoad_HTTPLoader(t *testing.T) {
adm := &localAdmission{
options: options{
httpLoader: &mockLoader{data: "10.10.10.1\n10.10.10.2\n"},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"10.10.10.1", "10.10.10.2"}, patterns)
}
func TestLoad_FileLoaderWithListError(t *testing.T) {
adm := &localAdmission{
options: options{
fileLoader: &mockListerLoader{listErr: io.ErrUnexpectedEOF},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Nil(t, patterns)
}
func TestLoad_FileLoaderWithLoadError(t *testing.T) {
adm := &localAdmission{
options: options{
fileLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Nil(t, patterns)
}
func TestLoad_RedisLoaderWithListError(t *testing.T) {
adm := &localAdmission{
options: options{
redisLoader: &mockListerLoader{listErr: io.ErrUnexpectedEOF},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Nil(t, patterns)
}
func TestLoad_RedisLoaderWithLoadError(t *testing.T) {
adm := &localAdmission{
options: options{
redisLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Nil(t, patterns)
}
func TestLoad_HTTPLoaderError(t *testing.T) {
adm := &localAdmission{
options: options{
httpLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Nil(t, patterns)
}
func TestLoad_FileLoaderWithListParsing(t *testing.T) {
adm := &localAdmission{
options: options{
fileLoader: &mockListerLoader{list: []string{" # comment", "", " 192.168.1.1 "}},
},
logger: xlogger.Nop(),
}
patterns, err := adm.load(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"192.168.1.1"}, patterns)
}
// --- periodReload tests ---
func TestPeriodReload_ZeroPeriod(t *testing.T) {
adm := &localAdmission{
logger: xlogger.Nop(),
options: options{period: 0},
}
err := adm.periodReload(context.Background())
assert.NoError(t, err)
}
func TestPeriodReload_NegativePeriod(t *testing.T) {
adm := &localAdmission{
logger: xlogger.Nop(),
options: options{period: -1},
}
err := adm.periodReload(context.Background())
assert.NoError(t, err)
}
func TestPeriodReload_WithContextCancel(t *testing.T) {
// 500ms < 1s triggers the clamping branch (period = time.Second),
// then sleep > 1s to let at least one ticker cycle fire before cancel.
adm := &localAdmission{
logger: xlogger.Nop(),
options: options{period: 500 * time.Millisecond},
}
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- adm.periodReload(ctx)
}()
time.Sleep(1100 * time.Millisecond)
cancel()
select {
case err := <-errCh:
assert.Equal(t, context.Canceled, err)
case <-time.After(3 * time.Second):
t.Fatal("periodReload did not return after context cancel")
}
}
// --- Close tests ---
func TestClose_NoLoaders(t *testing.T) {
adm := newSyncedAdmission()
err := adm.Close()
assert.NoError(t, err)
}
func TestClose_WithAllLoaders(t *testing.T) {
fl := &mockLoader{}
rl := &mockLoader{}
hl := &mockLoader{}
adm := newSyncedAdmission(
FileLoaderOption(fl),
RedisLoaderOption(rl),
HTTPLoaderOption(hl),
)
err := adm.Close()
assert.NoError(t, err)
assert.True(t, fl.closed)
assert.True(t, rl.closed)
assert.True(t, hl.closed)
}
// --- DNS resolution in reload ---
func TestReload_DNSResolution(t *testing.T) {
adm := &localAdmission{
ipMatcher: nil,
cidrMatcher: nil,
options: options{
matchers: []string{"localhost"},
},
logger: xlogger.Nop(),
}
err := adm.reload(context.Background())
assert.NoError(t, err)
// localhost should resolve to 127.0.0.1 (or ::1)
assert.True(t, adm.matched("127.0.0.1") || adm.matched("::1"))
}
// --- AdmissionGroup tests ---
func TestAdmissionGroup_Empty(t *testing.T) {
g := AdmissionGroup()
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmissionGroup_AllAdmit(t *testing.T) {
g := AdmissionGroup(
alwaysAdmit{},
alwaysAdmit{},
)
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmissionGroup_OneDenies(t *testing.T) {
g := AdmissionGroup(
alwaysAdmit{},
alwaysDeny{},
)
assert.False(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmissionGroup_NilMember(t *testing.T) {
g := AdmissionGroup(
nil,
alwaysAdmit{},
nil,
)
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestAdmissionGroup_ShortCircuit(t *testing.T) {
// The first deny should short-circuit, never calling the second.
callCount := 0
tracking := &trackingAdmission{admit: false, callCount: &callCount}
g := AdmissionGroup(tracking, alwaysAdmit{})
assert.False(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
assert.Equal(t, 1, callCount)
}
// --- Mock types ---
type mockLoader struct {
data string
loadErr error
closed bool
}
func (m *mockLoader) Load(ctx context.Context) (io.Reader, error) {
if m.loadErr != nil {
return nil, m.loadErr
}
return strings.NewReader(m.data), nil
}
func (m *mockLoader) Close() error {
m.closed = true
return nil
}
type mockListerLoader struct {
list []string
listErr error
}
func (m *mockListerLoader) Load(ctx context.Context) (io.Reader, error) {
return strings.NewReader(strings.Join(m.list, "\n")), nil
}
func (m *mockListerLoader) List(ctx context.Context) ([]string, error) {
if m.listErr != nil {
return nil, m.listErr
}
return m.list, nil
}
func (m *mockListerLoader) Close() error {
return nil
}
type alwaysAdmit struct{}
func (alwaysAdmit) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
return true
}
type alwaysDeny struct{}
func (alwaysDeny) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
return false
}
type trackingAdmission struct {
admit bool
callCount *int
}
func (t *trackingAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
*t.callCount++
return t.admit
}
// Ensure mock types implement the interfaces.
var _ loader.Loader = (*mockLoader)(nil)
var _ loader.Lister = (*mockListerLoader)(nil)
var _ loader.Loader = (*mockListerLoader)(nil)
var _ admission.Admission = alwaysAdmit{}
var _ admission.Admission = alwaysDeny{}
// TestAdmit_IPv6 tests that IPv6 addresses are handled correctly.
func TestAdmit_IPv6(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"fd00::1"}))
defer adm.Close()
assert.False(t, adm.Admit(context.Background(), "tcp", "fd00::1"))
assert.True(t, adm.Admit(context.Background(), "tcp", "fd00::2"))
}
// TestAdmit_IPv6WithPort tests that IPv6 addresses with port brackets are handled.
func TestAdmit_IPv6WithPort(t *testing.T) {
adm := newSyncedAdmission(MatchersOption([]string{"::1"}))
defer adm.Close()
// net.SplitHostPort handles [::1]:8080 format
assert.False(t, adm.Admit(context.Background(), "tcp", "[::1]:8080"))
}
// TestNewAdmission_ReloadErrorInBackground verifies that the initial reload
// in the background goroutine doesn't panic, even if it fails.
func TestNewAdmission_ReloadError(t *testing.T) {
fl := &mockLoader{loadErr: io.ErrUnexpectedEOF}
adm := newSyncedAdmission(
FileLoaderOption(fl),
LoggerOption(xlogger.Nop()),
)
defer adm.Close()
// Should still work, just logged a warning
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
}
// TestAdmissionGroup_NilMemberFirst tests nil as first member.
func TestAdmissionGroup_NilMemberFirst(t *testing.T) {
g := AdmissionGroup(nil, alwaysDeny{})
assert.False(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
}
// TestAdmissionGroup_AllNil tests all nil members.
func TestAdmissionGroup_AllNil(t *testing.T) {
g := AdmissionGroup(nil, nil)
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
}
// TestLoggerDefault tests that default logger is set when nil is passed.
func TestLoggerOption_Nil(t *testing.T) {
var opts options
LoggerOption(nil)(&opts)
assert.Nil(t, opts.logger)
}
// TestAdmit_IpMatcher test case where only IP matcher is valid.
func TestReload_OnlyIPs(t *testing.T) {
adm := &localAdmission{
options: options{
matchers: []string{"1.1.1.1", "8.8.8.8"},
},
logger: xlogger.Nop(),
}
err := adm.reload(context.Background())
assert.NoError(t, err)
assert.True(t, adm.matched("1.1.1.1"))
assert.True(t, adm.matched("8.8.8.8"))
assert.False(t, adm.matched("9.9.9.9"))
}
// TestReload_OnlyCIDRs tests reload with only CIDR patterns.
func TestReload_OnlyCIDRs(t *testing.T) {
adm := &localAdmission{
options: options{
matchers: []string{"192.168.0.0/16", "10.0.0.0/8"},
},
logger: xlogger.Nop(),
}
err := adm.reload(context.Background())
assert.NoError(t, err)
assert.True(t, adm.matched("192.168.1.1"))
assert.True(t, adm.matched("10.0.0.1"))
assert.False(t, adm.matched("172.16.0.1"))
}
// TestAdmit_LoggerDebugOutput tests that debug logging happens on deny.
func TestAdmit_DenyLogs(t *testing.T) {
adm := newSyncedAdmission(
MatchersOption([]string{"192.168.1.1"}),
LoggerOption(xlogger.Nop()),
)
defer adm.Close()
// Should not panic; debug log is written
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
}
// TestAdmit_WhitelistDenyLogs tests logging for whitelist deny.
func TestAdmit_WhitelistDenyLogs(t *testing.T) {
adm := newSyncedAdmission(
WhitelistOption(true),
MatchersOption([]string{"192.168.1.1"}),
LoggerOption(xlogger.Nop()),
)
defer adm.Close()
assert.False(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
}
// TestParsePatterns_WithCommentsAndEmptyLines tests edge cases.
func TestParsePatterns_EdgeCases(t *testing.T) {
adm := &localAdmission{}
r := strings.NewReader("# full comment\n\n # indented comment\n192.168.1.1 # inline")
patterns, err := adm.parsePatterns(r)
assert.NoError(t, err)
assert.Equal(t, []string{"192.168.1.1"}, patterns)
}
func TestAdmit_NetworkIgnoredForMatching(t *testing.T) {
// Network parameter doesn't affect matching behavior
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
defer adm.Close()
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
assert.False(t, adm.Admit(context.Background(), "udp", "192.168.1.1"))
assert.False(t, adm.Admit(context.Background(), "ip4", "192.168.1.1"))
}
// Compile-time interface check
var _ logger.Logger = xlogger.Nop()
// Ensure admission.Admission is implemented
var _ admission.Admission = (*localAdmission)(nil)
+39 -4
View File
@@ -1,3 +1,9 @@
// Package admission implements gRPC-based admission plugins.
// An admission plugin delegates the admit/deny decision to an external
// service via gRPC. This allows admission logic to be implemented in
// a separate process using any language supported by protobuf.
//
// The gRPC service definition is in plugin/admission/proto.
package admission
import (
@@ -11,13 +17,27 @@ import (
"google.golang.org/grpc"
)
// grpcPlugin is an admission controller that delegates to an external
// gRPC admission service.
//
// If the gRPC connection cannot be established at construction time,
// the client field remains nil and Admit returns true (fail-open),
// allowing all traffic through rather than blocking everything.
type grpcPlugin struct {
conn grpc.ClientConnInterface
client proto.AdmissionClient
log logger.Logger
}
// NewGRPCPlugin creates an Admission plugin based on gRPC.
// NewGRPCPlugin creates an admission controller that communicates with
// an external gRPC admission service at the given address.
//
// The name is used only for log identification. The addr should be a
// gRPC target string (e.g. "127.0.0.1:9000"). Additional plugin options
// (TLS, token, retry) can be passed via opts.
//
// If the connection fails, the plugin logs the error and operates in
// fail-open mode: all admission requests return true.
func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) admission.Admission {
var options plugin.Options
for _, opt := range opts {
@@ -43,14 +63,28 @@ func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) admission.Ad
return p
}
func (p *grpcPlugin) Admit(ctx context.Context, addr string, opts ...admission.Option) bool {
// Admit sends an admission request to the external gRPC service.
// It passes the network, address, and service name from the client
// connection.
//
// If the gRPC client is nil (connection failed at startup), it returns
// true (fail-open). If the RPC returns an error, it logs the error and
// returns false.
func (p *grpcPlugin) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
if p.client == nil {
return false
return true
}
var options admission.Options
for _, opt := range opts {
opt(&options)
}
r, err := p.client.Admit(ctx,
&proto.AdmissionRequest{
Addr: addr,
Service: options.Service,
Network: network,
Addr: addr,
})
if err != nil {
p.log.Error(err)
@@ -59,6 +93,7 @@ func (p *grpcPlugin) Admit(ctx context.Context, addr string, opts ...admission.O
return r.Ok
}
// Close closes the underlying gRPC connection if it implements io.Closer.
func (p *grpcPlugin) Close() error {
if closer, ok := p.conn.(io.Closer); ok {
return closer.Close()
+199
View File
@@ -0,0 +1,199 @@
package admission
import (
"context"
"io"
"net"
"os"
"testing"
"github.com/go-gost/core/admission"
"github.com/go-gost/plugin/admission/proto"
"github.com/go-gost/x/internal/plugin"
xlogger "github.com/go-gost/x/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
corelogger "github.com/go-gost/core/logger"
)
func TestMain(m *testing.M) {
corelogger.SetDefault(xlogger.Nop())
os.Exit(m.Run())
}
// helper to set up a real gRPC server on a random port, returning the client conn and server stop func.
func newTestGRPCConn(t *testing.T, srv proto.AdmissionServer) (*grpc.ClientConn, func()) {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
gsrv := grpc.NewServer()
proto.RegisterAdmissionServer(gsrv, srv)
go gsrv.Serve(lis)
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
return conn, func() {
conn.Close()
gsrv.Stop()
}
}
func TestGRPCPlugin_FailOpen(t *testing.T) {
// Test fail-open: create directly with nil client to ensure nil path is covered.
p := &grpcPlugin{
conn: nil,
client: nil,
log: xlogger.Nop(),
}
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Admit_Success(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testAdmissionServer{admit: true})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAdmissionClient(conn),
log: xlogger.Nop(),
}
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestGRPCPlugin_Admit_Deny(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testAdmissionServer{admit: false})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAdmissionClient(conn),
log: xlogger.Nop(),
}
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestGRPCPlugin_Admit_WithService(t *testing.T) {
server := &testAdmissionServer{admit: true}
conn, cleanup := newTestGRPCConn(t, server)
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAdmissionClient(conn),
log: xlogger.Nop(),
}
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1", admission.WithService("myservice")))
assert.Equal(t, "myservice", server.lastService)
}
func TestGRPCPlugin_Admit_NilClient(t *testing.T) {
p := &grpcPlugin{
conn: nil,
client: nil,
}
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestGRPCPlugin_Close_NilConn(t *testing.T) {
p := &grpcPlugin{
conn: nil,
client: nil,
}
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Close_NonCloserConn(t *testing.T) {
p := &grpcPlugin{
conn: &nonCloserConn{},
client: nil,
}
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Close_WithRealConn(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testAdmissionServer{admit: true})
defer cleanup()
// conn is *grpc.ClientConn which implements io.Closer
p := &grpcPlugin{
conn: conn,
client: nil,
log: xlogger.Nop(),
}
assert.NoError(t, p.Close())
}
func TestNewGRPCPlugin_RealConn(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
gsrv := grpc.NewServer()
proto.RegisterAdmissionServer(gsrv, &testAdmissionServer{admit: true})
go gsrv.Serve(lis)
defer gsrv.Stop()
// Call the real NewGRPCPlugin function.
p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500))
require.NotNil(t, p)
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
assert.NoError(t, p.(io.Closer).Close())
}
func TestGRPCPlugin_Admit_ServerError(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &errorAdmissionServer{})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAdmissionClient(conn),
log: xlogger.Nop(),
}
// Server returns an error; Admit should return false.
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
// --- test helpers ---
type testAdmissionServer struct {
proto.UnimplementedAdmissionServer
admit bool
lastService string
}
func (s *testAdmissionServer) Admit(ctx context.Context, req *proto.AdmissionRequest) (*proto.AdmissionReply, error) {
s.lastService = req.Service
return &proto.AdmissionReply{Ok: s.admit}, nil
}
type errorAdmissionServer struct {
proto.UnimplementedAdmissionServer
}
func (s *errorAdmissionServer) Admit(ctx context.Context, req *proto.AdmissionRequest) (*proto.AdmissionReply, error) {
return nil, status.Error(codes.Internal, "internal error")
}
type nonCloserConn struct{}
func (n *nonCloserConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error {
return nil
}
func (n *nonCloserConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return nil, nil
}
var _ grpc.ClientConnInterface = (*nonCloserConn)(nil)
var _ io.Closer = (*grpcPlugin)(nil)
+49 -4
View File
@@ -11,14 +11,24 @@ import (
"github.com/go-gost/x/internal/plugin"
)
// httpPluginRequest is the JSON payload sent to the HTTP admission service.
type httpPluginRequest struct {
Addr string `json:"addr"`
Service string `json:"service"`
Network string `json:"network"`
Addr string `json:"addr"`
}
// httpPluginResponse is the JSON response expected from the HTTP
// admission service. OK indicates whether the address is admitted.
type httpPluginResponse struct {
OK bool `json:"ok"`
}
// httpPlugin is an admission controller that delegates to an external
// HTTP admission service via JSON POST requests.
//
// The HTTP client is created via plugin.NewHTTPClient, which configures
// timeouts, TLS, and token-based authentication from plugin options.
type httpPlugin struct {
url string
client *http.Client
@@ -26,7 +36,14 @@ type httpPlugin struct {
log logger.Logger
}
// NewHTTPPlugin creates an Admission plugin based on HTTP.
// NewHTTPPlugin creates an admission controller that communicates with
// an external HTTP admission service at the given URL.
//
// The name is used only for log identification. On each Admit call,
// a JSON-encoded httpPluginRequest is POSTed to the URL. A 200 response
// with {"ok": true} admits the address; any other response denies it.
//
// If the HTTP client is nil, all admission requests return false.
func NewHTTPPlugin(name string, url string, opts ...plugin.Option) admission.Admission {
var options plugin.Options
for _, opt := range opts {
@@ -44,13 +61,30 @@ func NewHTTPPlugin(name string, url string, opts ...plugin.Option) admission.Adm
}
}
func (p *httpPlugin) Admit(ctx context.Context, addr string, opts ...admission.Option) (ok bool) {
// Admit sends a JSON POST request to the configured HTTP endpoint.
// The request includes the service name, network, and client address.
//
// The address is admitted only if ALL of the following are true:
// - The HTTP client is non-nil
// - The request is successfully sent
// - The response has status 200 OK
// - The response body decodes as JSON with "ok": true
//
// Any error, non-200 status, or decode failure results in denial.
func (p *httpPlugin) Admit(ctx context.Context, network, addr string, opts ...admission.Option) (ok bool) {
if p.client == nil {
return
}
var options admission.Options
for _, opt := range opts {
opt(&options)
}
rb := httpPluginRequest{
Addr: addr,
Service: options.Service,
Network: network,
Addr: addr,
}
v, err := json.Marshal(&rb)
if err != nil {
@@ -82,3 +116,14 @@ func (p *httpPlugin) Admit(ctx context.Context, addr string, opts ...admission.O
}
return res.OK
}
// Close closes idle connections in the HTTP client's connection pool.
// It does not shut down the client itself.
func (p *httpPlugin) Close() error {
if p.client != nil {
if tr := plugin.HTTPClientTransport(p.client); tr != nil {
tr.CloseIdleConnections()
}
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
package admission
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-gost/core/admission"
"github.com/go-gost/x/internal/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHTTPPlugin(t *testing.T) {
p := NewHTTPPlugin("test", "http://localhost:9999")
require.NotNil(t, p)
hp, ok := p.(*httpPlugin)
require.True(t, ok)
assert.Equal(t, "http://localhost:9999", hp.url)
assert.NotNil(t, hp.client)
assert.NotNil(t, hp.log)
}
func TestHTTPPlugin_Admit_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Admit_Deny(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": false}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Admit_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Admit_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`not json`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Admit_ConnectionRefused(t *testing.T) {
p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0))
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Admit_NilClient(t *testing.T) {
hp := &httpPlugin{
url: "http://localhost",
client: nil,
}
assert.False(t, hp.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Admit_WithServiceAndHeader(t *testing.T) {
var receivedService string
var receivedHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedService = r.URL.Query().Get("check_service")
receivedHeader = r.Header.Get("X-Custom")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{
"X-Custom": []string{"test-value"},
}))
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1", admission.WithService("myservice")))
_ = receivedService
_ = receivedHeader
}
func TestHTTPPlugin_Admit_WithNoHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
// Create plugin without custom headers
hp := &httpPlugin{
url: srv.URL,
client: plugin.NewHTTPClient(&plugin.Options{}),
header: nil,
}
assert.True(t, hp.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Close_NilClient(t *testing.T) {
hp := &httpPlugin{client: nil}
err := hp.Close()
assert.NoError(t, err)
}
func TestHTTPPlugin_Close_WithClient(t *testing.T) {
hp := &httpPlugin{
client: plugin.NewHTTPClient(&plugin.Options{}),
}
err := hp.Close()
assert.NoError(t, err)
}
func TestHTTPPlugin_Admit_WithAllOptions(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer mytoken", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
// Test with various plugin options
p := &httpPlugin{
url: srv.URL,
client: plugin.NewHTTPClient(&plugin.Options{}),
header: http.Header{"Authorization": []string{"Bearer mytoken"}},
}
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Admit_BadURL(t *testing.T) {
// A URL that causes http.NewRequestWithContext to fail.
hp := &httpPlugin{
url: "http://invalid hostname", // space causes NewRequest to fail
client: plugin.NewHTTPClient(&plugin.Options{}),
}
assert.False(t, hp.Admit(context.Background(), "tcp", "192.168.1.1"))
}
var _ io.Closer = (*httpPlugin)(nil)
+116 -17
View File
@@ -8,7 +8,8 @@ import (
"syscall"
"github.com/go-gost/core/admission"
"github.com/go-gost/core/metadata"
"github.com/go-gost/x/ctx"
xio "github.com/go-gost/x/internal/io"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/net/udp"
)
@@ -17,11 +18,22 @@ var (
errUnsupport = errors.New("unsupported operation")
)
// serverConn wraps a TCP connection (net.Conn) with per-read admission
// checking. Unlike the listener wrapper — which rejects at accept time —
// this wrapper re-validates the remote address on each Read call.
// This is useful when the admission rules may change during the lifetime
// of a connection, or when the connection was established before
// admission wrappers were applied.
type serverConn struct {
net.Conn
admission admission.Admission
}
// WrapConn wraps a net.Conn with per-read admission control.
// If admission is nil, the original connection is returned unchanged.
//
// When admission is denied on a Read, io.EOF is returned so the caller
// sees a clean end-of-stream rather than a hard error.
func WrapConn(admission admission.Admission, c net.Conn) net.Conn {
if admission == nil {
return c
@@ -32,15 +44,27 @@ 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.
func (c *serverConn) Read(b []byte) (n int, err error) {
if c.admission != nil &&
!c.admission.Admit(context.Background(), c.RemoteAddr().String()) {
!c.admission.Admit(context.Background(), c.RemoteAddr().Network(), c.RemoteAddr().String()) {
err = io.EOF
return
}
return c.Conn.Read(b)
}
// SyscallConn exposes the underlying raw connection for socket-level
// operations (e.g. setting SO_MARK, SO_REUSEPORT). Returns
// errUnsupport if the inner connection does not support this.
func (c *serverConn) SyscallConn() (rc syscall.RawConn, err error) {
if sc, ok := c.Conn.(syscall.Conn); ok {
rc, err = sc.SyscallConn()
@@ -50,18 +74,44 @@ func (c *serverConn) SyscallConn() (rc syscall.RawConn, err error) {
return
}
func (c *serverConn) Metadata() metadata.Metadata {
if md, ok := c.Conn.(metadata.Metadatable); ok {
return md.Metadata()
// CloseRead shuts down the read side of the connection if supported.
func (c *serverConn) CloseRead() error {
if sc, ok := c.Conn.(xio.CloseRead); ok {
return sc.CloseRead()
}
return xio.ErrUnsupported
}
// CloseWrite shuts down the write side of the connection if supported.
func (c *serverConn) CloseWrite() error {
if sc, ok := c.Conn.(xio.CloseWrite); ok {
return sc.CloseWrite()
}
return xio.ErrUnsupported
}
// Context returns the context carried by the underlying connection,
// or nil if the connection does not carry context.
func (c *serverConn) Context() context.Context {
if innerCtx, ok := c.Conn.(ctx.Context); ok {
return innerCtx.Context()
}
return nil
}
// packetConn wraps a net.PacketConn with per-packet admission checking.
// Packets from denied addresses are silently dropped and the read
// loop continues to the next packet.
type packetConn struct {
net.PacketConn
admission admission.Admission
}
// WrapPacketConn wraps a net.PacketConn with per-packet admission control.
// If admission is nil, the original connection is returned unchanged.
//
// Denied packets are silently discarded; ReadFrom loops until it receives
// a packet from an allowed address or encounters an error.
func WrapPacketConn(admission admission.Admission, pc net.PacketConn) net.PacketConn {
if admission == nil {
return pc
@@ -72,6 +122,8 @@ func WrapPacketConn(admission admission.Admission, pc net.PacketConn) net.Packet
}
}
// ReadFrom reads the next packet from an admitted address. Packets from
// denied addresses are discarded and the method retries automatically.
func (c *packetConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
for {
n, addr, err = c.PacketConn.ReadFrom(p)
@@ -80,7 +132,7 @@ func (c *packetConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
}
if c.admission != nil &&
!c.admission.Admit(context.Background(), addr.String()) {
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
continue
}
@@ -88,25 +140,49 @@ func (c *packetConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
}
}
func (c *packetConn) Metadata() metadata.Metadata {
if md, ok := c.PacketConn.(metadata.Metadatable); ok {
return md.Metadata()
// Context returns the context carried by the underlying packet connection.
func (c *packetConn) Context() context.Context {
if innerCtx, ok := c.PacketConn.(ctx.Context); ok {
return innerCtx.Context()
}
return nil
}
// udpConn wraps a packet connection as a UDP-specific connection.
// It provides the same admission filtering as packetConn, plus UDP-
// specific operations (ReadFromUDP, ReadMsgUDP, WriteToUDP, WriteMsgUDP,
// SetDSCP, etc.).
//
// The wrapper delegates to optional interfaces on the underlying
// connection, returning errUnsupport if the capability is not available.
type udpConn struct {
net.PacketConn
admission admission.Admission
}
func WrapUDPConn(admission admission.Admission, pc net.PacketConn) udp.Conn {
// 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.
// 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,
}
}
// RemoteAddr returns the remote address from the underlying connection
// if it implements the RemoteAddr interface. This is used for "connected"
// UDP sockets that have a fixed peer.
func (c *udpConn) RemoteAddr() net.Addr {
if nc, ok := c.PacketConn.(xnet.RemoteAddr); ok {
return nc.RemoteAddr()
@@ -114,6 +190,7 @@ func (c *udpConn) RemoteAddr() net.Addr {
return nil
}
// SetReadBuffer sets the socket receive buffer size.
func (c *udpConn) SetReadBuffer(n int) error {
if nc, ok := c.PacketConn.(xnet.SetBuffer); ok {
return nc.SetReadBuffer(n)
@@ -121,6 +198,7 @@ func (c *udpConn) SetReadBuffer(n int) error {
return errUnsupport
}
// SetWriteBuffer sets the socket send buffer size.
func (c *udpConn) SetWriteBuffer(n int) error {
if nc, ok := c.PacketConn.(xnet.SetBuffer); ok {
return nc.SetWriteBuffer(n)
@@ -128,6 +206,8 @@ func (c *udpConn) SetWriteBuffer(n int) error {
return errUnsupport
}
// Read delegates to the underlying connection's Read method if available
// (for connected UDP sockets that support stream-like reads).
func (c *udpConn) Read(b []byte) (n int, err error) {
if nc, ok := c.PacketConn.(io.Reader); ok {
n, err = nc.Read(b)
@@ -137,6 +217,8 @@ func (c *udpConn) Read(b []byte) (n int, err error) {
return
}
// ReadFrom reads the next packet from an admitted address, discarding
// packets from denied addresses.
func (c *udpConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
for {
n, addr, err = c.PacketConn.ReadFrom(p)
@@ -144,13 +226,15 @@ func (c *udpConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
return
}
if c.admission != nil &&
!c.admission.Admit(context.Background(), addr.String()) {
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
continue
}
return
}
}
// ReadFromUDP reads the next UDP packet from an admitted address.
// Packets from denied addresses are discarded transparently.
func (c *udpConn) ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error) {
if nc, ok := c.PacketConn.(udp.ReadUDP); ok {
for {
@@ -159,7 +243,7 @@ func (c *udpConn) ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error) {
return
}
if c.admission != nil &&
!c.admission.Admit(context.Background(), addr.String()) {
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
continue
}
return
@@ -169,6 +253,8 @@ func (c *udpConn) ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error) {
return
}
// ReadMsgUDP reads a UDP packet with out-of-band data from an admitted
// address. Packets from denied addresses are silently skipped.
func (c *udpConn) ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAddr, err error) {
if nc, ok := c.PacketConn.(udp.ReadUDP); ok {
for {
@@ -177,7 +263,7 @@ func (c *udpConn) ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAd
return
}
if c.admission != nil &&
!c.admission.Admit(context.Background(), addr.String()) {
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
continue
}
return
@@ -187,6 +273,8 @@ func (c *udpConn) ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAd
return
}
// Write delegates to the underlying connection's Write method if available
// (for connected UDP sockets).
func (c *udpConn) Write(b []byte) (n int, err error) {
if nc, ok := c.PacketConn.(io.Writer); ok {
n, err = nc.Write(b)
@@ -196,11 +284,15 @@ func (c *udpConn) Write(b []byte) (n int, err error) {
return
}
// WriteTo sends a packet to the specified address via the underlying
// packet connection. No admission check is performed on writes.
func (c *udpConn) WriteTo(p []byte, addr net.Addr) (n int, err error) {
n, err = c.PacketConn.WriteTo(p, addr)
return
}
// WriteToUDP sends a UDP packet to the specified address if the
// underlying connection supports UDP-specific writes.
func (c *udpConn) WriteToUDP(b []byte, addr *net.UDPAddr) (n int, err error) {
if nc, ok := c.PacketConn.(udp.WriteUDP); ok {
n, err = nc.WriteToUDP(b, addr)
@@ -210,6 +302,7 @@ func (c *udpConn) WriteToUDP(b []byte, addr *net.UDPAddr) (n int, err error) {
return
}
// WriteMsgUDP sends a UDP packet with out-of-band data if supported.
func (c *udpConn) WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (n, oobn int, err error) {
if nc, ok := c.PacketConn.(udp.WriteUDP); ok {
n, oobn, err = nc.WriteMsgUDP(b, oob, addr)
@@ -219,6 +312,8 @@ func (c *udpConn) WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (n, oobn int, er
return
}
// SyscallConn exposes the underlying raw connection for socket-level
// operations if supported.
func (c *udpConn) SyscallConn() (rc syscall.RawConn, err error) {
if nc, ok := c.PacketConn.(xnet.SyscallConn); ok {
return nc.SyscallConn()
@@ -227,6 +322,9 @@ func (c *udpConn) SyscallConn() (rc syscall.RawConn, err error) {
return
}
// SetDSCP sets the Differentiated Services Code Point on the socket
// for traffic classification/QoS. Silently succeeds if not supported
// (best-effort).
func (c *udpConn) SetDSCP(n int) error {
if nc, ok := c.PacketConn.(xnet.SetDSCP); ok {
return nc.SetDSCP(n)
@@ -234,9 +332,10 @@ func (c *udpConn) SetDSCP(n int) error {
return nil
}
func (c *udpConn) Metadata() metadata.Metadata {
if md, ok := c.PacketConn.(metadata.Metadatable); ok {
return md.Metadata()
// Context returns the context carried by the underlying packet connection.
func (c *udpConn) Context() context.Context {
if innerCtx, ok := c.PacketConn.(ctx.Context); ok {
return innerCtx.Context()
}
return nil
}
File diff suppressed because it is too large Load Diff
+51 -2
View File
@@ -1,3 +1,14 @@
// Package wrapper provides net.Listener and net.Conn wrappers that
// enforce admission control at the network level.
//
// The listener wrapper checks each accepted connection against the
// admission controller before returning it to the caller. Denied
// connections are closed immediately and the listener continues
// accepting (transparent rejection).
//
// The connection wrappers (TCP, UDP, generic packet) check admission
// on each read operation, effectively dropping traffic from denied
// addresses without closing the underlying transport.
package wrapper
import (
@@ -6,32 +17,70 @@ import (
"github.com/go-gost/core/admission"
"github.com/go-gost/core/logger"
xctx "github.com/go-gost/x/ctx"
)
// listener wraps a net.Listener and performs admission checks on each
// accepted connection. Connections from denied addresses are silently
// closed and the accept loop continues.
type listener struct {
service string
net.Listener
admission admission.Admission
log logger.Logger
}
func WrapListener(admission admission.Admission, ln net.Listener) net.Listener {
// WrapListener wraps a net.Listener with admission control. If admission
// is nil, the original listener is returned unchanged (no-op).
//
// The service name is passed to the admission controller via
// admission.WithService so that the controller can distinguish
// between different services sharing the same admission rules.
func WrapListener(service string, admission admission.Admission, ln net.Listener) net.Listener {
if admission == nil {
return ln
}
return &listener{
service: service,
Listener: ln,
admission: admission,
}
}
// Accept blocks until a connection is accepted from the underlying
// listener, then checks it against the admission controller. Denied
// connections are closed and the loop retries. The caller never sees
// a rejected connection.
//
// The client address used for the admission check is determined by:
// 1. The srcAddr value from the connection's context (if available),
// which is the original client address before any proxy protocol
// or NAT rewriting.
// 2. Falling back to the raw RemoteAddr of the accepted connection.
func (ln *listener) Accept() (net.Conn, error) {
for {
c, err := ln.Listener.Accept()
if err != nil {
return nil, err
}
// Extract the context from the connection if it carries one.
ctx := context.Background()
if cc, ok := c.(xctx.Context); ok {
if cv := cc.Context(); cv != nil {
ctx = cv
}
}
// Prefer the original source address from context (e.g. set by
// proxy protocol handling), falling back to the raw remote address.
clientAddr := c.RemoteAddr()
if addr := xctx.SrcAddrFromContext(ctx); addr != nil {
clientAddr = addr
}
if ln.admission != nil &&
!ln.admission.Admit(context.Background(), c.RemoteAddr().String()) {
!ln.admission.Admit(ctx, clientAddr.Network(), clientAddr.String(), admission.WithService(ln.service)) {
c.Close()
continue
}
+234
View File
@@ -0,0 +1,234 @@
package wrapper
import (
"context"
"errors"
"net"
"testing"
"github.com/go-gost/core/admission"
xctx "github.com/go-gost/x/ctx"
"github.com/stretchr/testify/assert"
)
// --- WrapListener tests ---
func TestWrapListener_NilAdmission(t *testing.T) {
ln := &fakeListener{}
result := WrapListener("myservice", nil, ln)
assert.Equal(t, ln, result) // should return the original listener
}
func TestWrapListener_WithAdmission(t *testing.T) {
ln := &fakeListener{}
result := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
assert.IsType(t, &listener{}, result)
}
// --- listener.Accept tests ---
func TestListener_Accept_Admit(t *testing.T) {
c := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}}
ln := &fakeListener{conns: []net.Conn{c}}
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
conn, err := wrapped.Accept()
assert.NoError(t, err)
assert.NotNil(t, conn)
assert.False(t, c.closed)
}
func TestListener_Accept_Deny(t *testing.T) {
c := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}}
denied := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 5678}}
ln := &fakeListener{conns: []net.Conn{denied, c}}
wrapped := WrapListener("myservice", &denyFirstConnAdmission{}, ln)
// First connection is denied, second is admitted
conn, err := wrapped.Accept()
assert.NoError(t, err)
assert.Equal(t, c, conn)
assert.True(t, denied.closed)
}
func TestListener_Accept_Error(t *testing.T) {
ln := &fakeListener{acceptErr: errors.New("accept failed")}
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
_, err := wrapped.Accept()
assert.EqualError(t, err, "accept failed")
}
func TestListener_Accept_WithContextSrcAddr(t *testing.T) {
srcAddr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}
c := &fakeConn{
raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234},
ctx: xctx.ContextWithSrcAddr(context.Background(), srcAddr),
}
ln := &fakeListener{conns: []net.Conn{c}}
// Use an admission that records the address it receives
recorder := &recordingAdmission{admit: true}
wrapped := WrapListener("myservice", recorder, ln)
conn, err := wrapped.Accept()
assert.NoError(t, err)
assert.NotNil(t, conn)
// Should have used the src addr from context (String() includes port)
assert.Equal(t, "1.2.3.4:1234", recorder.lastAddr)
}
func TestListener_Accept_WithContextNilContext(t *testing.T) {
c := &fakeConn{
raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234},
ctx: nil, // returns nil context
}
ln := &fakeListener{conns: []net.Conn{c}}
recorder := &recordingAdmission{admit: true}
wrapped := WrapListener("myservice", recorder, ln)
conn, err := wrapped.Accept()
assert.NoError(t, err)
assert.NotNil(t, conn)
// Should fall back to RemoteAddr (String() includes port)
assert.Equal(t, "192.168.1.1:1234", recorder.lastAddr)
}
func TestListener_Accept_NoContextInterface(t *testing.T) {
// A plain net.Conn without xctx.Context support
c := &plainNetConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 9999}}
ln := &fakeListener{conns: []net.Conn{c}}
recorder := &recordingAdmission{admit: true}
wrapped := WrapListener("myservice", recorder, ln)
conn, err := wrapped.Accept()
assert.NoError(t, err)
assert.NotNil(t, conn)
// Should use RemoteAddr (String() includes port)
assert.Equal(t, "10.0.0.1:9999", recorder.lastAddr)
}
func TestListener_Accept_MultipleDeniesThenAdmit(t *testing.T) {
denied1 := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1}}
denied2 := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.2"), Port: 2}}
admitted := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 3}}
ln := &fakeListener{conns: []net.Conn{denied1, denied2, admitted}}
// Deny the first two, admit the third
count := 0
conditional := &conditionalAdmission{
fn: func(ctx context.Context, network, addr string, opts ...admission.Option) bool {
count++
return count > 2
},
}
wrapped := WrapListener("myservice", conditional, ln)
conn, err := wrapped.Accept()
assert.NoError(t, err)
assert.Equal(t, admitted, conn)
assert.True(t, denied1.closed)
assert.True(t, denied2.closed)
assert.False(t, admitted.closed)
}
// Test that Close is forwarded to the underlying listener
func TestListener_Close(t *testing.T) {
ln := &fakeListener{}
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
err := wrapped.Close()
assert.NoError(t, err)
assert.True(t, ln.closed)
}
// Test that Addr is forwarded to the underlying listener
func TestListener_Addr(t *testing.T) {
ln := &fakeListener{}
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
addr := wrapped.Addr()
assert.NotNil(t, addr)
}
// --- fake types for listener tests ---
type fakeListener struct {
conns []net.Conn
pos int
acceptErr error
closed bool
}
func (ln *fakeListener) Accept() (net.Conn, error) {
if ln.acceptErr != nil {
return nil, ln.acceptErr
}
if ln.pos >= len(ln.conns) {
return nil, errors.New("no more connections")
}
c := ln.conns[ln.pos]
ln.pos++
return c, nil
}
func (ln *fakeListener) Close() error {
ln.closed = true
return nil
}
func (ln *fakeListener) Addr() net.Addr {
return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8080}
}
// --- admission helpers for listener tests ---
type recordingAdmission struct {
admit bool
lastAddr string
}
func (r *recordingAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
r.lastAddr = addr
return r.admit
}
type denyFirstConnAdmission struct {
count int
}
func (d *denyFirstConnAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
d.count++
return d.count > 1 // deny first, admit rest
}
type conditionalAdmission struct {
fn func(ctx context.Context, network, addr string, opts ...admission.Option) bool
}
func (c *conditionalAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
return c.fn(ctx, network, addr, opts...)
}
// Compile-time checks
var (
_ net.Listener = (*fakeListener)(nil)
_ net.Conn = (*fakeConn)(nil)
_ xctx.Context = (*fakeConn)(nil)
_ net.Conn = (*plainNetConn)(nil)
)
// Test that plainNetConn does NOT implement xctx.Context
func TestPlainNetConn_NoContext(t *testing.T) {
c := &plainNetConn{raddr: &net.TCPAddr{}}
_, ok := any(c).(xctx.Context)
assert.False(t, ok)
}
// Test that fakeConn DOES implement xctx.Context
func TestFakeConn_HasContext(t *testing.T) {
c := &fakeConn{raddr: &net.TCPAddr{}}
_, ok := any(c).(xctx.Context)
assert.True(t, ok)
}
+57 -82
View File
@@ -1,14 +1,14 @@
// swagger generate spec -o swagger.yaml
package api
import (
"embed"
"net"
"net/http"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/service"
)
var (
@@ -17,56 +17,22 @@ var (
)
type Response struct {
Code int `json:"code,omitempty"`
Msg string `json:"msg,omitempty"`
Code int `json:"code,omitempty"`
Msg string `json:"msg,omitempty"`
Data interface{} `json:"data,omitempty"`
}
type options struct {
accessLog bool
pathPrefix string
auther auth.Authenticator
type Options struct {
AccessLog bool
PathPrefix string
Auther auth.Authenticator
}
type Option func(*options)
func PathPrefixOption(pathPrefix string) Option {
return func(o *options) {
o.pathPrefix = pathPrefix
}
}
func AccessLogOption(enable bool) Option {
return func(o *options) {
o.accessLog = enable
}
}
func AutherOption(auther auth.Authenticator) Option {
return func(o *options) {
o.auther = auther
}
}
type server struct {
s *http.Server
ln net.Listener
cclose chan struct{}
}
func NewService(addr string, opts ...Option) (service.Service, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
func Register(r *gin.Engine, opts *Options) {
if opts == nil {
opts = &Options{}
}
var options options
for _, opt := range opts {
opt(&options)
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(
cors.New((cors.Config{
AllowAllOrigins: true,
@@ -76,115 +42,124 @@ func NewService(addr string, opts ...Option) (service.Service, error) {
})),
gin.Recovery(),
)
if options.accessLog {
if opts.AccessLog {
r.Use(mwLogger())
}
router := r.Group("")
if options.pathPrefix != "" {
router = router.Group(options.pathPrefix)
if opts.PathPrefix != "" {
router = router.Group(opts.PathPrefix)
}
router.StaticFS("/docs", http.FS(swaggerDoc))
config := router.Group("/config")
config.Use(mwBasicAuth(options.auther))
registerConfig(config)
config.Use(mwBasicAuth(opts.Auther))
return &server{
s: &http.Server{
Handler: r,
},
ln: ln,
cclose: make(chan struct{}),
}, nil
}
func (s *server) Serve() error {
return s.s.Serve(s.ln)
}
func (s *server) Addr() net.Addr {
return s.ln.Addr()
}
func (s *server) Close() error {
return s.s.Close()
}
func (s *server) IsClosed() bool {
select {
case <-s.cclose:
return true
default:
return false
}
}
func registerConfig(config *gin.RouterGroup) {
config.GET("", getConfig)
config.POST("", saveConfig)
config.POST("/reload", reloadConfig)
config.GET("/services", getServiceList)
config.GET("/services/:service", getService)
config.POST("/services", createService)
config.PUT("/services/:service", updateService)
config.DELETE("/services/:service", deleteService)
config.GET("/chains", getChainList)
config.GET("/chains/:chain", getChain)
config.POST("/chains", createChain)
config.PUT("/chains/:chain", updateChain)
config.DELETE("/chains/:chain", deleteChain)
config.GET("/hops", getHopList)
config.GET("/hops/:hop", getHop)
config.POST("/hops", createHop)
config.PUT("/hops/:hop", updateHop)
config.DELETE("/hops/:hop", deleteHop)
config.GET("/authers", getAutherList)
config.GET("/authers/:auther", getAuther)
config.POST("/authers", createAuther)
config.PUT("/authers/:auther", updateAuther)
config.DELETE("/authers/:auther", deleteAuther)
config.GET("/admissions", getAdmissionList)
config.GET("/admissions/:admission", getAdmission)
config.POST("/admissions", createAdmission)
config.PUT("/admissions/:admission", updateAdmission)
config.DELETE("/admissions/:admission", deleteAdmission)
config.GET("/bypasses", getBypassList)
config.GET("/bypasses/:bypass", getBypass)
config.POST("/bypasses", createBypass)
config.PUT("/bypasses/:bypass", updateBypass)
config.DELETE("/bypasses/:bypass", deleteBypass)
config.GET("/resolvers", getResolverList)
config.GET("/resolvers/:resolver", getResolver)
config.POST("/resolvers", createResolver)
config.PUT("/resolvers/:resolver", updateResolver)
config.DELETE("/resolvers/:resolver", deleteResolver)
config.GET("/hosts", getHostsList)
config.GET("/hosts/:hosts", getHosts)
config.POST("/hosts", createHosts)
config.PUT("/hosts/:hosts", updateHosts)
config.DELETE("/hosts/:hosts", deleteHosts)
config.GET("/ingresses", getIngressList)
config.GET("/ingresses/:ingress", getIngress)
config.POST("/ingresses", createIngress)
config.PUT("/ingresses/:ingress", updateIngress)
config.DELETE("/ingresses/:ingress", deleteIngress)
config.GET("/routers", getRouterList)
config.GET("/routers/:router", getRouter)
config.POST("/routers", createRouter)
config.PUT("/routers/:router", updateRouter)
config.DELETE("/routers/:router", deleteRouter)
config.GET("/observers", getObserverList)
config.GET("/observers/:observer", getObserver)
config.POST("/observers", createObserver)
config.PUT("/observers/:observer", updateObserver)
config.DELETE("/observers/:observer", deleteObserver)
config.GET("/recorders", getRecorderList)
config.GET("/recorders/:recorder", getRecorder)
config.POST("/recorders", createRecorder)
config.PUT("/recorders/:recorder", updateRecorder)
config.DELETE("/recorders/:recorder", deleteRecorder)
config.GET("/sds", getSDList)
config.GET("/sds/:sd", getSD)
config.POST("/sds", createSD)
config.PUT("/sds/:sd", updateSD)
config.DELETE("/sds/:sd", deleteSD)
config.GET("/limiters", getLimiterList)
config.GET("/limiters/:limiter", getLimiter)
config.POST("/limiters", createLimiter)
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)
config.PUT("/climiters/:limiter", updateConnLimiter)
config.DELETE("/climiters/:limiter", deleteConnLimiter)
config.GET("/rlimiters", getRateLimiterList)
config.GET("/rlimiters/:limiter", getRateLimiter)
config.POST("/rlimiters", createRateLimiter)
config.PUT("/rlimiters/:limiter", updateRateLimiter)
config.DELETE("/rlimiters/:limiter", deleteRateLimiter)
+53 -26
View File
@@ -17,6 +17,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 +101,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
})
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getAdmissionListRequest
type getAdmissionListRequest struct {
}
// successful operation.
// swagger:response getAdmissionListResponse
type getAdmissionListResponse struct {
// in: body
Data admissionList
}
type admissionList struct {
Count int `json:"count"`
List []*config.AdmissionConfig `json:"list"`
}
func getAdmissionList(ctx *gin.Context) {
// swagger:route GET /config/admissions Admission getAdmissionListRequest
//
// Get admission list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getAdmissionListResponse
var req getAdmissionListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Admissions
var resp getAdmissionListResponse
resp.Data = admissionList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getAdmissionRequest
type getAdmissionRequest struct {
// in: path
// required: true
Admission string `uri:"admission" json:"admission"`
}
// successful operation.
// swagger:response getAdmissionResponse
type getAdmissionResponse struct {
// in: body
Data *config.AdmissionConfig
}
func getAdmission(ctx *gin.Context) {
// swagger:route GET /config/admissions/{admission} Admission getAdmissionRequest
//
// Get admission.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getAdmissionResponse
var req getAdmissionRequest
ctx.ShouldBindUri(&req)
var resp getAdmissionResponse
for _, admission := range config.Global().Admissions {
if admission == nil {
continue
}
if admission.Name == req.Admission {
resp.Data = admission
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createAdmissionRequest
type createAdmissionRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getAutherListRequest
type getAutherListRequest struct {
}
// successful operation.
// swagger:response getAutherListResponse
type getAutherListResponse struct {
// in: body
Data autherList
}
type autherList struct {
Count int `json:"count"`
List []*config.AutherConfig `json:"list"`
}
func getAutherList(ctx *gin.Context) {
// swagger:route GET /config/authers Auther getAutherListRequest
//
// Get auther list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getAutherListResponse
var req getAutherListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Authers
var resp getAutherListResponse
resp.Data = autherList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getAutherRequest
type getAutherRequest struct {
// in: path
// required: true
Auther string `uri:"auther" json:"auther"`
}
// successful operation.
// swagger:response getAutherResponse
type getAutherResponse struct {
// in: body
Data *config.AutherConfig
}
func getAuther(ctx *gin.Context) {
// swagger:route GET /config/authers/{auther} Auther getAutherRequest
//
// Get auther.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getAutherResponse
var req getAutherRequest
ctx.ShouldBindUri(&req)
var resp getAutherResponse
for _, auther := range config.Global().Authers {
if auther == nil {
continue
}
if auther.Name == req.Auther {
resp.Data = auther
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createAutherRequest
type createAutherRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getBypassListRequest
type getBypassListRequest struct {
}
// successful operation.
// swagger:response getBypassListResponse
type getBypassListResponse struct {
// in: body
Data bypassList
}
type bypassList struct {
Count int `json:"count"`
List []*config.BypassConfig `json:"list"`
}
func getBypassList(ctx *gin.Context) {
// swagger:route GET /config/bypasses Bypass getBypassListRequest
//
// Get bypass list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getBypassListResponse
var req getBypassListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Bypasses
var resp getBypassListResponse
resp.Data = bypassList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getBypassRequest
type getBypassRequest struct {
// in: path
// required: true
Bypass string `uri:"bypass" json:"bypass"`
}
// successful operation.
// swagger:response getBypassResponse
type getBypassResponse struct {
// in: body
Data *config.BypassConfig
}
func getBypass(ctx *gin.Context) {
// swagger:route GET /config/bypasses/{bypass} Bypass getBypassRequest
//
// Get bypass.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getBypassResponse
var req getBypassRequest
ctx.ShouldBindUri(&req)
var resp getBypassResponse
for _, bypass := range config.Global().Bypasses {
if bypass == nil {
continue
}
if bypass.Name == req.Bypass {
resp.Data = bypass
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createBypassRequest
type createBypassRequest struct {
// in: body
+87
View File
@@ -12,6 +12,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getChainListRequest
type getChainListRequest struct {
}
// successful operation.
// swagger:response getChainListResponse
type getChainListResponse struct {
// in: body
Data chainList
}
type chainList struct {
Count int `json:"count"`
List []*config.ChainConfig `json:"list"`
}
func getChainList(ctx *gin.Context) {
// swagger:route GET /config/chains Chain getChainListRequest
//
// Get chain list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getChainListResponse
var req getChainListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Chains
var resp getChainListResponse
resp.Data = chainList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getChainRequest
type getChainRequest struct {
// in: path
// required: true
Chain string `uri:"chain" json:"chain"`
}
// successful operation.
// swagger:response getChainResponse
type getChainResponse struct {
// in: body
Data *config.ChainConfig
}
func getChain(ctx *gin.Context) {
// swagger:route GET /config/chains/{chain} Chain getChainRequest
//
// Get chain.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getChainResponse
var req getChainRequest
ctx.ShouldBindUri(&req)
var resp getChainResponse
for _, chain := range config.Global().Chains {
if chain == nil {
continue
}
if chain.Name == req.Chain {
resp.Data = chain
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createChainRequest
type createChainRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getConnLimiterListRequest
type getConnLimiterListRequest struct {
}
// successful operation.
// swagger:response getConnLimiterListResponse
type getConnLimiterListResponse struct {
// in: body
Data connLimiterList
}
type connLimiterList struct {
Count int `json:"count"`
List []*config.LimiterConfig `json:"list"`
}
func getConnLimiterList(ctx *gin.Context) {
// swagger:route GET /config/climiters Limiter getConnLimiterListRequest
//
// Get conn limiter list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getConnLimiterListResponse
var req getConnLimiterListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().CLimiters
var resp getConnLimiterListResponse
resp.Data = connLimiterList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getConnLimiterRequest
type getConnLimiterRequest struct {
// in: path
// required: true
Limiter string `uri:"limiter" json:"limiter"`
}
// successful operation.
// swagger:response getConnLimiterResponse
type getConnLimiterResponse struct {
// in: body
Data *config.LimiterConfig
}
func getConnLimiter(ctx *gin.Context) {
// swagger:route GET /config/climiters/{limiter} Limiter getConnLimiterRequest
//
// Get conn limiter.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getConnLimiterResponse
var req getConnLimiterRequest
ctx.ShouldBindUri(&req)
var resp getConnLimiterResponse
for _, limiter := range config.Global().CLimiters{
if limiter == nil {
continue
}
if limiter.Name == req.Limiter {
resp.Data = limiter
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createConnLimiterRequest
type createConnLimiterRequest struct {
// in: body
+87
View File
@@ -12,6 +12,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getHopListRequest
type getHopListRequest struct {
}
// successful operation.
// swagger:response getHopListResponse
type getHopListResponse struct {
// in: body
Data hopList
}
type hopList struct {
Count int `json:"count"`
List []*config.HopConfig `json:"list"`
}
func getHopList(ctx *gin.Context) {
// swagger:route GET /config/hops Hop getHopListRequest
//
// Get hop list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getHopListResponse
var req getHopListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Hops
var resp getHopListResponse
resp.Data = hopList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getHopRequest
type getHopRequest struct {
// in: path
// required: true
Hop string `uri:"hop" json:"hop"`
}
// successful operation.
// swagger:response getHopResponse
type getHopResponse struct {
// in: body
Data *config.HopConfig
}
func getHop(ctx *gin.Context) {
// swagger:route GET /config/hops/{hop} Hop getHopRequest
//
// Get hop.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getHopResponse
var req getHopRequest
ctx.ShouldBindUri(&req)
var resp getHopResponse
for _, hop := range config.Global().Hops {
if hop == nil {
continue
}
if hop.Name == req.Hop {
resp.Data = hop
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createHopRequest
type createHopRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getHostsListRequest
type getHostsListRequest struct {
}
// successful operation.
// swagger:response getHostsListResponse
type getHostsListResponse struct {
// in: body
Data hostsList
}
type hostsList struct {
Count int `json:"count"`
List []*config.HostsConfig `json:"list"`
}
func getHostsList(ctx *gin.Context) {
// swagger:route GET /config/hosts Hosts getHostsListRequest
//
// Get hosts list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getHostsListResponse
var req getHostsListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Hosts
var resp getHostsListResponse
resp.Data = hostsList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getHostsRequest
type getHostsRequest struct {
// in: path
// required: true
Hosts string `uri:"hosts" json:"hosts"`
}
// successful operation.
// swagger:response getHostsResponse
type getHostsResponse struct {
// in: body
Data *config.HostsConfig
}
func getHosts(ctx *gin.Context) {
// swagger:route GET /config/hosts/{hosts} Hosts getHostsRequest
//
// Get hosts.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getHostsResponse
var req getHostsRequest
ctx.ShouldBindUri(&req)
var resp getHostsResponse
for _, hosts := range config.Global().Hosts {
if hosts == nil {
continue
}
if hosts.Name == req.Hosts {
resp.Data = hosts
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createHostsRequest
type createHostsRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getIngressListRequest
type getIngressListRequest struct {
}
// successful operation.
// swagger:response getIngressListResponse
type getIngressListResponse struct {
// in: body
Data ingressList
}
type ingressList struct {
Count int `json:"count"`
List []*config.IngressConfig `json:"list"`
}
func getIngressList(ctx *gin.Context) {
// swagger:route GET /config/ingresses Ingress getIngressListRequest
//
// Get ingress list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getIngressListResponse
var req getIngressListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Ingresses
var resp getIngressListResponse
resp.Data = ingressList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getIngressRequest
type getIngressRequest struct {
// in: path
// required: true
Ingress string `uri:"ingress" json:"ingress"`
}
// successful operation.
// swagger:response getIngressResponse
type getIngressResponse struct {
// in: body
Data *config.IngressConfig
}
func getIngress(ctx *gin.Context) {
// swagger:route GET /config/ingresses/{ingress} Ingress getIngressRequest
//
// Get ingress.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getIngressResponse
var req getIngressRequest
ctx.ShouldBindUri(&req)
var resp getIngressResponse
for _, ingress := range config.Global().Ingresses {
if ingress == nil {
continue
}
if ingress.Name == req.Ingress {
resp.Data = ingress
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createIngressRequest
type createIngressRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getLimiterListRequest
type getLimiterListRequest struct {
}
// successful operation.
// swagger:response getLimiterListResponse
type getLimiterListResponse struct {
// in: body
Data limiterList
}
type limiterList struct {
Count int `json:"count"`
List []*config.LimiterConfig `json:"list"`
}
func getLimiterList(ctx *gin.Context) {
// swagger:route GET /config/limiters Limiter getLimiterListRequest
//
// Get limiter list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getLimiterListResponse
var req getLimiterListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Limiters
var resp getLimiterListResponse
resp.Data = limiterList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getLimiterRequest
type getLimiterRequest struct {
// in: path
// required: true
Limiter string `uri:"limiter" json:"limiter"`
}
// successful operation.
// swagger:response getLimiterResponse
type getLimiterResponse struct {
// in: body
Data *config.LimiterConfig
}
func getLimiter(ctx *gin.Context) {
// swagger:route GET /config/limiters/{limiter} Limiter getLimiterRequest
//
// Get limiter.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getLimiterResponse
var req getLimiterRequest
ctx.ShouldBindUri(&req)
var resp getLimiterResponse
for _, limiter := range config.Global().Limiters {
if limiter == nil {
continue
}
if limiter.Name == req.Limiter {
resp.Data = limiter
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createLimiterRequest
type createLimiterRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getObserverListRequest
type getObserverListRequest struct {
}
// successful operation.
// swagger:response getObserverListResponse
type getObserverListResponse struct {
// in: body
Data observerList
}
type observerList struct {
Count int `json:"count"`
List []*config.ObserverConfig `json:"list"`
}
func getObserverList(ctx *gin.Context) {
// swagger:route GET /config/observers Observer getObserverListRequest
//
// Get observer list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getObserverListResponse
var req getObserverListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Observers
var resp getObserverListResponse
resp.Data = observerList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getObserverRequest
type getObserverRequest struct {
// in: path
// required: true
Observer string `uri:"observer" json:"observer"`
}
// successful operation.
// swagger:response getObserverResponse
type getObserverResponse struct {
// in: body
Data *config.ObserverConfig
}
func getObserver(ctx *gin.Context) {
// swagger:route GET /config/observers/{observer} Observer getObserverRequest
//
// Get observer.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getObserverResponse
var req getObserverRequest
ctx.ShouldBindUri(&req)
var resp getObserverResponse
for _, observer := range config.Global().Observers {
if observer == nil {
continue
}
if observer.Name == req.Observer {
resp.Data = observer
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createObserverRequest
type createObserverRequest struct {
// in: body
+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",
})
}
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getRateLimiterListRequest
type getRateLimiterListRequest struct {
}
// successful operation.
// swagger:response getRateLimiterListResponse
type getRateLimiterListResponse struct {
// in: body
Data rateLimiterList
}
type rateLimiterList struct {
Count int `json:"count"`
List []*config.LimiterConfig `json:"list"`
}
func getRateLimiterList(ctx *gin.Context) {
// swagger:route GET /config/rlimiters Limiter getRateLimiterListRequest
//
// Get rate limiter list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getRateLimiterListResponse
var req getRateLimiterListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().CLimiters
var resp getRateLimiterListResponse
resp.Data = rateLimiterList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getRateLimiterRequest
type getRateLimiterRequest struct {
// in: path
// required: true
Limiter string `uri:"limiter" json:"limiter"`
}
// successful operation.
// swagger:response getRateLimiterResponse
type getRateLimiterResponse struct {
// in: body
Data *config.LimiterConfig
}
func getRateLimiter(ctx *gin.Context) {
// swagger:route GET /config/rlimiters/{limiter} Limiter getRateLimiterRequest
//
// Get conn limiter.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getRateLimiterResponse
var req getRateLimiterRequest
ctx.ShouldBindUri(&req)
var resp getRateLimiterResponse
for _, limiter := range config.Global().CLimiters{
if limiter == nil {
continue
}
if limiter.Name == req.Limiter {
resp.Data = limiter
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createRateLimiterRequest
type createRateLimiterRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getRecorderListRequest
type getRecorderListRequest struct {
}
// successful operation.
// swagger:response getRecorderListResponse
type getRecorderListResponse struct {
// in: body
Data recorderList
}
type recorderList struct {
Count int `json:"count"`
List []*config.RecorderConfig `json:"list"`
}
func getRecorderList(ctx *gin.Context) {
// swagger:route GET /config/recorders Recorder getRecorderListRequest
//
// Get recorder list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getRecorderListResponse
var req getRecorderListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Recorders
var resp getRecorderListResponse
resp.Data = recorderList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getRecorderRequest
type getRecorderRequest struct {
// in: path
// required: true
Recorder string `uri:"recorder" json:"recorder"`
}
// successful operation.
// swagger:response getRecorderResponse
type getRecorderResponse struct {
// in: body
Data *config.RecorderConfig
}
func getRecorder(ctx *gin.Context) {
// swagger:route GET /config/recorders/{recorder} Recorder getRecorderRequest
//
// Get recorder.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getRecorderResponse
var req getRecorderRequest
ctx.ShouldBindUri(&req)
var resp getRecorderResponse
for _, recorder := range config.Global().Recorders {
if recorder == nil {
continue
}
if recorder.Name == req.Recorder {
resp.Data = recorder
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createRecorderRequest
type createRecorderRequest struct {
// in: body
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/loader"
"github.com/go-gost/x/config/parsing/parser"
"github.com/go-gost/x/registry"
)
// swagger:parameters reloadConfigRequest
type reloadConfigRequest struct{}
// successful operation.
// swagger:response reloadConfigResponse
type reloadConfigResponse struct {
Data Response
}
func reloadConfig(ctx *gin.Context) {
// swagger:route POST /config/reload Reload reloadConfigRequest
//
// Hot reload config.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: reloadConfigResponse
cfg, err := parser.Parse()
if err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, err.Error()))
return
}
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, err.Error()))
return
}
for _, svc := range registry.ServiceRegistry().GetAll() {
svc := svc
go func() {
svc.Serve()
}()
}
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getResolverListRequest
type getResolverListRequest struct {
}
// successful operation.
// swagger:response getResolverListResponse
type getResolverListResponse struct {
// in: body
Data resolverList
}
type resolverList struct {
Count int `json:"count"`
List []*config.ResolverConfig `json:"list"`
}
func getResolverList(ctx *gin.Context) {
// swagger:route GET /config/resolvers Resolver getResolverListRequest
//
// Get resolver list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getResolverListResponse
var req getResolverListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Resolvers
var resp getResolverListResponse
resp.Data = resolverList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getResolverRequest
type getResolverRequest struct {
// in: path
// required: true
Resolver string `uri:"resolver" json:"resolver"`
}
// successful operation.
// swagger:response getResolverResponse
type getResolverResponse struct {
// in: body
Data *config.ResolverConfig
}
func getResolver(ctx *gin.Context) {
// swagger:route GET /config/resolvers/{resolver} Resolver getResolverRequest
//
// Get resolver.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getResolverResponse
var req getResolverRequest
ctx.ShouldBindUri(&req)
var resp getResolverResponse
for _, resolver := range config.Global().Resolvers {
if resolver == nil {
continue
}
if resolver.Name == req.Resolver {
resp.Data = resolver
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createResolverRequest
type createResolverRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getRouterListRequest
type getRouterListRequest struct {
}
// successful operation.
// swagger:response getRouterListResponse
type getRouterListResponse struct {
// in: body
Data routerList
}
type routerList struct {
Count int `json:"count"`
List []*config.RouterConfig `json:"list"`
}
func getRouterList(ctx *gin.Context) {
// swagger:route GET /config/routers Router getRouterListRequest
//
// Get router list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getRouterListResponse
var req getRouterListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Routers
var resp getRouterListResponse
resp.Data = routerList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getRouterRequest
type getRouterRequest struct {
// in: path
// required: true
Router string `uri:"router" json:"router"`
}
// successful operation.
// swagger:response getRouterResponse
type getRouterResponse struct {
// in: body
Data *config.RouterConfig
}
func getRouter(ctx *gin.Context) {
// swagger:route GET /config/routers/{router} Router getRouterRequest
//
// Get router.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getRouterResponse
var req getRouterRequest
ctx.ShouldBindUri(&req)
var resp getRouterResponse
for _, router := range config.Global().Routers {
if router == nil {
continue
}
if router.Name == req.Router {
resp.Data = router
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createRouterRequest
type createRouterRequest struct {
// in: body
+87
View File
@@ -11,6 +11,93 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getSDListRequest
type getSDListRequest struct {
}
// successful operation.
// swagger:response getSDListResponse
type getSDListResponse struct {
// in: body
Data sdList
}
type sdList struct {
Count int `json:"count"`
List []*config.SDConfig `json:"list"`
}
func getSDList(ctx *gin.Context) {
// swagger:route GET /config/sds SD getSDListRequest
//
// Get sd list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getSDListResponse
var req getSDListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().SDs
var resp getSDListResponse
resp.Data = sdList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getSDRequest
type getSDRequest struct {
// in: path
// required: true
SD string `uri:"sd" json:"sd"`
}
// successful operation.
// swagger:response getSDResponse
type getSDResponse struct {
// in: body
Data *config.SDConfig
}
func getSD(ctx *gin.Context) {
// swagger:route GET /config/sds/{sd} SD getSDRequest
//
// Get sd.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getSDResponse
var req getSDRequest
ctx.ShouldBindUri(&req)
var resp getSDResponse
for _, sd := range config.Global().SDs {
if sd == nil {
continue
}
if sd.Name == req.SD {
resp.Data = sd
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createSDRequest
type createSDRequest struct {
// in: body
+95
View File
@@ -11,6 +11,101 @@ import (
"github.com/go-gost/x/registry"
)
// swagger:parameters getServiceListRequest
type getServiceListRequest struct {
}
// successful operation.
// swagger:response getServiceListResponse
type getServiceListResponse struct {
// in: body
Data serviceList
}
type serviceList struct {
Count int `json:"count"`
List []*config.ServiceConfig `json:"list"`
}
func getServiceList(ctx *gin.Context) {
// swagger:route GET /config/services Service getServiceListRequest
//
// Get service list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getServiceListResponse
var req getServiceListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Services
for _, svc := range list {
if svc == nil {
continue
}
fillServiceStatus(svc)
}
var resp getServiceListResponse
resp.Data = serviceList{
Count: len(list),
List: list,
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters getServiceRequest
type getServiceRequest struct {
// in: path
// required: true
Service string `uri:"service" json:"service"`
}
// successful operation.
// swagger:response getServiceResponse
type getServiceResponse struct {
// in: body
Data *config.ServiceConfig
}
func getService(ctx *gin.Context) {
// swagger:route GET /config/services/{service} Service getServiceRequest
//
// Get service.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getServiceResponse
var req getServiceRequest
ctx.ShouldBindUri(&req)
var resp getServiceResponse
for _, service := range config.Global().Services {
if service == nil {
continue
}
if service.Name == req.Service {
fillServiceStatus(service)
resp.Data = service
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createServiceRequest
type createServiceRequest struct {
// in: body
+1 -1
View File
@@ -35,7 +35,7 @@ func mwBasicAuth(auther auth.Authenticator) gin.HandlerFunc {
return
}
u, p, _ := c.Request.BasicAuth()
if _, ok := auther.Authenticate(c, u, p); !ok {
if _, ok := auther.Authenticate(c, u, p, auth.WithService("@api")); !ok {
c.Writer.Header().Set("WWW-Authenticate", "Basic")
c.JSON(http.StatusUnauthorized, Response{
Code: http.StatusUnauthorized,
+96
View File
@@ -0,0 +1,96 @@
package service
import (
"net"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/service"
"github.com/go-gost/x/api"
)
type options struct {
accessLog bool
pathPrefix string
auther auth.Authenticator
}
type Option func(*options)
func PathPrefixOption(pathPrefix string) Option {
return func(o *options) {
o.pathPrefix = pathPrefix
}
}
func AccessLogOption(enable bool) Option {
return func(o *options) {
o.accessLog = enable
}
}
func AutherOption(auther auth.Authenticator) Option {
return func(o *options) {
o.auther = auther
}
}
type server struct {
s *http.Server
ln net.Listener
cclose chan struct{}
}
func NewService(network, addr string, opts ...Option) (service.Service, error) {
if network == "" {
network = "tcp"
}
ln, err := net.Listen(network, addr)
if err != nil {
return nil, err
}
var options options
for _, opt := range opts {
opt(&options)
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
api.Register(r, &api.Options{
AccessLog: options.accessLog,
PathPrefix: options.pathPrefix,
Auther: options.auther,
})
return &server{
s: &http.Server{
Handler: r,
},
ln: ln,
cclose: make(chan struct{}),
}, nil
}
func (s *server) Serve() error {
return s.s.Serve(s.ln)
}
func (s *server) Addr() net.Addr {
return s.ln.Addr()
}
func (s *server) Close() error {
return s.s.Close()
}
func (s *server) IsClosed() bool {
select {
case <-s.cclose:
return true
default:
return false
}
}
+898 -8
View File
File diff suppressed because it is too large Load Diff
+85 -40
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"io"
"maps"
"strings"
"sync"
"time"
@@ -67,6 +68,7 @@ type authenticator struct {
mu sync.RWMutex
cancelFunc context.CancelFunc
options options
logger logger.Logger
}
// NewAuthenticator creates an Authenticator that authenticates client by pre-defined user mapping.
@@ -75,23 +77,19 @@ func NewAuthenticator(opts ...Option) auth.Authenticator {
for _, opt := range opts {
opt(&options)
}
if options.logger == nil {
options.logger = xlogger.Nop()
}
ctx, cancel := context.WithCancel(context.TODO())
ctx, cancel := context.WithCancel(context.Background())
p := &authenticator{
kvs: make(map[string]string),
cancelFunc: cancel,
options: options,
logger: options.logger,
}
if p.logger == nil {
p.logger = xlogger.Nop()
}
if err := p.reload(ctx); err != nil {
options.logger.Warnf("reload: %v", err)
}
if p.options.period > 0 {
go p.periodReload(ctx)
}
go p.periodReload(ctx)
return p
}
@@ -114,7 +112,14 @@ func (p *authenticator) Authenticate(ctx context.Context, user, password string,
}
func (p *authenticator) periodReload(ctx context.Context) error {
if err := p.reload(ctx); err != nil {
p.logger.Warnf("reload: %v", err)
}
period := p.options.period
if period <= 0 {
return nil
}
if period < time.Second {
period = time.Second
}
@@ -125,7 +130,7 @@ func (p *authenticator) periodReload(ctx context.Context) error {
select {
case <-ticker.C:
if err := p.reload(ctx); err != nil {
p.options.logger.Warnf("reload: %v", err)
p.logger.Warnf("reload: %v", err)
// return err
}
case <-ctx.Done():
@@ -134,81 +139,92 @@ func (p *authenticator) periodReload(ctx context.Context) error {
}
}
func (p *authenticator) reload(ctx context.Context) (err error) {
func (p *authenticator) reload(ctx context.Context) error {
kvs := make(map[string]string)
for k, v := range p.options.auths {
kvs[k] = v
}
maps.Copy(kvs, p.options.auths)
m, err := p.load(ctx)
for k, v := range m {
kvs[k] = v
if err != nil {
return err
}
maps.Copy(kvs, m)
p.options.logger.Debugf("load items %d", len(m))
p.logger.Debugf("load items %d", len(m))
p.mu.Lock()
defer p.mu.Unlock()
p.kvs = kvs
return
return nil
}
func (p *authenticator) load(ctx context.Context) (m map[string]string, err error) {
m = make(map[string]string)
func (p *authenticator) load(ctx context.Context) (map[string]string, error) {
m := make(map[string]string)
var loadErr error
if p.options.fileLoader != nil {
if mapper, ok := p.options.fileLoader.(loader.Mapper); ok {
auths, er := mapper.Map(ctx)
if er != nil {
p.options.logger.Warnf("file loader: %v", er)
p.logger.Warnf("file loader: %v", er)
loadErr = er
}
m = auths
} else {
r, er := p.options.fileLoader.Load(ctx)
if er != nil {
p.options.logger.Warnf("file loader: %v", er)
p.logger.Warnf("file loader: %v", er)
loadErr = er
}
if auths, _ := p.parseAuths(r); auths != nil {
if auths, err := p.parseAuths(r); err == nil {
m = auths
} else {
p.logger.Warnf("file loader parse: %v", err)
}
}
}
if p.options.redisLoader != nil {
if mapper, ok := p.options.fileLoader.(loader.Mapper); ok {
if mapper, ok := p.options.redisLoader.(loader.Mapper); ok {
auths, er := mapper.Map(ctx)
if er != nil {
p.options.logger.Warnf("file loader: %v", er)
}
for k, v := range auths {
m[k] = v
p.logger.Warnf("redis loader: %v", er)
if loadErr == nil {
loadErr = er
}
}
maps.Copy(m, auths)
} else {
r, er := p.options.redisLoader.Load(ctx)
if er != nil {
p.options.logger.Warnf("redis loader: %v", er)
}
if auths, _ := p.parseAuths(r); auths != nil {
for k, v := range auths {
m[k] = v
p.logger.Warnf("redis loader: %v", er)
if loadErr == nil {
loadErr = er
}
}
if auths, err := p.parseAuths(r); err == nil {
maps.Copy(m, auths)
} else {
p.logger.Warnf("redis loader parse: %v", err)
}
}
}
if p.options.httpLoader != nil {
r, er := p.options.httpLoader.Load(ctx)
if er != nil {
p.options.logger.Warnf("http loader: %v", er)
}
if auths, _ := p.parseAuths(r); auths != nil {
for k, v := range auths {
m[k] = v
p.logger.Warnf("http loader: %v", er)
if loadErr == nil {
loadErr = er
}
}
if auths, err := p.parseAuths(r); err == nil {
maps.Copy(m, auths)
} else {
p.logger.Warnf("http loader parse: %v", err)
}
}
return
return m, loadErr
}
func (p *authenticator) parseAuths(r io.Reader) (auths map[string]string, err error) {
@@ -250,5 +266,34 @@ func (p *authenticator) Close() error {
if p.options.redisLoader != nil {
p.options.redisLoader.Close()
}
if p.options.httpLoader != nil {
p.options.httpLoader.Close()
}
return nil
}
type authenticatorGroup struct {
authers []auth.Authenticator
}
func AuthenticatorGroup(authers ...auth.Authenticator) auth.Authenticator {
return &authenticatorGroup{
authers: authers,
}
}
func (p *authenticatorGroup) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
if len(p.authers) == 0 {
return "", false
}
for _, auther := range p.authers {
if auther == nil {
continue
}
if id, ok := auther.Authenticate(ctx, user, password, opts...); ok {
return id, ok
}
}
return "", false
}
+1100
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -7,7 +7,7 @@ import (
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
"github.com/go-gost/plugin/auth/proto"
ctxvalue "github.com/go-gost/x/ctx"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/plugin"
"google.golang.org/grpc"
)
@@ -51,11 +51,21 @@ func (p *grpcPlugin) Authenticate(ctx context.Context, user, password string, op
return "", false
}
var options auth.Options
for _, opt := range opts {
opt(&options)
}
var clientAddr string
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: string(ctxvalue.ClientAddrFromContext(ctx)),
Client: clientAddr,
})
if err != nil {
p.log.Error(err)
+252
View File
@@ -0,0 +1,252 @@
package auth
import (
"context"
"io"
"net"
"os"
"testing"
"github.com/go-gost/core/auth"
corelogger "github.com/go-gost/core/logger"
"github.com/go-gost/plugin/auth/proto"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/plugin"
xlogger "github.com/go-gost/x/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
func TestMain(m *testing.M) {
corelogger.SetDefault(xlogger.Nop())
os.Exit(m.Run())
}
func newTestGRPCConn(t *testing.T, srv proto.AuthenticatorServer) (*grpc.ClientConn, func()) {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
gsrv := grpc.NewServer()
proto.RegisterAuthenticatorServer(gsrv, srv)
go gsrv.Serve(lis)
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
return conn, func() {
conn.Close()
gsrv.Stop()
}
}
func TestGRPCPlugin_NilClient(t *testing.T) {
p := &grpcPlugin{
conn: nil,
client: nil,
log: xlogger.Nop(),
}
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Authenticate_Success(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: true, id: "user1"})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAuthenticatorClient(conn),
log: xlogger.Nop(),
}
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "user1", id)
}
func TestGRPCPlugin_Authenticate_Fail(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: false, id: ""})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAuthenticatorClient(conn),
log: xlogger.Nop(),
}
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestGRPCPlugin_Authenticate_WithService(t *testing.T) {
server := &testAuthServer{ok: true, id: "srv"}
conn, cleanup := newTestGRPCConn(t, server)
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAuthenticatorClient(conn),
log: xlogger.Nop(),
}
id, ok := p.Authenticate(context.Background(), "u", "p", auth.WithService("myservice"))
assert.True(t, ok)
assert.Equal(t, "srv", id)
assert.Equal(t, "myservice", server.lastService)
}
func TestGRPCPlugin_Authenticate_WithClientAddr(t *testing.T) {
server := &testAuthServer{ok: true, id: "addr"}
conn, cleanup := newTestGRPCConn(t, server)
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAuthenticatorClient(conn),
log: xlogger.Nop(),
}
ctx := context.Background()
ctx = xctx.ContextWithSrcAddr(ctx, &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234})
id, ok := p.Authenticate(ctx, "u", "p")
assert.True(t, ok)
assert.Equal(t, "addr", id)
assert.Equal(t, "10.0.0.1:1234", server.lastClient)
}
func TestGRPCPlugin_Authenticate_WithoutClientAddr(t *testing.T) {
server := &testAuthServer{ok: true, id: "noclient"}
conn, cleanup := newTestGRPCConn(t, server)
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAuthenticatorClient(conn),
log: xlogger.Nop(),
}
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "noclient", id)
assert.Empty(t, server.lastClient)
}
func TestGRPCPlugin_Authenticate_ServerError(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &errorAuthServer{})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewAuthenticatorClient(conn),
log: xlogger.Nop(),
}
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestNewGRPCPlugin_RealConn(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
gsrv := grpc.NewServer()
proto.RegisterAuthenticatorServer(gsrv, &testAuthServer{ok: true, id: "real"})
go gsrv.Serve(lis)
defer gsrv.Stop()
p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500))
require.NotNil(t, p)
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "real", id)
assert.NoError(t, p.(io.Closer).Close())
}
func TestNewGRPCPlugin_InvalidAddr(t *testing.T) {
// Null byte causes grpc.NewClient target parsing to fail synchronously.
// This covers the log.Error(err) path.
// NOTE: depends on grpc-go's net/url control-character validation.
// If a future grpc version changes target parsing, this test may silently
// stop covering the error branch.
p := NewGRPCPlugin("test", "\x00")
require.NotNil(t, p)
// conn is nil, so client is nil, so Authenticate returns false
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestGRPCPlugin_Close_NilConn(t *testing.T) {
p := &grpcPlugin{
conn: nil,
client: nil,
}
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Close_NonCloserConn(t *testing.T) {
p := &grpcPlugin{
conn: &nonCloserConn{},
client: nil,
}
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Close_WithRealConn(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: true})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: nil,
log: xlogger.Nop(),
}
assert.NoError(t, p.Close())
}
// --- test helpers ---
type testAuthServer struct {
proto.UnimplementedAuthenticatorServer
ok bool
id string
lastService string
lastClient string
}
func (s *testAuthServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateReply, error) {
s.lastService = req.Service
s.lastClient = req.Client
return &proto.AuthenticateReply{Ok: s.ok, Id: s.id}, nil
}
type errorAuthServer struct {
proto.UnimplementedAuthenticatorServer
}
func (s *errorAuthServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateReply, error) {
return nil, status.Error(codes.Internal, "internal error")
}
type nonCloserConn struct{}
func (n *nonCloserConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error {
return nil
}
func (n *nonCloserConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return nil, nil
}
var _ grpc.ClientConnInterface = (*nonCloserConn)(nil)
var _ io.Closer = (*grpcPlugin)(nil)
+16 -2
View File
@@ -4,15 +4,17 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
ctxvalue "github.com/go-gost/x/ctx"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/plugin"
)
type httpPluginRequest struct {
Service string `json:"service"`
Username string `json:"username"`
Password string `json:"password"`
Client string `json:"client"`
@@ -53,10 +55,21 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
return
}
var options auth.Options
for _, opt := range opts {
opt(&options)
}
var clientAddr string
if v := xctx.SrcAddrFromContext(ctx); v != nil {
clientAddr = v.String()
}
rb := httpPluginRequest{
Service: options.Service,
Username: user,
Password: password,
Client: string(ctxvalue.ClientAddrFromContext(ctx)),
Client: clientAddr,
}
v, err := json.Marshal(&rb)
if err != nil {
@@ -79,6 +92,7 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return
}
+206
View File
@@ -0,0 +1,206 @@
package auth
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-gost/core/auth"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHTTPPlugin(t *testing.T) {
p := NewHTTPPlugin("test", "http://localhost:9999")
require.NotNil(t, p)
hp, ok := p.(*httpPlugin)
require.True(t, ok)
assert.Equal(t, "http://localhost:9999", hp.url)
assert.NotNil(t, hp.client)
assert.NotNil(t, hp.log)
}
func TestHTTPPlugin_Authenticate_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true, "id": "user1"}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "user1", id)
}
func TestHTTPPlugin_Authenticate_Fail(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": false, "id": ""}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestHTTPPlugin_Authenticate_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestHTTPPlugin_Authenticate_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`not json`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestHTTPPlugin_Authenticate_ConnectionRefused(t *testing.T) {
p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0))
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestHTTPPlugin_Authenticate_NilClient(t *testing.T) {
hp := &httpPlugin{
url: "http://localhost",
client: nil,
}
id, ok := hp.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestHTTPPlugin_Authenticate_WithService(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true, "id": "svc"}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
id, ok := p.Authenticate(context.Background(), "u", "p", auth.WithService("myservice"))
assert.True(t, ok)
assert.Equal(t, "svc", id)
}
func TestHTTPPlugin_Authenticate_WithHeader(t *testing.T) {
var receivedHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeader = r.Header.Get("X-Custom")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true, "id": "hdr"}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{
"X-Custom": []string{"test-value"},
}))
id, ok := p.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "hdr", id)
assert.Equal(t, "test-value", receivedHeader)
}
func TestHTTPPlugin_Authenticate_NoHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true, "id": "nohdr"}`))
}))
defer srv.Close()
hp := &httpPlugin{
url: srv.URL,
client: plugin.NewHTTPClient(&plugin.Options{}),
header: nil,
}
id, ok := hp.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "nohdr", id)
}
func TestHTTPPlugin_Authenticate_WithClientAddr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true, "id": "addr"}`))
}))
defer srv.Close()
hp := &httpPlugin{
url: srv.URL,
client: plugin.NewHTTPClient(&plugin.Options{}),
}
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 5678})
id, ok := hp.Authenticate(ctx, "u", "p")
assert.True(t, ok)
assert.Equal(t, "addr", id)
}
func TestHTTPPlugin_Authenticate_WithoutClientAddr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true, "id": "noaddr"}`))
}))
defer srv.Close()
hp := &httpPlugin{
url: srv.URL,
client: plugin.NewHTTPClient(&plugin.Options{}),
}
id, ok := hp.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "noaddr", id)
}
func TestHTTPPlugin_Authenticate_BadURL(t *testing.T) {
hp := &httpPlugin{
url: "http://invalid hostname",
client: plugin.NewHTTPClient(&plugin.Options{}),
}
id, ok := hp.Authenticate(context.Background(), "u", "p")
assert.False(t, ok)
assert.Empty(t, id)
}
func TestHTTPPlugin_Authenticate_WithAllOptions(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer mytoken", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true, "id": "all"}`))
}))
defer srv.Close()
hp := &httpPlugin{
url: srv.URL,
client: plugin.NewHTTPClient(&plugin.Options{}),
header: http.Header{"Authorization": []string{"Bearer mytoken"}},
}
id, ok := hp.Authenticate(context.Background(), "u", "p")
assert.True(t, ok)
assert.Equal(t, "all", id)
}
+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
}
+371 -97
View File
@@ -1,8 +1,25 @@
// Package bypass implements address-based routing that decides whether a
// target address should skip the proxy chain and connect directly.
//
// The package provides:
// - NewBypass: a local bypass that matches addresses against CIDR, IP range,
// wildcard, and exact-address patterns, with optional periodic reload from
// file, Redis, or HTTP sources.
// - BypassGroup: composes multiple bypass rules; whitelist rules use AND
// logic, blacklist rules use OR logic (evaluated only if whitelist fails).
// - Plugin-based bypass (gRPC and HTTP) in the plugin sub-package.
//
// 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.
package bypass
import (
"bufio"
"context"
"errors"
"io"
"net"
"strings"
@@ -11,112 +28,247 @@ import (
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/logger"
ctxvalue "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/matcher"
xnet "github.com/go-gost/x/internal/net"
xlogger "github.com/go-gost/x/logger"
"github.com/gobwas/glob"
)
// ErrBypass is returned by wrapped connections when the bypass rule
// determines that the connection should skip the proxy chain.
var ErrBypass = errors.New("bypass")
// options holds the configuration for a localBypass instance.
type options struct {
whitelist bool
matchers []string
fileLoader loader.Loader
// whitelist toggles between blacklist and whitelist mode.
// When false (default): matching addresses bypass the proxy.
// 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
// fileLoader loads patterns from a file or directory.
fileLoader loader.Loader
// redisLoader loads patterns from a Redis set or key.
redisLoader loader.Loader
httpLoader loader.Loader
period time.Duration
logger logger.Logger
// httpLoader loads patterns from an HTTP endpoint.
httpLoader loader.Loader
// period controls the interval between automatic reloads.
// Values less than 1 second are clamped to 1 second.
// A value <= 0 disables periodic reload (load once at startup).
period time.Duration
// logger is used for debug and warning messages.
// Falls back to a no-op logger if nil.
logger logger.Logger
}
// Option is a functional option for configuring a Bypass instance.
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.
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) {
opts.matchers = matchers
}
}
// ReloadPeriodOption sets the interval between automatic reloads of bypass
// patterns from external loaders (file, Redis, HTTP).
func ReloadPeriodOption(period time.Duration) Option {
return func(opts *options) {
opts.period = period
}
}
// FileLoaderOption sets the file loader for reading bypass patterns from a
// file or directory.
func FileLoaderOption(fileLoader loader.Loader) Option {
return func(opts *options) {
opts.fileLoader = fileLoader
}
}
// RedisLoaderOption sets the Redis loader for reading bypass patterns from a
// Redis set or key.
func RedisLoaderOption(redisLoader loader.Loader) Option {
return func(opts *options) {
opts.redisLoader = redisLoader
}
}
// HTTPLoaderOption sets the HTTP loader for reading bypass patterns from an
// HTTP endpoint.
func HTTPLoaderOption(httpLoader loader.Loader) Option {
return func(opts *options) {
opts.httpLoader = httpLoader
}
}
// LoggerOption sets the logger for debug and warning messages.
func LoggerOption(logger logger.Logger) Option {
return func(opts *options) {
opts.logger = logger
}
}
type localBypass struct {
cidrMatcher matcher.Matcher
addrMatcher matcher.Matcher
wildcardMatcher matcher.Matcher
cancelFunc context.CancelFunc
options options
mu sync.RWMutex
// bypassDecision represents the outcome of evaluating a bypass rule.
type bypassDecision int
const (
// decisionBypass means the address should connect directly, skipping the proxy chain.
decisionBypass bypassDecision = iota
// decisionProxy means the address should go through the proxy chain.
decisionProxy
)
func (d bypassDecision) String() string {
switch d {
case decisionBypass:
return "bypass"
case decisionProxy:
return "proxy"
default:
return "unknown"
}
}
// NewBypass creates and initializes a new Bypass.
// The rules will be reversed if the reverse option is true.
// patternSet holds classified pattern matchers for a single bypass rule.
// All four matcher types are built together by classifyPatterns and swapped
// atomically under the write lock.
type patternSet struct {
cidr matcher.Matcher
addr matcher.Matcher
wildcard matcher.Matcher
ipRange matcher.Matcher
}
// matchAny reports whether addr matches any pattern in the set,
// trying IP range, address, CIDR, and wildcard matchers in order.
// Returns false if ps is nil.
func (ps *patternSet) matchAny(addr string) bool {
if ps == nil {
return false
}
if ps.ipRange.Match(addr) {
return true
}
if ps.addr.Match(addr) {
return true
}
host, _, _ := net.SplitHostPort(addr)
if host == "" {
host = addr
}
if ip := net.ParseIP(host); ip != nil && ps.cidr.Match(host) {
return true
}
return ps.wildcard.Match(addr)
}
// localBypass is a Bypass that matches addresses against local pattern
// matchers. Patterns are classified into CIDR, wildcard, IP range, and
// exact address matchers. Patterns can be loaded from static config,
// file, Redis, or HTTP sources with optional periodic reload.
type localBypass struct {
patterns *patternSet
options options
logger logger.Logger
mu sync.RWMutex
cancelFunc context.CancelFunc
}
// 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.
//
// If a reload period is configured, patterns from external loaders are
// refreshed automatically in the background.
func NewBypass(opts ...Option) bypass.Bypass {
var options options
for _, opt := range opts {
opt(&options)
}
ctx, cancel := context.WithCancel(context.TODO())
ctx, cancel := context.WithCancel(context.Background())
bp := &localBypass{
p := &localBypass{
cancelFunc: cancel,
options: options,
logger: options.logger,
}
if p.logger == nil {
p.logger = xlogger.Nop()
}
if err := bp.reload(ctx); err != nil {
options.logger.Warnf("reload: %v", err)
}
if bp.options.period > 0 {
go bp.periodReload(ctx)
if p.hasLoaders() {
go p.periodReload(ctx)
} else {
_ = p.reload(ctx)
}
return bp
return p
}
func (bp *localBypass) periodReload(ctx context.Context) error {
period := bp.options.period
// periodReload loads patterns immediately, then periodically reloads them
// from external sources at the configured interval. Returns when ctx is
// cancelled.
func (p *localBypass) periodReload(ctx context.Context) error {
if err := p.reload(ctx); err != nil {
p.logger.Warnf("reload: %v", err)
}
period := p.options.period
if period <= 0 {
return nil
}
if period < time.Second {
period = time.Second
}
ticker := time.NewTicker(period)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := bp.reload(ctx); err != nil {
bp.options.logger.Warnf("reload: %v", err)
// return err
if err := p.reload(ctx); err != nil {
p.logger.Warnf("reload: %v", err)
}
case <-ctx.Done():
return ctx.Err()
@@ -124,84 +276,117 @@ func (bp *localBypass) periodReload(ctx context.Context) error {
}
}
func (bp *localBypass) reload(ctx context.Context) error {
v, err := bp.load(ctx)
// reload loads patterns from all configured sources, classifies them into
// CIDR, wildcard, IP range, and address matchers, then atomically swaps the
// pattern set under the write lock.
func (p *localBypass) reload(ctx context.Context) error {
v, err := p.load(ctx)
if err != nil {
return err
}
patterns := append(bp.options.matchers, v...)
bp.options.logger.Debugf("load items %d", len(patterns))
patterns := append(p.options.matchers, v...)
p.logger.Debugf("load items %d", len(patterns))
p.mu.Lock()
if len(patterns) > 0 {
p.patterns = classifyPatterns(patterns, p.logger)
}
p.mu.Unlock()
return nil
}
// hasLoaders reports whether any external data source is configured,
// which determines whether a background reload goroutine is needed.
func (p *localBypass) hasLoaders() bool {
return p.options.fileLoader != nil || p.options.redisLoader != nil || p.options.httpLoader != nil || p.options.period > 0
}
// classifyPatterns sorts raw pattern strings into typed matchers.
// Invalid wildcard patterns are logged and fall through to address matching.
func classifyPatterns(patterns []string, log logger.Logger) *patternSet {
var addrs []string
var inets []*net.IPNet
var wildcards []string
var ipRanges []xnet.IPRange
for _, pattern := range patterns {
if _, inet, err := net.ParseCIDR(pattern); err == nil {
inets = append(inets, inet)
continue
}
if strings.ContainsAny(pattern, "*?") {
wildcards = append(wildcards, pattern)
if _, err := glob.Compile(pattern); err == nil {
wildcards = append(wildcards, pattern)
continue
}
log.Warnf("invalid wildcard pattern %q, treating as plain address", pattern)
}
r := xnet.IPRange{}
if err := r.Parse(pattern); err == nil {
ipRanges = append(ipRanges, r)
continue
}
addrs = append(addrs, pattern)
}
bp.mu.Lock()
defer bp.mu.Unlock()
bp.cidrMatcher = matcher.CIDRMatcher(inets)
bp.addrMatcher = matcher.AddrMatcher(addrs)
bp.wildcardMatcher = matcher.WildcardMatcher(wildcards)
return nil
return &patternSet{
cidr: matcher.CIDRMatcher(inets),
addr: matcher.AddrMatcher(addrs),
wildcard: matcher.WildcardMatcher(wildcards),
ipRange: matcher.IPRangeMatcher(ipRanges),
}
}
func (bp *localBypass) load(ctx context.Context) (patterns []string, err error) {
if bp.options.fileLoader != nil {
if lister, ok := bp.options.fileLoader.(loader.Lister); ok {
// load reads patterns from file, Redis and HTTP loaders if configured.
func (p *localBypass) load(ctx context.Context) (patterns []string, err error) {
if p.options.fileLoader != nil {
if lister, ok := p.options.fileLoader.(loader.Lister); ok {
list, er := lister.List(ctx)
if er != nil {
bp.options.logger.Warnf("file loader: %v", er)
p.logger.Warnf("file loader: %v", er)
}
for _, s := range list {
if line := bp.parseLine(s); line != "" {
if line := p.parseLine(s); line != "" {
patterns = append(patterns, line)
}
}
} else {
r, er := bp.options.fileLoader.Load(ctx)
r, er := p.options.fileLoader.Load(ctx)
if er != nil {
bp.options.logger.Warnf("file loader: %v", er)
p.logger.Warnf("file loader: %v", er)
}
if v, _ := bp.parsePatterns(r); v != nil {
if v, _ := p.parsePatterns(r); v != nil {
patterns = append(patterns, v...)
}
}
}
if bp.options.redisLoader != nil {
if lister, ok := bp.options.redisLoader.(loader.Lister); ok {
if p.options.redisLoader != nil {
if lister, ok := p.options.redisLoader.(loader.Lister); ok {
list, er := lister.List(ctx)
if er != nil {
bp.options.logger.Warnf("redis loader: %v", er)
p.logger.Warnf("redis loader: %v", er)
}
patterns = append(patterns, list...)
} else {
r, er := bp.options.redisLoader.Load(ctx)
r, er := p.options.redisLoader.Load(ctx)
if er != nil {
bp.options.logger.Warnf("redis loader: %v", er)
p.logger.Warnf("redis loader: %v", er)
}
if v, _ := bp.parsePatterns(r); v != nil {
if v, _ := p.parsePatterns(r); v != nil {
patterns = append(patterns, v...)
}
}
}
if bp.options.httpLoader != nil {
r, er := bp.options.httpLoader.Load(ctx)
if p.options.httpLoader != nil {
r, er := p.options.httpLoader.Load(ctx)
if er != nil {
bp.options.logger.Warnf("http loader: %v", er)
p.logger.Warnf("http loader: %v", er)
}
if v, _ := bp.parsePatterns(r); v != nil {
if v, _ := p.parsePatterns(r); v != nil {
patterns = append(patterns, v...)
}
}
@@ -209,14 +394,16 @@ func (bp *localBypass) load(ctx context.Context) (patterns []string, err error)
return
}
func (bp *localBypass) parsePatterns(r io.Reader) (patterns []string, err error) {
// parsePatterns reads lines from r, strips comments and whitespace, and
// returns non-empty lines as patterns.
func (p *localBypass) parsePatterns(r io.Reader) (patterns []string, err error) {
if r == nil {
return
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
if line := bp.parseLine(scanner.Text()); line != "" {
if line := p.parseLine(scanner.Text()); line != "" {
patterns = append(patterns, line)
}
}
@@ -225,61 +412,148 @@ func (bp *localBypass) parsePatterns(r io.Reader) (patterns []string, err error)
return
}
func (bp *localBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
if addr == "" || bp == nil {
func (p *localBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
if addr == "" || p == nil {
return false
}
matched := bp.matched(addr)
decision := p.decide(network, addr)
b := !bp.options.whitelist && matched ||
bp.options.whitelist && !matched
if b {
bp.options.logger.Debugf("bypass: %s, whitelist: %t", addr, bp.options.whitelist)
} else {
bp.options.logger.Debugf("pass: %s, whitelist: %t", addr, bp.options.whitelist)
log := p.logger.WithFields(map[string]any{
"sid": ctxvalue.SidFromContext(ctx),
})
log.Debugf("%s: %s, whitelist: %t", decision, addr, p.options.whitelist)
return decision == decisionBypass
}
// decide returns the bypass decision for the given address, applying the
// whitelist or blacklist mode to the pattern match result.
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
}
return b
if p.patterns == nil {
if p.options.network != "" {
if p.options.whitelist {
return decisionProxy
}
return decisionBypass
}
return decisionProxy
}
matched := p.patterns.matchAny(addr)
if p.options.whitelist {
// Whitelist mode: the pattern set specifies addresses that MUST use the proxy.
// Matched addresses go through the proxy; unmatched addresses bypass.
if matched {
return decisionProxy
}
return decisionBypass
}
// Blacklist mode: the pattern set specifies addresses that should bypass the proxy.
// Matched addresses bypass; unmatched addresses go through the proxy.
if matched {
return decisionBypass
}
return decisionProxy
}
func (p *localBypass) IsWhitelist() bool {
return p.options.whitelist
}
func (bp *localBypass) parseLine(s string) string {
// parseLine removes comments (starting with '#') and trims whitespace.
func (p *localBypass) parseLine(s string) string {
if n := strings.IndexByte(s, '#'); n >= 0 {
s = s[:n]
}
return strings.TrimSpace(s)
}
func (bp *localBypass) matched(addr string) bool {
bp.mu.RLock()
defer bp.mu.RUnlock()
if bp.addrMatcher.Match(addr) {
return true
func (p *localBypass) Close() error {
p.cancelFunc()
if p.options.fileLoader != nil {
p.options.fileLoader.Close()
}
host, _, _ := net.SplitHostPort(addr)
if host == "" {
host = addr
if p.options.redisLoader != nil {
p.options.redisLoader.Close()
}
if ip := net.ParseIP(host); ip != nil {
return bp.cidrMatcher.Match(host)
}
return bp.wildcardMatcher.Match(addr)
}
func (bp *localBypass) Close() error {
bp.cancelFunc()
if bp.options.fileLoader != nil {
bp.options.fileLoader.Close()
}
if bp.options.redisLoader != nil {
bp.options.redisLoader.Close()
if p.options.httpLoader != nil {
p.options.httpLoader.Close()
}
return nil
}
// bypassGroup composes multiple Bypass instances into a single rule.
// Whitelist rules use AND logic (all must match); blacklist rules use
// OR logic (any match triggers bypass) and are only evaluated when
// whitelist rules fail.
type bypassGroup struct {
bypasses []bypass.Bypass
}
// BypassGroup creates a composite Bypass from multiple rules.
// Whitelist rules use AND logic (all must match); blacklist rules use
// OR logic (any match triggers bypass) and are only evaluated when
// whitelist rules fail.
func BypassGroup(bypasses ...bypass.Bypass) bypass.Bypass {
return &bypassGroup{
bypasses: bypasses,
}
}
// Contains evaluates all bypass rules in the group with the following logic:
// - Whitelist rules: ALL must match (AND logic). If any whitelist rule
// returns false, the combined whitelist result is false.
// - Blacklist rules: only evaluated if the whitelist result is false.
// ANY matching blacklist rule triggers a bypass (OR logic).
func (p *bypassGroup) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
return p.evaluate(ctx, network, addr, opts...) == decisionBypass
}
// evaluate returns the bypass decision for the group by applying two-phase
// evaluation: whitelist rules use AND logic (all must agree), then blacklist
// rules use OR logic (any match wins), evaluated only if whitelist fails.
func (p *bypassGroup) evaluate(ctx context.Context, network, addr string, opts ...bypass.Option) bypassDecision {
// Phase 1: Whitelist AND — all must agree to bypass.
hasWhitelist := false
allWhitelistBypass := true
for _, bp := range p.bypasses {
if !bp.IsWhitelist() {
continue
}
hasWhitelist = true
if !bp.Contains(ctx, network, addr, opts...) {
allWhitelistBypass = false
break // short-circuit: one whitelist failure is enough
}
}
if hasWhitelist && allWhitelistBypass {
return decisionBypass
}
// Phase 2: Blacklist OR — any match triggers bypass.
for _, bp := range p.bypasses {
if bp.IsWhitelist() {
continue
}
if bp.Contains(ctx, network, addr, opts...) {
return decisionBypass
}
}
return decisionProxy
}
// IsWhitelist always returns false for a group, since the group may contain
// a mix of whitelist and blacklist rules.
func (p *bypassGroup) IsWhitelist() bool {
return false
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -1,3 +1,6 @@
// Package bypass implements plugin-based Bypass using gRPC and HTTP
// transports. Plugins delegate bypass decisions to an external process
// or service.
package bypass
import (
@@ -12,13 +15,18 @@ import (
"google.golang.org/grpc"
)
// grpcPlugin delegates bypass decisions to a remote gRPC service.
// If the connection fails or the client is nil, all addresses bypass
// the proxy (fail-open).
type grpcPlugin struct {
conn grpc.ClientConnInterface
client proto.BypassClient
log logger.Logger
}
// NewGRPCPlugin creates a Bypass plugin based on gRPC.
// NewGRPCPlugin creates a Bypass that delegates decisions to a gRPC
// bypass service at addr. On connection failure, the plugin logs the
// error and returns a fail-open instance (all addresses bypass).
func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) bypass.Bypass {
var options plugin.Options
for _, opt := range opts {
@@ -56,6 +64,7 @@ func (p *grpcPlugin) Contains(ctx context.Context, network, addr string, opts ..
r, err := p.client.Bypass(ctx,
&proto.BypassRequest{
Service: options.Service,
Network: network,
Addr: addr,
Client: string(ctxvalue.ClientIDFromContext(ctx)),
+222
View File
@@ -0,0 +1,222 @@
package bypass
import (
"context"
"io"
"net"
"os"
"testing"
"github.com/go-gost/core/bypass"
corelogger "github.com/go-gost/core/logger"
"github.com/go-gost/plugin/bypass/proto"
"github.com/go-gost/x/internal/plugin"
xlogger "github.com/go-gost/x/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
func TestMain(m *testing.M) {
corelogger.SetDefault(xlogger.Nop())
os.Exit(m.Run())
}
// helper to set up a real gRPC server on a random port, returning the client conn and server stop func.
func newTestGRPCConn(t *testing.T, srv proto.BypassServer) (*grpc.ClientConn, func()) {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
gsrv := grpc.NewServer()
proto.RegisterBypassServer(gsrv, srv)
go gsrv.Serve(lis)
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
return conn, func() {
conn.Close()
gsrv.Stop()
}
}
func TestGRPCPlugin_FailOpen_NilClient(t *testing.T) {
p := &grpcPlugin{
conn: nil,
client: nil,
log: xlogger.Nop(),
}
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Contains_Success(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: true})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewBypassClient(conn),
log: xlogger.Nop(),
}
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestGRPCPlugin_Contains_Deny(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: false})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewBypassClient(conn),
log: xlogger.Nop(),
}
assert.False(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestGRPCPlugin_Contains_WithService(t *testing.T) {
srv := &testBypassServer{ok: true}
conn, cleanup := newTestGRPCConn(t, srv)
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewBypassClient(conn),
log: xlogger.Nop(),
}
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", bypass.WithService("myservice")))
assert.Equal(t, "myservice", srv.lastService)
}
func TestGRPCPlugin_Contains_WithHostAndPath(t *testing.T) {
srv := &testBypassServer{ok: true}
conn, cleanup := newTestGRPCConn(t, srv)
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewBypassClient(conn),
log: xlogger.Nop(),
}
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1",
bypass.WithHostOption("example.com"),
bypass.WithPathOption("/api/v1"),
))
assert.Equal(t, "example.com", srv.lastHost)
assert.Equal(t, "/api/v1", srv.lastPath)
}
func TestGRPCPlugin_Contains_NilClient(t *testing.T) {
p := &grpcPlugin{
conn: nil,
client: nil,
}
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestGRPCPlugin_Contains_ServerError(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &errorBypassServer{})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: proto.NewBypassClient(conn),
log: xlogger.Nop(),
}
// Server returns an error; Contains should return true (fail-open)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestGRPCPlugin_Close_NilConn(t *testing.T) {
p := &grpcPlugin{
conn: nil,
client: nil,
}
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Close_NonCloserConn(t *testing.T) {
p := &grpcPlugin{
conn: &nonCloserConn{},
client: nil,
}
assert.NoError(t, p.Close())
}
func TestGRPCPlugin_Close_WithRealConn(t *testing.T) {
conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: true})
defer cleanup()
p := &grpcPlugin{
conn: conn,
client: nil,
log: xlogger.Nop(),
}
assert.NoError(t, p.Close())
}
func TestNewGRPCPlugin_RealConn(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
gsrv := grpc.NewServer()
proto.RegisterBypassServer(gsrv, &testBypassServer{ok: true})
go gsrv.Serve(lis)
defer gsrv.Stop()
p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500))
require.NotNil(t, p)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
assert.NoError(t, p.(io.Closer).Close())
}
func TestGRPCPlugin_IsWhitelist(t *testing.T) {
p := &grpcPlugin{}
assert.False(t, p.IsWhitelist())
}
// --- test helpers ---
type testBypassServer struct {
proto.UnimplementedBypassServer
ok bool
lastService string
lastHost string
lastPath string
}
func (s *testBypassServer) Bypass(ctx context.Context, req *proto.BypassRequest) (*proto.BypassReply, error) {
s.lastService = req.Service
s.lastHost = req.Host
s.lastPath = req.Path
return &proto.BypassReply{Ok: s.ok}, nil
}
type errorBypassServer struct {
proto.UnimplementedBypassServer
}
func (s *errorBypassServer) Bypass(ctx context.Context, req *proto.BypassRequest) (*proto.BypassReply, error) {
return nil, status.Error(codes.Internal, "internal error")
}
type nonCloserConn struct{}
func (n *nonCloserConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error {
return nil
}
func (n *nonCloserConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return nil, nil
}
var _ grpc.ClientConnInterface = (*nonCloserConn)(nil)
var _ io.Closer = (*grpcPlugin)(nil)
+35 -8
View File
@@ -12,7 +12,9 @@ import (
"github.com/go-gost/x/internal/plugin"
)
// httpPluginRequest is the JSON payload sent to a remote HTTP bypass service.
type httpPluginRequest struct {
Service string `json:"service"`
Network string `json:"network"`
Addr string `json:"addr"`
Client string `json:"client"`
@@ -20,10 +22,14 @@ type httpPluginRequest struct {
Path string `json:"path"`
}
// httpPluginResponse is the JSON response from a remote HTTP bypass service.
type httpPluginResponse struct {
OK bool `json:"ok"`
}
// httpPlugin delegates bypass decisions to a remote HTTP service.
// All error paths return true (fail-open) so that a down plugin
// does not block traffic.
type httpPlugin struct {
url string
client *http.Client
@@ -31,7 +37,9 @@ type httpPlugin struct {
log logger.Logger
}
// NewHTTPPlugin creates an Bypass plugin based on HTTP.
// NewHTTPPlugin creates a Bypass that delegates decisions to an HTTP
// bypass service at url. The service should accept POST requests with
// a JSON body and return {"ok": true/false}.
func NewHTTPPlugin(name string, url string, opts ...plugin.Option) bypass.Bypass {
var options plugin.Options
for _, opt := range opts {
@@ -49,9 +57,14 @@ func NewHTTPPlugin(name string, url string, opts ...plugin.Option) bypass.Bypass
}
}
func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) (ok bool) {
func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
if p.client == nil {
return
return true
}
log := p.log
if log == nil {
log = logger.Default()
}
var options bypass.Options
@@ -60,6 +73,7 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ..
}
rb := httpPluginRequest{
Service: options.Service,
Network: network,
Addr: addr,
Client: string(ctxvalue.ClientIDFromContext(ctx)),
@@ -68,12 +82,14 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ..
}
v, err := json.Marshal(&rb)
if err != nil {
return
log.Error(err)
return true
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(v))
if err != nil {
return
log.Error(err)
return true
}
if p.header != nil {
@@ -82,21 +98,32 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ..
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return
log.Error(err)
return true
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
return true
}
res := httpPluginResponse{}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return
log.Error(err)
return true
}
return res.OK
}
func (p *httpPlugin) Close() error {
if p.client != nil {
if tr := plugin.HTTPClientTransport(p.client); tr != nil {
tr.CloseIdleConnections()
}
}
return nil
}
func (p *httpPlugin) IsWhitelist() bool {
return false
}
+174
View File
@@ -0,0 +1,174 @@
package bypass
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-gost/core/bypass"
"github.com/go-gost/x/internal/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHTTPPlugin(t *testing.T) {
p := NewHTTPPlugin("test", "http://localhost:9999")
require.NotNil(t, p)
hp, ok := p.(*httpPlugin)
require.True(t, ok)
assert.Equal(t, "http://localhost:9999", hp.url)
assert.NotNil(t, hp.client)
assert.NotNil(t, hp.log)
}
func TestHTTPPlugin_Contains_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Contains_Deny(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": false}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.False(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Contains_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
// Non-200 returns true (fail-open)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Contains_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`not json`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
// Invalid JSON returns true (fail-open)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Contains_ConnectionRefused(t *testing.T) {
p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0))
// Connection refused returns true (fail-open)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Contains_NilClient(t *testing.T) {
hp := &httpPlugin{
url: "http://localhost",
client: nil,
}
// Nil client returns true (fail-open)
assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Contains_WithService(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", bypass.WithService("myservice")))
}
func TestHTTPPlugin_Contains_WithHostAndPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1",
bypass.WithHostOption("example.com"),
bypass.WithPathOption("/api/v1"),
))
}
func TestHTTPPlugin_Contains_WithCustomHeader(t *testing.T) {
var receivedHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeader = r.Header.Get("X-Custom")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{
"X-Custom": []string{"test-value"},
}))
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
assert.Equal(t, "test-value", receivedHeader)
}
func TestHTTPPlugin_Contains_NoHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
hp := &httpPlugin{
url: srv.URL,
client: plugin.NewHTTPClient(&plugin.Options{}),
header: nil,
}
assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Contains_BadURL(t *testing.T) {
hp := &httpPlugin{
url: "http://invalid hostname", // space causes NewRequest to fail
client: plugin.NewHTTPClient(&plugin.Options{}),
}
// Bad URL returns true (fail-open)
assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1"))
}
func TestHTTPPlugin_Close_NilClient(t *testing.T) {
hp := &httpPlugin{client: nil}
err := hp.Close()
assert.NoError(t, err)
}
func TestHTTPPlugin_Close_WithClient(t *testing.T) {
hp := &httpPlugin{
client: plugin.NewHTTPClient(&plugin.Options{}),
}
err := hp.Close()
assert.NoError(t, err)
}
func TestHTTPPlugin_IsWhitelist(t *testing.T) {
hp := &httpPlugin{}
assert.False(t, hp.IsWhitelist())
}
var _ io.Closer = (*httpPlugin)(nil)
+38 -1
View File
@@ -1,3 +1,31 @@
// Package chain implements the core routing infrastructure for GOST.
//
// It provides three key abstractions:
//
// - Router: top-level entry point that resolves addresses, selects routes
// via a Chainer, retries on failure, and records telemetry.
//
// - Chain: a named sequence of proxy hops (nodes). Each hop selects a node
// from its group, and the resulting Route carries traffic through every
// selected node in order.
//
// - Transport: bundles a dialer and connector for a single chain node. It
// handles Dial, Handshake, Connect, and Bind — the four steps needed to
// move traffic through a proxy hop.
//
// # Route traversal
//
// For a chain of N nodes, the first node is reached via Dial → Handshake,
// and each subsequent node via Connect → Handshake through the previous
// connection. On failure, connections are cleaned up and nodes are marked
// so selectors can deprioritize them.
//
// # Multiplexing
//
// When a node's transport supports multiplexing, Chain splits the route at
// that point: nodes before the multiplex-capable node form a sub-route that is
// copied into the transport, establishing a reusable tunnel for subsequent
// connections.
package chain
import (
@@ -45,6 +73,7 @@ type Chain struct {
logger logger.Logger
}
// NewChain creates a new Chain with the given name and options.
func NewChain(name string, opts ...ChainOption) *Chain {
var options ChainOptions
for _, opt := range opts {
@@ -61,11 +90,14 @@ func NewChain(name string, opts ...ChainOption) *Chain {
}
}
// AddHop appends a hop to the chain. Hops are traversed in order during
// route construction.
func (c *Chain) AddHop(hop hop.Hop) {
c.hops = append(c.hops, hop)
}
// Metadata implements metadata.Metadatable interface.
// Metadata returns the chain's metadata.
// Implements metadata.Metadatable interface.
func (c *Chain) Metadata() metadata.Metadata {
return c.metadata
}
@@ -79,6 +111,9 @@ func (c *Chain) Name() string {
return c.name
}
// 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.
func (c *Chain) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
if c == nil || len(c.hops) == 0 {
return nil
@@ -117,6 +152,8 @@ type chainGroup struct {
selector selector.Selector[chain.Chainer]
}
// 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}
}
+1769
View File
File diff suppressed because it is too large Load Diff
+26 -4
View File
@@ -19,14 +19,18 @@ import (
)
var (
// ErrEmptyRoute is returned when the configured chain produces a route
// with no nodes.
ErrEmptyRoute = errors.New("empty route")
)
var (
// DefaultRoute is a fallback route that dials the target directly
// without going through any proxy nodes.
DefaultRoute chain.Route = &defaultRoute{}
)
// defaultRoute is a Route without nodes.
// defaultRoute dials the target directly without any proxy nodes.
type defaultRoute struct{}
func (*defaultRoute) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (net.Conn, error) {
@@ -38,7 +42,7 @@ func (*defaultRoute) Dial(ctx context.Context, network, address string, opts ...
netd := dialer.Dialer{
Interface: options.Interface,
Netns: options.Netns,
Logger: options.Logger,
Log: options.Logger,
}
if options.SockOpts != nil {
netd.Mark = options.SockOpts.Mark
@@ -78,10 +82,16 @@ func (*defaultRoute) Bind(ctx context.Context, network, address string, opts ...
ReadQueueSize: options.UDPDataQueueSize,
ReadBufferSize: options.UDPDataBufferSize,
TTL: options.UDPConnTTL,
KeepAlive: true,
Keepalive: true,
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
@@ -98,6 +108,8 @@ type RouteOptions struct {
type RouteOption func(*RouteOptions)
// ChainRouteOption sets the parent chain on a route, enabling error
// tracking and metrics for the chain.
func ChainRouteOption(c chain.Chainer) RouteOption {
return func(o *RouteOptions) {
o.Chain = c
@@ -109,6 +121,7 @@ type chainRoute struct {
options RouteOptions
}
// NewRoute creates a new route with the given options.
func NewRoute(opts ...RouteOption) *chainRoute {
var options RouteOptions
for _, opt := range opts {
@@ -271,20 +284,29 @@ func (r *chainRoute) connect(ctx context.Context, logger logger.Logger) (conn ne
}
cc, err = preNode.Options().Transport.Connect(ctx, cn, "tcp", addr)
if err != nil {
if cc != nil {
cc.Close()
}
cn.Close()
if marker != nil {
marker.Mark()
}
return
}
cc, err = node.Options().Transport.Handshake(ctx, cc)
var cc2 net.Conn
cc2, err = node.Options().Transport.Handshake(ctx, cc)
if err != nil {
if cc2 != nil {
cc2.Close()
}
cc.Close()
cn.Close()
if marker != nil {
marker.Mark()
}
return
}
cc = cc2
if marker != nil {
marker.Reset()
}
+143 -74
View File
@@ -5,18 +5,26 @@ import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/recorder"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xnet "github.com/go-gost/x/internal/net"
)
// Router is the top-level routing entry point. It resolves addresses,
// selects routes through the configured chain, retries on failure, and
// records telemetry (address events, errors, metrics).
type Router struct {
options chain.RouterOptions
}
// NewRouter creates a Router with the given options. Defaults: 15s timeout,
// a logger with kind "router" if none is provided.
func NewRouter(opts ...chain.RouterOption) *Router {
r := &Router{}
for _, opt := range opts {
@@ -41,20 +49,23 @@ func (r *Router) Options() *chain.RouterOptions {
return &r.options
}
func (r *Router) Dial(ctx context.Context, network, address string) (conn net.Conn, err error) {
if r.options.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
defer cancel()
}
// 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.
// For UDP networks the returned connection is wrapped as a PacketConn if the
// underlying transport does not already implement net.PacketConn.
func (r *Router) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (conn net.Conn, err error) {
host := address
if h, _, _ := net.SplitHostPort(address); h != "" {
host = h
}
r.record(ctx, recorder.RecorderServiceRouterDialAddress, []byte(host))
conn, err = r.dial(ctx, network, address)
log := r.options.Logger.WithFields(map[string]any{
"sid": xctx.SidFromContext(ctx),
})
conn, err = r.dial(ctx, network, address, log, opts...)
if err != nil {
r.record(ctx, recorder.RecorderServiceRouterDialAddressError, []byte(host))
return
@@ -75,103 +86,161 @@ func (r *Router) record(ctx context.Context, name string, data []byte) error {
for _, rec := range r.options.Recorders {
if rec.Record == name {
err := rec.Recorder.Record(ctx, data)
if err != nil {
r.options.Logger.Errorf("record %s: %v", name, err)
}
return err
return rec.Recorder.Record(ctx, data)
}
}
return nil
}
func (r *Router) dial(ctx context.Context, network, address string) (conn net.Conn, err error) {
func (r *Router) dial(ctx context.Context, network, address string, log logger.Logger, callerOpts ...chain.DialOption) (conn net.Conn, err error) {
count := r.options.Retries + 1
if count <= 0 {
count = 1
}
r.options.Logger.Debugf("dial %s/%s", address, network)
log.Debugf("dial %s/%s", address, network)
for i := 0; i < count; i++ {
var ipAddr string
ipAddr, err = xnet.Resolve(ctx, "ip", address, r.options.Resolver, r.options.HostMapper, r.options.Logger)
if err != nil {
r.options.Logger.Error(err)
break
}
var route chain.Route
if r.options.Chain != nil {
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
}
if r.options.Logger.IsLevelEnabled(logger.DebugLevel) {
buf := bytes.Buffer{}
for _, node := range routePath(route) {
fmt.Fprintf(&buf, "%s@%s > ", node.Name, node.Addr)
func() {
ctx := ctx
if r.options.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
defer cancel()
}
fmt.Fprintf(&buf, "%s", ipAddr)
r.options.Logger.Debugf("route(retry=%d) %s", i, buf.String())
}
if route == nil {
route = DefaultRoute
}
conn, err = route.Dial(ctx, network, ipAddr,
chain.InterfaceDialOption(r.options.IfceName),
chain.NetnsDialOption(r.options.Netns),
chain.SockOptsDialOption(r.options.SockOpts),
chain.LoggerDialOption(r.options.Logger),
)
if err == nil {
buf := ictx.BufferFromContext(ctx)
if buf != nil {
buf.Reset()
}
var ipAddr string
ipAddr, err = xnet.Resolve(ctx, "ip", address, r.options.Resolver, r.options.HostMapper, log)
if err != nil {
log.Error(err)
return
}
if buf != nil {
buf.Reset()
}
var route chain.Route
if r.options.Chain != nil {
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
}
if buf == nil {
buf = &bytes.Buffer{}
}
for _, node := range routePath(route) {
fmt.Fprintf(buf, "%s@%s > ", node.Name, node.Addr)
}
fmt.Fprintf(buf, "%s", ipAddr)
log.Debugf("route(retry=%d) %s", i, buf.String())
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(ifceName),
chain.NetnsDialOption(r.options.Netns),
chain.SockOptsDialOption(r.options.SockOpts),
chain.LoggerDialOption(log),
}, callerOpts...)...,
)
if err == nil {
return
}
log.Errorf("route(retry=%d) %s", i, err)
}()
if conn != nil || err == nil {
break
}
r.options.Logger.Errorf("route(retry=%d) %s", i, err)
}
return
}
// Bind creates a listener bound to the given address through the configured
// chain. It retries up to Retries+1 times on failure.
func (r *Router) Bind(ctx context.Context, network, address string, opts ...chain.BindOption) (ln net.Listener, err error) {
if r.options.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
defer cancel()
}
count := r.options.Retries + 1
if count <= 0 {
count = 1
}
r.options.Logger.Debugf("bind on %s/%s", address, network)
log := r.options.Logger.WithFields(map[string]any{
"sid": xctx.SidFromContext(ctx),
})
log.Debugf("bind on %s/%s", address, network)
for i := 0; i < count; i++ {
var route chain.Route
if r.options.Chain != nil {
route = r.options.Chain.Route(ctx, network, address)
if route == nil || len(route.Nodes()) == 0 {
err = ErrEmptyRoute
func() {
ctx := ctx
if r.options.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
defer cancel()
}
var route chain.Route
if r.options.Chain != nil {
route = r.options.Chain.Route(ctx, network, address)
if route == nil || len(route.Nodes()) == 0 {
err = ErrEmptyRoute
return
}
}
if log.IsLevelEnabled(logger.DebugLevel) {
buf := bytes.Buffer{}
for _, node := range routePath(route) {
fmt.Fprintf(&buf, "%s@%s > ", node.Name, node.Addr)
}
fmt.Fprintf(&buf, "%s", address)
log.Debugf("route(retry=%d) %s", i, buf.String())
}
if route == nil {
route = DefaultRoute
}
ln, err = route.Bind(ctx, network, address, opts...)
if err == nil {
return
}
}
if r.options.Logger.IsLevelEnabled(logger.DebugLevel) {
buf := bytes.Buffer{}
for _, node := range routePath(route) {
fmt.Fprintf(&buf, "%s@%s > ", node.Name, node.Addr)
}
fmt.Fprintf(&buf, "%s", address)
r.options.Logger.Debugf("route(retry=%d) %s", i, buf.String())
}
if route == nil {
route = DefaultRoute
}
ln, err = route.Bind(ctx, network, address, opts...)
if err == nil {
log.Errorf("route(retry=%d) %s", i, err)
}()
if ln != nil || err == nil {
break
}
r.options.Logger.Errorf("route(retry=%d) %s", i, err)
}
return
+8 -1
View File
@@ -10,12 +10,16 @@ import (
net_dialer "github.com/go-gost/x/internal/net/dialer"
)
// Transport bundles a dialer and connector for a single chain node.
// It implements chain.Transporter and handles the four operations in
// the proxy chain lifecycle: Dial, Handshake, Connect, and Bind.
type Transport struct {
dialer dialer.Dialer
connector connector.Connector
options chain.TransportOptions
}
// NewTransport creates a Transport with the given dialer and connector.
func NewTransport(d dialer.Dialer, c connector.Connector, opts ...chain.TransportOption) *Transport {
tr := &Transport{
dialer: d,
@@ -99,8 +103,11 @@ func (tr *Transport) Options() *chain.TransportOptions {
return nil
}
// Copy returns a shallow copy of the transport. Used by Chain when
// splitting a route at a multiplex-capable node so the sub-route can
// be embedded in the transport without sharing state across calls.
func (tr *Transport) Copy() chain.Transporter {
tr2 := &Transport{}
*tr2 = *tr
return tr
return tr2
}
+341 -64
View File
@@ -1,3 +1,7 @@
// Package cmd converts CLI command-line arguments (-L services, -F chain nodes)
// into structured config.Config objects. It supports URL-format service and node
// specifications, metadata query parameters, and auto-detection of handler,
// listener, dialer, and connector types via the registry.
package cmd
import (
@@ -10,20 +14,27 @@ import (
"strings"
"time"
mdutil "github.com/go-gost/core/metadata/util"
"github.com/go-gost/x/config"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/limiter/conn"
"github.com/go-gost/x/limiter/traffic"
mdx "github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/registry"
)
var (
ErrInvalidCmd = errors.New("invalid cmd")
// ErrInvalidCmd is returned when the command-line argument is empty.
ErrInvalidCmd = errors.New("invalid cmd")
// ErrInvalidNode is returned when a node specification cannot be parsed.
ErrInvalidNode = errors.New("invalid node")
)
// BuildConfigFromCmd converts CLI service (-L) and node (-F) arguments into
// a Config. Each node becomes a hop in a single chain; each service produces
// a ServiceConfig with auto-detected handler and listener types. Query
// parameters in the URL are parsed as metadata and routed to the appropriate
// config sub-struct (handler, listener, hop, connector, dialer, service).
func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error) {
namePrefix := ""
cfg := &config.Config{}
@@ -42,24 +53,6 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
return nil, err
}
nodeConfig, err := buildNodeConfig(url)
if err != nil {
return nil, err
}
nodeConfig.Name = fmt.Sprintf("%snode-0", namePrefix)
var nodes []*config.NodeConfig
for _, host := range strings.Split(nodeConfig.Addr, ",") {
if host == "" {
continue
}
nodeCfg := &config.NodeConfig{}
*nodeCfg = *nodeConfig
nodeCfg.Name = fmt.Sprintf("%snode-%d", namePrefix, len(nodes))
nodeCfg.Addr = host
nodes = append(nodes, nodeCfg)
}
m := map[string]any{}
for k, v := range url.Query() {
if len(v) > 0 {
@@ -71,8 +64,6 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
hopConfig := &config.HopConfig{
Name: fmt.Sprintf("%shop-%d", namePrefix, i),
Selector: parseSelector(m),
Nodes: nodes,
Metadata: m,
}
if v := mdutil.GetString(md, "bypass"); v != "" {
@@ -104,7 +95,9 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
resolverCfg.Nameservers = append(
resolverCfg.Nameservers,
&config.NameserverConfig{
Addr: rs,
Addr: rs,
Prefer: mdutil.GetString(md, "prefer"),
Only: mdutil.GetString(md, "only"),
},
)
}
@@ -135,15 +128,56 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
}
if v := mdutil.GetString(md, "interface"); v != "" {
hopConfig.Interface = v
m["hop.interface"] = v
delete(m, "interface")
}
if v := mdutil.GetInt(md, "so_mark"); v > 0 {
hopConfig.SockOpts = &config.SockOptsConfig{
Mark: v,
}
m["hop.so_mark"] = v
delete(m, "so_mark")
}
if v := mdutil.GetInt(md, "proxyProtocol"); v > 0 {
m["hop.proxyProtocol"] = v
delete(m, "proxyProtocol")
}
hopMd := map[string]any{}
for k, v := range m {
if strings.HasPrefix(k, "node.") ||
strings.HasPrefix(k, "connector.") ||
strings.HasPrefix(k, "dialer.") {
continue
}
if s, found := strings.CutPrefix(k, "hop."); found {
hopMd[s] = v
delete(m, k)
continue
}
hopMd[k] = v
}
hopConfig.Metadata = hopMd
nodeConfig, err := buildNodeConfig(url, m)
if err != nil {
return nil, err
}
nodeConfig.Name = fmt.Sprintf("%snode-0", namePrefix)
var nodes []*config.NodeConfig
for _, host := range strings.Split(nodeConfig.Addr, ",") {
if host == "" {
continue
}
nodeCfg := &config.NodeConfig{}
*nodeCfg = *nodeConfig
nodeCfg.Connector = copyConnectorConfig(nodeConfig.Connector)
nodeCfg.Dialer = copyDialerConfig(nodeConfig.Dialer)
nodeCfg.Metadata = copyMap(nodeConfig.Metadata)
nodeCfg.Name = fmt.Sprintf("%snode-%d", namePrefix, len(nodes))
nodeCfg.Addr = host
nodes = append(nodes, nodeCfg)
}
hopConfig.Nodes = nodes
chain.Hops = append(chain.Hops, hopConfig)
}
@@ -177,7 +211,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
@@ -240,6 +274,7 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
&config.NameserverConfig{
Addr: rs,
Prefer: mdutil.GetString(md, "prefer"),
Only: mdutil.GetString(md, "only"),
},
)
}
@@ -279,7 +314,7 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
}
if in != "" || out != "" {
limiter.Limits = append(limiter.Limits,
fmt.Sprintf("%s %s %s", traffic.GlobalLimitKey, in, out))
fmt.Sprintf("%s %s %s", traffic.ServiceLimitKey, in, out))
}
if cin != "" || cout != "" {
limiter.Limits = append(limiter.Limits,
@@ -317,6 +352,9 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
return cfg, nil
}
// cutHost extracts the host portion from a URL string, stripping any auth
// info (user:pass@). It returns the host and the remainder of the URL with
// the host portion removed.
func cutHost(s string) (host, remain string) {
if s == "" {
return
@@ -341,6 +379,10 @@ func cutHost(s string) (host, remain string) {
return
}
// buildServiceConfig converts a parsed service URL into one or more
// ServiceConfig entries. Scheme components (handler+listener) are resolved
// against the registry; path-based protocols use the URL path as the listen
// address; a non-empty path for other protocols indicates forwarding mode.
func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
namePrefix := ""
if v := os.Getenv("_GOST_ID"); v != "" {
@@ -358,9 +400,24 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
listener = schemes[1]
}
addrs := xnet.AddrPortRange(url.Host).Addrs()
if len(addrs) == 0 {
addrs = append(addrs, url.Host)
// For path-based protocols (Unix socket listeners and serial), use path as address
isPathBasedProtocol := listener == "unix" || listener == "runix" || listener == "serial"
var addrs []string
if isPathBasedProtocol {
path := url.EscapedPath()
if url.Host != "" {
addrs = append(addrs, url.Host+strings.TrimPrefix(path, "/"))
} else if path != "" {
addrs = append(addrs, path)
} else {
addrs = append(addrs, url.Host)
}
} else {
addrs = xnet.AddrPortRange(url.Host).Addrs()
if len(addrs) == 0 {
addrs = append(addrs, url.Host)
}
}
var services []*config.ServiceConfig
@@ -382,7 +439,8 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
var nodes []*config.ForwardNodeConfig
// forward mode
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" {
// For path-based protocols, path is address not forward target
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathBasedProtocol {
i := 0
for _, addr := range strings.Split(remotes, ",") {
addrs := xnet.AddrPortRange(addr).Addrs()
@@ -402,7 +460,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 {
@@ -411,6 +469,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{
@@ -435,10 +504,15 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
var auth *config.AuthConfig
if url.User != nil {
auth = &config.AuthConfig{
Username: url.User.Username(),
if strings.HasPrefix(url.Scheme, "ss") {
auth = decodeSIP002Auth(url)
}
if auth == nil {
auth = &config.AuthConfig{
Username: url.User.Username(),
}
auth.Password, _ = url.User.Password()
}
auth.Password, _ = url.User.Password()
}
m := map[string]any{}
@@ -449,7 +523,7 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
}
md := mdx.NewMetadata(m)
if sa := mdutil.GetString(md, "auth"); sa != "" {
if sa := mdutil.GetString(md, "auth"); sa != "" && auth == nil {
au, err := parseAuthFromCmd(sa)
if err != nil {
return nil, err
@@ -479,19 +553,63 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
}
if v := mdutil.GetString(md, "dns"); v != "" {
md.Set("dns", strings.Split(v, ","))
m["dns"] = strings.Split(v, ",")
}
if v := mdutil.GetString(md, "interface"); v != "" {
m["service.interface"] = v
delete(m, "interface")
}
if v := mdutil.GetInt(md, "so_mark"); v > 0 {
m["service.so_mark"] = v
delete(m, "so_mark")
}
if v := mdutil.GetInt(md, "proxyProtocol"); v > 0 {
m["service.proxyProtocol"] = v
delete(m, "proxyProtocol")
}
if v := mdutil.GetString(md, "netns"); v != "" {
m["service.netns"] = v
delete(m, "netns")
}
if v := mdutil.GetString(md, "netns.out"); v != "" {
m["service.netns.out"] = v
delete(m, "netns.out")
}
selector := parseSelector(m)
serviceMd := map[string]any{}
handleMd := map[string]any{}
listenerMd := map[string]any{}
for k, v := range m {
if s, found := strings.CutPrefix(k, "service."); found {
serviceMd[s] = v
continue
}
if s, found := strings.CutPrefix(k, "handler."); found {
handleMd[s] = v
continue
}
if s, found := strings.CutPrefix(k, "listener."); found {
listenerMd[s] = v
continue
}
handleMd[k] = v
listenerMd[k] = v
serviceMd[k] = v
}
handlerCfg := &config.HandlerConfig{
Type: handler,
Auth: auth,
Metadata: m,
Metadata: handleMd,
}
listenerCfg := &config.ListenerConfig{
Type: listener,
TLS: tlsConfig,
Metadata: m,
Metadata: listenerMd,
}
if listenerCfg.Type == "ssh" || listenerCfg.Type == "sshd" {
handlerCfg.Auth = nil
@@ -502,15 +620,18 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
if svc.Forwarder != nil {
svc.Forwarder.Selector = selector
}
svc.Handler = handlerCfg
svc.Listener = listenerCfg
svc.Metadata = m
svc.Handler = copyHandlerConfig(handlerCfg)
svc.Listener = copyListenerConfig(listenerCfg)
svc.Metadata = copyMap(serviceMd)
}
return services, nil
}
func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
// buildNodeConfig converts a parsed node URL into a NodeConfig. Scheme
// components (connector+dialer) are resolved against the registry; path-based
// dialers use the URL path as the node address.
func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error) {
var connector, dialer string
schemes := strings.Split(url.Scheme, "+")
if len(schemes) == 1 {
@@ -522,19 +643,8 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
dialer = schemes[1]
}
m := map[string]any{}
for k, v := range url.Query() {
if len(v) > 0 {
m[k] = v[0]
}
}
md := mdx.NewMetadata(m)
node := &config.NodeConfig{
Addr: url.Host,
Metadata: m,
}
if c := registry.ConnectorRegistry().Get(connector); c == nil {
connector = "http"
}
@@ -547,10 +657,15 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
var auth *config.AuthConfig
if url.User != nil {
auth = &config.AuthConfig{
Username: url.User.Username(),
if strings.HasPrefix(url.Scheme, "ss") {
auth = decodeSIP002Auth(url)
}
if auth == nil {
auth = &config.AuthConfig{
Username: url.User.Username(),
}
auth.Password, _ = url.User.Password()
}
auth.Password, _ = url.User.Password()
}
if sauth := mdutil.GetString(md, "auth"); sauth != "" && auth == nil {
@@ -591,15 +706,59 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
tlsConfig = nil
}
nodeMd := map[string]any{}
connectorMd := map[string]any{}
dialerMd := map[string]any{}
for k, v := range m {
if s, found := strings.CutPrefix(k, "node."); found {
nodeMd[s] = v
continue
}
if s, found := strings.CutPrefix(k, "connector."); found {
connectorMd[s] = v
continue
}
if s, found := strings.CutPrefix(k, "dialer."); found {
dialerMd[s] = v
continue
}
connectorMd[k] = v
dialerMd[k] = v
nodeMd[k] = v
}
// For path-based protocols (Unix socket dialers and serial), use path as address
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
}
} else {
nodeAddr = url.Host
}
node := &config.NodeConfig{
Addr: nodeAddr,
Metadata: nodeMd,
}
node.Connector = &config.ConnectorConfig{
Type: connector,
Auth: auth,
Metadata: m,
Metadata: connectorMd,
}
node.Dialer = &config.DialerConfig{
Type: dialer,
TLS: tlsConfig,
Metadata: m,
Metadata: dialerMd,
}
if node.Dialer.Type == "ssh" || node.Dialer.Type == "sshd" {
@@ -610,6 +769,9 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
return node, nil
}
// Norm normalizes a raw command-line string into a parsed URL. Missing schemes
// default to "auto" and "https" is rewritten to "http+tls". An empty string
// returns ErrInvalidCmd.
func Norm(s string) (*url.URL, error) {
s = strings.TrimSpace(s)
if s == "" {
@@ -631,10 +793,51 @@ func Norm(s string) (*url.URL, error) {
return url, nil
}
func parseAuthFromCmd(sa string) (*config.AuthConfig, error) {
v, err := base64.StdEncoding.DecodeString(sa)
// decodeSIP002Auth attempts to decode a Shadowsocks SIP002-style URL where
// the userinfo is a single base64-encoded "method:password" string with no
// separate password field. Returns nil if the URL does not use this format.
func decodeSIP002Auth(u *url.URL) *config.AuthConfig {
if u.User == nil {
return nil
}
_, hasPassword := u.User.Password()
if hasPassword {
return nil
}
username := u.User.Username()
if username == "" {
return nil
}
decoded, err := base64.RawURLEncoding.DecodeString(username)
if err != nil {
return nil, err
decoded, err = base64.StdEncoding.DecodeString(username)
if err != nil {
return nil
}
}
cs := string(decoded)
n := strings.IndexByte(cs, ':')
if n < 0 {
return nil
}
return &config.AuthConfig{
Username: cs[:n],
Password: cs[n+1:],
}
}
// parseAuthFromCmd decodes a base64-encoded "username:password" string from
// a query parameter into an AuthConfig. The password portion is optional.
func parseAuthFromCmd(sa string) (*config.AuthConfig, error) {
v, err := base64.RawURLEncoding.DecodeString(sa)
if err != nil {
v, err = base64.StdEncoding.DecodeString(sa)
if err != nil {
return nil, err
}
}
cs := string(v)
n := strings.IndexByte(cs, ':')
@@ -650,6 +853,9 @@ func parseAuthFromCmd(sa string) (*config.AuthConfig, error) {
}, nil
}
// parseSelector extracts selector configuration (strategy, maxFails,
// failTimeout) from metadata and removes the consumed keys. Returns nil
// if no selector parameters are present.
func parseSelector(m map[string]any) *config.SelectorConfig {
md := mdx.NewMetadata(m)
strategy := mdutil.GetString(md, "strategy")
@@ -680,3 +886,74 @@ func parseSelector(m map[string]any) *config.SelectorConfig {
FailTimeout: failTimeout,
}
}
func copyMap(m map[string]any) map[string]any {
if m == nil {
return nil
}
c := make(map[string]any, len(m))
for k, v := range m {
c[k] = v
}
return c
}
func copyAuth(cfg *config.AuthConfig) *config.AuthConfig {
if cfg == nil {
return nil
}
c := *cfg
return &c
}
func copyTLS(cfg *config.TLSConfig) *config.TLSConfig {
if cfg == nil {
return nil
}
c := *cfg
return &c
}
func copyConnectorConfig(cfg *config.ConnectorConfig) *config.ConnectorConfig {
if cfg == nil {
return nil
}
c := *cfg
c.Auth = copyAuth(cfg.Auth)
c.TLS = copyTLS(cfg.TLS)
c.Metadata = copyMap(cfg.Metadata)
return &c
}
func copyDialerConfig(cfg *config.DialerConfig) *config.DialerConfig {
if cfg == nil {
return nil
}
c := *cfg
c.Auth = copyAuth(cfg.Auth)
c.TLS = copyTLS(cfg.TLS)
c.Metadata = copyMap(cfg.Metadata)
return &c
}
func copyHandlerConfig(cfg *config.HandlerConfig) *config.HandlerConfig {
if cfg == nil {
return nil
}
c := *cfg
c.Auth = copyAuth(cfg.Auth)
c.TLS = copyTLS(cfg.TLS)
c.Metadata = copyMap(cfg.Metadata)
return &c
}
func copyListenerConfig(cfg *config.ListenerConfig) *config.ListenerConfig {
if cfg == nil {
return nil
}
c := *cfg
c.Auth = copyAuth(cfg.Auth)
c.TLS = copyTLS(cfg.TLS)
c.Metadata = copyMap(cfg.Metadata)
return &c
}
File diff suppressed because it is too large Load Diff
+188 -61
View File
@@ -3,6 +3,8 @@ package config
import (
"encoding/json"
"io"
"path/filepath"
"strings"
"sync"
"time"
@@ -115,12 +117,16 @@ type TLSConfig struct {
Validity time.Duration `yaml:",omitempty" json:"validity,omitempty"`
CommonName string `yaml:"commonName,omitempty" json:"commonName,omitempty"`
Organization string `yaml:",omitempty" json:"organization,omitempty"`
RejectUnknownSNI bool `yaml:"rejectUnknownSNI,omitempty" json:"rejectUnknownSNI,omitempty"`
ServerNames []string `yaml:"serverNames,omitempty" json:"serverNames,omitempty"`
}
type TLSOptions struct {
MinVersion string `yaml:"minVersion,omitempty" json:"minVersion,omitempty"`
MaxVersion string `yaml:"maxVersion,omitempty" json:"maxVersion,omitempty"`
CipherSuites []string `yaml:"cipherSuites,omitempty" json:"cipherSuites,omitempty"`
ALPN []string `yaml:"alpn,omitempty" json:"alpn,omitempty"`
}
type PluginConfig struct {
@@ -142,8 +148,9 @@ type AutherConfig struct {
}
type AuthConfig struct {
Username string `json:"username"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
File string `yaml:",omitempty" json:"file,omitempty"`
}
type SelectorConfig struct {
@@ -154,7 +161,7 @@ type SelectorConfig struct {
type AdmissionConfig struct {
Name string `json:"name"`
// DEPRECATED by whitelist since beta.4
// Deprecated: use whitelist instead
Reverse bool `yaml:",omitempty" json:"reverse,omitempty"`
Whitelist bool `yaml:",omitempty" json:"whitelist,omitempty"`
Matchers []string `yaml:",omitempty" json:"matchers,omitempty"`
@@ -167,9 +174,10 @@ type AdmissionConfig struct {
type BypassConfig struct {
Name string `json:"name"`
// DEPRECATED by whitelist since beta.4
// 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"`
@@ -185,6 +193,7 @@ type FileLoader struct {
type RedisLoader 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 string `yaml:",omitempty" json:"type,omitempty"`
@@ -250,7 +259,9 @@ type SDConfig struct {
}
type RouterRouteConfig struct {
Net string `json:"net"`
// Deprecated: use dst instead
Net string `yaml:",omitempty" json:"net,omitempty"`
Dst string `yaml:",omitempty" json:"dst,omitempty"`
Gateway string `json:"gateway"`
}
@@ -274,8 +285,9 @@ type RecorderConfig struct {
}
type FileRecorder struct {
Path string `json:"path"`
Sep string `yaml:",omitempty" json:"sep,omitempty"`
Path string `json:"path"`
Sep string `yaml:",omitempty" json:"sep,omitempty"`
Rotation *LogRotationConfig `yaml:",omitempty" json:"rotation,omitempty"`
}
type TCPRecorder struct {
@@ -284,13 +296,15 @@ type TCPRecorder struct {
}
type HTTPRecorder struct {
URL string `json:"url" yaml:"url"`
Timeout time.Duration `json:"timeout"`
URL string `yaml:"url" json:"url"`
Timeout time.Duration `yaml:",omitempty" json:"timeout,omitempty"`
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
}
type RedisRecorder 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 string `yaml:",omitempty" json:"type,omitempty"`
@@ -302,6 +316,11 @@ type RecorderObject struct {
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
type RewriterConfig struct {
Name string `json:"name"`
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
type LimiterConfig struct {
Name string `json:"name"`
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
@@ -343,7 +362,7 @@ type HandlerConfig struct {
}
type ForwarderConfig struct {
// DEPRECATED by hop field
// Deprecated: use hop instead
Name string `yaml:",omitempty" json:"name,omitempty"`
// the referenced hop name
Hop string `yaml:",omitempty" json:"hop,omitempty"`
@@ -357,18 +376,20 @@ type ForwardNodeConfig struct {
Network string `yaml:",omitempty" json:"network,omitempty"`
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
// DEPRECATED by filter.protocol
// Deprecated: use matcher instead
Protocol string `yaml:",omitempty" json:"protocol,omitempty"`
// DEPRECATED by filter.host
// Deprecated: use matcher instead
Host string `yaml:",omitempty" json:"host,omitempty"`
// DEPRECATED by filter.path
// Deprecated: use matcher instead
Path string `yaml:",omitempty" json:"path,omitempty"`
// DEPRECATED by http.auth
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
// Deprecated: use matcher instead
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
// Deprecated: use http.auth instead
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
type HTTPURLRewriteConfig struct {
@@ -376,17 +397,57 @@ type HTTPURLRewriteConfig struct {
Replacement string
}
type HTTPBodyRewriteConfig struct {
// filter by MIME types
Type string
Match string
Replacement string
// name of the rewriter plugin (via registry)
Rewriter string
}
type NodeFilterConfig struct {
Host string `yaml:",omitempty" json:"host,omitempty"`
Protocol string `yaml:",omitempty" json:"protocol,omitempty"`
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"`
}
type HTTPNodeConfig struct {
Host string `yaml:",omitempty" json:"host,omitempty"`
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
// rewrite host header
Host string `yaml:",omitempty" json:"host,omitempty"`
// Deprecated: use requestHeader instead
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
// additional request header
RequestHeader map[string]string `yaml:"requestHeader,omitempty" json:"requestHeader,omitempty"`
// additional response header
ResponseHeader map[string]string `yaml:"responseHeader,omitempty" json:"responseHeader,omitempty"`
// Deprecated: use rewriteURL instead
Rewrite []HTTPURLRewriteConfig `yaml:",omitempty" json:"rewrite,omitempty"`
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
// rewrite URL
RewriteURL []HTTPURLRewriteConfig `yaml:"rewriteURL,omitempty" json:"rewriteURL,omitempty"`
// Deprecated: use rewriteResponseBody instead
RewriteBody []HTTPBodyRewriteConfig `yaml:"rewriteBody,omitempty" json:"rewriteBody,omitempty"`
// rewrite request body
RewriteRequestBody []HTTPBodyRewriteConfig `yaml:"rewriteRequestBody,omitempty" json:"rewriteRequestBody,omitempty"`
// rewrite response body
RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"`
// HTTP basic auth
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
}
type TLSNodeConfig struct {
@@ -416,9 +477,9 @@ type SockOptsConfig struct {
type ServiceConfig struct {
Name string `json:"name"`
Addr string `yaml:",omitempty" json:"addr,omitempty"`
// DEPRECATED by metadata.interface since beta.5
// Deprecated: use metadata.interface instead
Interface string `yaml:",omitempty" json:"interface,omitempty"`
// DEPRECATED by metadata.so_mark since beta.5
// Deprecated: use metadata.so_mark instead
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
Admission string `yaml:",omitempty" json:"admission,omitempty"`
Admissions []string `yaml:",omitempty" json:"admissions,omitempty"`
@@ -427,13 +488,15 @@ type ServiceConfig struct {
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Limiter string `yaml:",omitempty" json:"limiter,omitempty"`
Quotas []string `yaml:",omitempty" json:"quotas,omitempty"`
CLimiter string `yaml:"climiter,omitempty" json:"climiter,omitempty"`
RLimiter string `yaml:"rlimiter,omitempty" json:"rlimiter,omitempty"`
Logger string `yaml:",omitempty" json:"logger,omitempty"`
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
Observer string `yaml:",omitempty" json:"observer,omitempty"`
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
@@ -442,10 +505,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 {
@@ -461,6 +526,57 @@ type ServiceStats struct {
OutputBytes uint64 `yaml:"outputBytes" json:"outputBytes"`
}
// QuotaConfig is a named cumulative traffic-volume limiter; the same name
// referenced by several services (ServiceConfig.Quotas) shares one counter.
type QuotaConfig struct {
Name string `json:"name"`
Limit string `yaml:",omitempty" json:"limit,omitempty"`
StartsAt string `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt string `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Direction string `yaml:",omitempty" json:"direction,omitempty"`
Flush string `yaml:",omitempty" json:"flush,omitempty"`
Store *QuotaStoreConfig `yaml:",omitempty" json:"store,omitempty"`
// read-only
Status *QuotaStatus `yaml:",omitempty" json:"status,omitempty"`
}
type QuotaStoreConfig struct {
Type string `json:"type"` // "file" (default) | "redis" (stub, not implemented)
File string `yaml:",omitempty" json:"file,omitempty"`
Redis *QuotaRedisConfig `yaml:",omitempty" json:"redis,omitempty"`
}
type QuotaRedisConfig struct {
Addr string `json:"addr"`
DB int `yaml:",omitempty" json:"db,omitempty"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
Key string `yaml:",omitempty" json:"key,omitempty"`
}
type QuotaStatus struct {
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ServiceQuota struct {
Name string `yaml:"name" json:"name"`
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ChainConfig struct {
Name string `json:"name"`
Hops []*HopConfig `json:"hops"`
@@ -473,40 +589,47 @@ type ChainGroupConfig struct {
}
type HopConfig struct {
Name string `json:"name"`
Interface string `yaml:",omitempty" json:"interface,omitempty"`
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
Reload time.Duration `yaml:",omitempty" json:"reload,omitempty"`
File *FileLoader `yaml:",omitempty" json:"file,omitempty"`
Redis *RedisLoader `yaml:",omitempty" json:"redis,omitempty"`
HTTP *HTTPLoader `yaml:"http,omitempty" json:"http,omitempty"`
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
Name string `json:"name"`
// Deprecated: use metadata.interface instead
Interface string `yaml:",omitempty" json:"interface,omitempty"`
// Deprecated: use metadata.so_mark instead
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
Reload time.Duration `yaml:",omitempty" json:"reload,omitempty"`
File *FileLoader `yaml:",omitempty" json:"file,omitempty"`
Redis *RedisLoader `yaml:",omitempty" json:"redis,omitempty"`
HTTP *HTTPLoader `yaml:"http,omitempty" json:"http,omitempty"`
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
type NodeConfig struct {
Name string `json:"name"`
Addr string `yaml:",omitempty" json:"addr,omitempty"`
Network string `yaml:",omitempty" json:"network,omitempty"`
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Connector *ConnectorConfig `yaml:",omitempty" json:"connector,omitempty"`
Dialer *DialerConfig `yaml:",omitempty" json:"dialer,omitempty"`
Interface string `yaml:",omitempty" json:"interface,omitempty"`
Netns string `yaml:",omitempty" json:"netns,omitempty"`
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
Name string `json:"name"`
Addr string `yaml:",omitempty" json:"addr,omitempty"`
Network string `yaml:",omitempty" json:"network,omitempty"`
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Connector *ConnectorConfig `yaml:",omitempty" json:"connector,omitempty"`
Dialer *DialerConfig `yaml:",omitempty" json:"dialer,omitempty"`
// Deprecated: use metadata.interface instead
Interface string `yaml:",omitempty" json:"interface,omitempty"`
// Deprecated: use metadata.netns instead
Netns string `yaml:",omitempty" json:"netns,omitempty"`
// Deprecated: use metadata.so_mark instead
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
// Deprecated: use matcher instead
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
type Config struct {
@@ -522,7 +645,9 @@ type Config struct {
Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"`
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
RLimiters []*LimiterConfig `yaml:"rlimiters,omitempty" json:"rlimiters,omitempty"`
Observers []*ObserverConfig `yaml:",omitempty" json:"observers,omitempty"`
@@ -542,7 +667,8 @@ func (c *Config) Load() error {
return v.Unmarshal(c)
}
func (c *Config) Read(r io.Reader) error {
func (c *Config) Read(r io.Reader, configType string) error {
v.SetConfigType(configType)
if err := v.ReadConfig(r); err != nil {
return err
}
@@ -552,6 +678,7 @@ func (c *Config) Read(r io.Reader) error {
func (c *Config) ReadFile(file string) error {
v.SetConfigFile(file)
v.SetConfigType(strings.TrimPrefix(filepath.Ext(file), ".")) // force format from extension
if err := v.ReadInConfig(); err != nil {
return err
}
+364
View File
@@ -0,0 +1,364 @@
// Package loader converts a parsed [config.Config] into running components
// by parsing each config section and registering the results into the
// global registries. Components are registered in dependency order so that
// when a component reads its dependencies from registries during parsing,
// those dependencies are already available.
package loader
import (
"github.com/go-gost/core/admission"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/hosts"
"github.com/go-gost/core/ingress"
"github.com/go-gost/core/limiter/conn"
"github.com/go-gost/core/limiter/rate"
"github.com/go-gost/core/limiter/traffic"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer"
"github.com/go-gost/core/recorder"
reg "github.com/go-gost/core/registry"
"github.com/go-gost/core/resolver"
"github.com/go-gost/core/router"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/sd"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
admission_parser "github.com/go-gost/x/config/parsing/admission"
auth_parser "github.com/go-gost/x/config/parsing/auth"
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
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"
ingress_parser "github.com/go-gost/x/config/parsing/ingress"
limiter_parser "github.com/go-gost/x/config/parsing/limiter"
logger_parser "github.com/go-gost/x/config/parsing/logger"
observer_parser "github.com/go-gost/x/config/parsing/observer"
quota_parser "github.com/go-gost/x/config/parsing/quota"
recorder_parser "github.com/go-gost/x/config/parsing/recorder"
resolver_parser "github.com/go-gost/x/config/parsing/resolver"
rewriter_parser "github.com/go-gost/x/config/parsing/rewriter"
router_parser "github.com/go-gost/x/config/parsing/router"
sd_parser "github.com/go-gost/x/config/parsing/sd"
service_parser "github.com/go-gost/x/config/parsing/service"
quota "github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry"
)
// defaultLoader is a singleton loader used by the top-level Load function.
var (
defaultLoader *loader = &loader{}
)
// Load parses all config sections from cfg and registers the resulting
// components into the global registries, then sets up the default logger
// and TLS config.
func Load(cfg *config.Config) error {
return defaultLoader.Load(cfg)
}
// loader holds no state; its Load method is the entry point for converting
// a config into running components.
type loader struct{}
// Load builds the default TLS config, registers all named components from
// cfg into the global registries, and sets the default logger and TLS config.
func (l *loader) Load(cfg *config.Config) error {
if cfg == nil {
return nil
}
logCfg := cfg.Log
if logCfg == nil {
logCfg = &config.LogConfig{}
}
logger.SetDefault(logger_parser.ParseLogger(&config.LoggerConfig{Log: logCfg}))
tlsCfg, err := parsing.BuildDefaultTLSConfig(cfg.TLS)
if err != nil {
return err
}
parsing.SetDefaultTLSConfig(tlsCfg)
if err := register(cfg); err != nil {
return err
}
return nil
}
// named is a generic name+value pair used to buffer parsed components
// before registering them.
type named[T any] struct {
name string
v T
}
// 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 {
unregisterAll(r)
for _, e := range entries {
if err := r.Register(e.name, e.v); err != nil {
return err
}
}
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
// when a component reads its dependencies from registries during
// parsing, those dependencies are already registered.
func register(cfg *config.Config) error {
if cfg == nil {
return nil
}
// --- leaf components (no inter-group registry dependencies) ---
{
var entries []named[logger.Logger]
for _, c := range cfg.Loggers {
entries = append(entries, named[logger.Logger]{c.Name, logger_parser.ParseLogger(c)})
}
if err := registerGroup(entries, registry.LoggerRegistry()); err != nil {
return err
}
}
{
var entries []named[auth.Authenticator]
for _, c := range cfg.Authers {
entries = append(entries, named[auth.Authenticator]{c.Name, auth_parser.ParseAuther(c)})
}
if err := registerGroup(entries, registry.AutherRegistry()); err != nil {
return err
}
}
{
var entries []named[admission.Admission]
for _, c := range cfg.Admissions {
entries = append(entries, named[admission.Admission]{c.Name, admission_parser.ParseAdmission(c)})
}
if err := registerGroup(entries, registry.AdmissionRegistry()); err != nil {
return err
}
}
{
var entries []named[bypass.Bypass]
for _, c := range cfg.Bypasses {
entries = append(entries, named[bypass.Bypass]{c.Name, bypass_parser.ParseBypass(c)})
}
if err := registerGroup(entries, registry.BypassRegistry()); err != nil {
return err
}
}
{
var entries []named[resolver.Resolver]
for _, c := range cfg.Resolvers {
r, err := resolver_parser.ParseResolver(c)
if err != nil {
return err
}
entries = append(entries, named[resolver.Resolver]{c.Name, r})
}
if err := registerGroup(entries, registry.ResolverRegistry()); err != nil {
return err
}
}
{
var entries []named[hosts.HostMapper]
for _, c := range cfg.Hosts {
entries = append(entries, named[hosts.HostMapper]{c.Name, hosts_parser.ParseHostMapper(c)})
}
if err := registerGroup(entries, registry.HostsRegistry()); err != nil {
return err
}
}
{
var entries []named[ingress.Ingress]
for _, c := range cfg.Ingresses {
entries = append(entries, named[ingress.Ingress]{c.Name, ingress_parser.ParseIngress(c)})
}
if err := registerGroup(entries, registry.IngressRegistry()); err != nil {
return err
}
}
{
var entries []named[router.Router]
for _, c := range cfg.Routers {
entries = append(entries, named[router.Router]{c.Name, router_parser.ParseRouter(c)})
}
if err := registerGroup(entries, registry.RouterRegistry()); err != nil {
return err
}
}
{
var entries []named[sd.SD]
for _, c := range cfg.SDs {
entries = append(entries, named[sd.SD]{c.Name, sd_parser.ParseSD(c)})
}
if err := registerGroup(entries, registry.SDRegistry()); err != nil {
return err
}
}
{
var entries []named[observer.Observer]
for _, c := range cfg.Observers {
entries = append(entries, named[observer.Observer]{c.Name, observer_parser.ParseObserver(c)})
}
if err := registerGroup(entries, registry.ObserverRegistry()); err != nil {
return err
}
}
{
var entries []named[recorder.Recorder]
for _, c := range cfg.Recorders {
entries = append(entries, named[recorder.Recorder]{c.Name, recorder_parser.ParseRecorder(c)})
}
if err := registerGroup(entries, registry.RecorderRegistry()); err != nil {
return err
}
}
{
var entries []named[rewriter.Rewriter]
for _, c := range cfg.Rewriters {
entries = append(entries, named[rewriter.Rewriter]{c.Name, rewriter_parser.ParseRewriter(c)})
}
if err := registerGroup(entries, registry.RewriterRegistry()); err != nil {
return err
}
}
{
var entries []named[traffic.TrafficLimiter]
for _, c := range cfg.Limiters {
entries = append(entries, named[traffic.TrafficLimiter]{c.Name, limiter_parser.ParseTrafficLimiter(c)})
}
if err := registerGroup(entries, registry.TrafficLimiterRegistry()); err != nil {
return err
}
}
{
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 {
entries = append(entries, named[conn.ConnLimiter]{c.Name, limiter_parser.ParseConnLimiter(c)})
}
if err := registerGroup(entries, registry.ConnLimiterRegistry()); err != nil {
return err
}
}
{
var entries []named[rate.RateLimiter]
for _, c := range cfg.RLimiters {
entries = append(entries, named[rate.RateLimiter]{c.Name, limiter_parser.ParseRateLimiter(c)})
}
if err := registerGroup(entries, registry.RateLimiterRegistry()); err != nil {
return err
}
}
// --- hops (references bypasses, resolvers, hosts from registries) ---
{
var entries []named[hop.Hop]
for _, c := range cfg.Hops {
h, err := hop_parser.ParseHop(c, logger.Default())
if err != nil {
return err
}
entries = append(entries, named[hop.Hop]{c.Name, h})
}
if err := registerGroup(entries, registry.HopRegistry()); err != nil {
return err
}
}
// --- chains (references hops from registries) ---
{
var entries []named[chain.Chainer]
for _, c := range cfg.Chains {
ch, err := chain_parser.ParseChain(c, logger.Default())
if err != nil {
return err
}
entries = append(entries, named[chain.Chainer]{c.Name, ch})
}
if err := registerGroup(entries, registry.ChainRegistry()); err != nil {
return err
}
}
// --- 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.
{
unregisterAll(registry.ServiceRegistry())
for _, c := range cfg.Services {
svc, err := service_parser.ParseService(c)
if err != nil {
return err
}
if svc != nil {
if err := registry.ServiceRegistry().Register(c.Name, svc); err != nil {
return err
}
}
}
}
return nil
}
+935
View File
@@ -0,0 +1,935 @@
package loader
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
reg "github.com/go-gost/core/registry"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/registry"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
// --- mock registry for registerGroup tests ---
type mockRegistry[T any] struct {
m map[string]T
}
func newMockRegistry[T any]() *mockRegistry[T] {
return &mockRegistry[T]{m: make(map[string]T)}
}
func (r *mockRegistry[T]) Register(name string, v T) error {
if name == "" {
return nil
}
if _, ok := r.m[name]; ok {
return errors.New("duplicate")
}
r.m[name] = v
return nil
}
func (r *mockRegistry[T]) Unregister(name string) {
delete(r.m, name)
}
func (r *mockRegistry[T]) IsRegistered(name string) bool {
_, ok := r.m[name]
return ok
}
func (r *mockRegistry[T]) Get(name string) T {
return r.m[name]
}
func (r *mockRegistry[T]) GetAll() map[string]T {
result := make(map[string]T)
for k, v := range r.m {
result[k] = v
}
return result
}
// Ensure mockRegistry satisfies reg.Registry[T].
var _ reg.Registry[int] = (*mockRegistry[int])(nil)
// --- registerGroup tests ---
func TestRegisterGroup_EmptyEntries_EmptyRegistry(t *testing.T) {
r := newMockRegistry[int]()
err := registerGroup([]named[int]{}, r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(r.GetAll()) != 0 {
t.Fatalf("expected empty registry, got %d entries", len(r.GetAll()))
}
}
func TestRegisterGroup_EmptyEntries_ClearsExisting(t *testing.T) {
r := newMockRegistry[int]()
r.Register("a", 1)
r.Register("b", 2)
err := registerGroup([]named[int]{}, r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(r.GetAll()) != 0 {
t.Fatalf("expected empty registry, got %d entries", len(r.GetAll()))
}
}
func TestRegisterGroup_SingleEntry(t *testing.T) {
r := newMockRegistry[int]()
err := registerGroup([]named[int]{{"x", 42}}, r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered("x") {
t.Fatal("expected 'x' to be registered")
}
if v := r.Get("x"); v != 42 {
t.Fatalf("expected 42, got %v", v)
}
}
func TestRegisterGroup_MultipleEntries(t *testing.T) {
r := newMockRegistry[int]()
err := registerGroup([]named[int]{
{"x", 1},
{"y", 2},
{"z", 3},
}, r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
all := r.GetAll()
if len(all) != 3 {
t.Fatalf("expected 3 entries, got %d", len(all))
}
for _, name := range []string{"x", "y", "z"} {
if !r.IsRegistered(name) {
t.Fatalf("expected '%s' to be registered", name)
}
}
}
func TestRegisterGroup_ReplacesExisting(t *testing.T) {
r := newMockRegistry[int]()
r.Register("old", 1)
r.Register("keep", 2) // this one should be removed
err := registerGroup([]named[int]{
{"old", 99},
{"new", 100},
}, r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v := r.Get("old"); v != 99 {
t.Fatalf("expected 99, got %v", v)
}
if !r.IsRegistered("new") {
t.Fatal("expected 'new' to be registered")
}
if r.IsRegistered("keep") {
t.Fatal("expected 'keep' to be unregistered")
}
}
func TestRegisterGroup_DuplicateNameInEntries(t *testing.T) {
r := newMockRegistry[int]()
err := registerGroup([]named[int]{
{"dup", 1},
{"dup", 2},
}, r)
if err == nil {
t.Fatal("expected error for duplicate name")
}
// First entry should be registered, second failed.
if v := r.Get("dup"); v != 1 {
t.Fatalf("expected first entry (1), got %v", v)
}
}
func TestRegisterGroup_ZeroValue(t *testing.T) {
r := newMockRegistry[int]()
// int zero value (0) is still registered
err := registerGroup([]named[int]{{"zero", 0}}, r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered("zero") {
t.Fatal("expected 'zero' to be registered")
}
}
// --- register tests ---
func TestRegister_NilConfig(t *testing.T) {
if err := register(nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRegister_EmptyConfig(t *testing.T) {
if err := register(&config.Config{}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRegister_Loggers(t *testing.T) {
reg := registry.LoggerRegistry()
name := "test-logger"
t.Cleanup(func() { reg.Unregister(name) })
cfg := &config.Config{
Loggers: []*config.LoggerConfig{
{Name: name, Log: &config.LogConfig{Level: "info"}},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reg.IsRegistered(name) {
t.Fatal("expected logger to be registered")
}
}
func TestRegister_Authers(t *testing.T) {
r := registry.AutherRegistry()
name := "test-auther"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Authers: []*config.AutherConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected auther to be registered")
}
}
func TestRegister_Admissions(t *testing.T) {
r := registry.AdmissionRegistry()
name := "test-admission"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Admissions: []*config.AdmissionConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected admission to be registered")
}
}
func TestRegister_Bypasses(t *testing.T) {
r := registry.BypassRegistry()
name := "test-bypass"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Bypasses: []*config.BypassConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected bypass to be registered")
}
}
func TestRegister_Resolvers(t *testing.T) {
r := registry.ResolverRegistry()
name := "test-resolver"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Resolvers: []*config.ResolverConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected resolver to be registered")
}
}
func TestRegister_Hosts(t *testing.T) {
r := registry.HostsRegistry()
name := "test-hosts"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Hosts: []*config.HostsConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected host mapper to be registered")
}
}
func TestRegister_Ingresses(t *testing.T) {
r := registry.IngressRegistry()
name := "test-ingress"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Ingresses: []*config.IngressConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected ingress to be registered")
}
}
func TestRegister_Routers(t *testing.T) {
r := registry.RouterRegistry()
name := "test-router"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Routers: []*config.RouterConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected router to be registered")
}
}
func TestRegister_SDs(t *testing.T) {
r := registry.SDRegistry()
name := "test-sd"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
SDs: []*config.SDConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected SD to be registered")
}
}
func TestRegister_Observers(t *testing.T) {
r := registry.ObserverRegistry()
name := "test-observer"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Observers: []*config.ObserverConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected observer to be registered")
}
}
func TestRegister_Recorders(t *testing.T) {
r := registry.RecorderRegistry()
name := "test-recorder"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Recorders: []*config.RecorderConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected recorder to be registered")
}
}
func TestRegister_TrafficLimiters(t *testing.T) {
r := registry.TrafficLimiterRegistry()
name := "test-traffic-limiter"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Limiters: []*config.LimiterConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected traffic limiter to be registered")
}
}
func TestRegister_ConnLimiters(t *testing.T) {
r := registry.ConnLimiterRegistry()
name := "test-conn-limiter"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
CLimiters: []*config.LimiterConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected conn limiter to be registered")
}
}
func TestRegister_RateLimiters(t *testing.T) {
r := registry.RateLimiterRegistry()
name := "test-rate-limiter"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
RLimiters: []*config.LimiterConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected rate limiter to be registered")
}
}
func TestRegister_Hops(t *testing.T) {
r := registry.HopRegistry()
name := "test-hop"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Hops: []*config.HopConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected hop to be registered")
}
}
func TestRegister_Chains(t *testing.T) {
r := registry.ChainRegistry()
name := "test-chain"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Chains: []*config.ChainConfig{
{Name: name},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected chain to be registered")
}
}
// stubListener satisfies listener.Listener for testing.
type stubListener struct{}
func (l *stubListener) Init(md metadata.Metadata) error { return nil }
func (l *stubListener) Accept() (net.Conn, error) { return nil, net.ErrClosed }
func (l *stubListener) Addr() net.Addr { return &net.TCPAddr{IP: net.IPv4zero, Port: 0} }
func (l *stubListener) Close() error { return nil }
// stubHandler satisfies handler.Handler for testing.
type stubHandler struct{}
func (h *stubHandler) Init(md metadata.Metadata) error { return nil }
func (h *stubHandler) Handle(_ context.Context, _ net.Conn, _ ...handler.HandleOption) error { return nil }
func TestRegister_Services(t *testing.T) {
// ParseService needs "tcp" listener and "auto" handler in registries.
// These are registered via init() in their respective packages but those
// packages are not imported here, so the factories won't be found.
// Register stub factories so the test can exercise the service section.
//
// This save/restore pattern assumes no other test or transitive import
// has already registered "tcp" or "auto" factory functions. If those
// packages are ever imported by this test file, origListener/origHandler
// will be non-nil and the restore path will re-register the real
// factories, which is the desired behavior.
origListener := registry.ListenerRegistry().Get("tcp")
origHandler := registry.HandlerRegistry().Get("auto")
registry.ListenerRegistry().Register("tcp", func(opts ...listener.Option) listener.Listener {
return &stubListener{}
})
registry.HandlerRegistry().Register("auto", func(opts ...handler.Option) handler.Handler {
return &stubHandler{}
})
t.Cleanup(func() {
registry.ListenerRegistry().Unregister("tcp")
registry.HandlerRegistry().Unregister("auto")
if origListener != nil {
registry.ListenerRegistry().Register("tcp", origListener)
}
if origHandler != nil {
registry.HandlerRegistry().Register("auto", origHandler)
}
})
r := registry.ServiceRegistry()
name := "test-service"
t.Cleanup(func() { r.Unregister(name) })
cfg := &config.Config{
Services: []*config.ServiceConfig{
{Name: name, Addr: ":0"},
},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !r.IsRegistered(name) {
t.Fatal("expected service to be registered")
}
}
func TestRegister_MultipleSections(t *testing.T) {
// Verify that multiple sections are all registered and in dependency order.
bypassReg := registry.BypassRegistry()
hopReg := registry.HopRegistry()
chainReg := registry.ChainRegistry()
bypassName := "multi-bypass"
hopName := "multi-hop"
chainName := "multi-chain"
t.Cleanup(func() {
bypassReg.Unregister(bypassName)
hopReg.Unregister(hopName)
chainReg.Unregister(chainName)
})
cfg := &config.Config{
Bypasses: []*config.BypassConfig{{Name: bypassName}},
Hops: []*config.HopConfig{{Name: hopName}},
Chains: []*config.ChainConfig{{Name: chainName}},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bypassReg.IsRegistered(bypassName) {
t.Fatal("expected bypass to be registered")
}
if !hopReg.IsRegistered(hopName) {
t.Fatal("expected hop to be registered")
}
if !chainReg.IsRegistered(chainName) {
t.Fatal("expected chain to be registered")
}
}
func TestRegister_ComponentOrder(t *testing.T) {
// register() processes sections in dependency order: leaf components
// (loggers, authers, resolvers, etc.) first, then hops, then chains,
// then services. Hops look up loggers/resolvers from registries;
// chains look up hops; services look up chains.
//
// This test directly calls registerGroup in the canonical dependency
// sequence and verifies each layer can see the previous layer's
// registrations.
// Use a sequence-tracking registry to verify registration order.
type seqEntry struct {
name string
seq int
}
var seq int
leafReg := newMockRegistry[seqEntry]()
hopReg := newMockRegistry[seqEntry]()
chainReg := newMockRegistry[seqEntry]()
svcReg := newMockRegistry[seqEntry]()
seq++
registerGroup([]named[seqEntry]{{"leaf", seqEntry{"logger", seq}}}, leafReg)
// Hops should see leaf registrations.
if !leafReg.IsRegistered("leaf") {
t.Fatal("expected leaf to be registered before hops")
}
seq++
registerGroup([]named[seqEntry]{{"hop", seqEntry{"hop", seq}}}, hopReg)
// Chains should see hop registrations.
if !hopReg.IsRegistered("hop") {
t.Fatal("expected hop to be registered before chains")
}
seq++
registerGroup([]named[seqEntry]{{"chain", seqEntry{"chain", seq}}}, chainReg)
// Services should see chain registrations.
if !chainReg.IsRegistered("chain") {
t.Fatal("expected chain to be registered before services")
}
seq++
registerGroup([]named[seqEntry]{{"svc", seqEntry{"svc", seq}}}, svcReg)
// All should be registered.
for _, r := range []struct {
name string
reg *mockRegistry[seqEntry]
}{
{"leaf", leafReg},
{"hop", hopReg},
{"chain", chainReg},
{"svc", svcReg},
} {
if len(r.reg.GetAll()) != 1 {
t.Fatalf("expected 1 entry in %s registry", r.name)
}
}
// Verify registration sequence: leaf < hop < chain < svc.
if leafReg.Get("leaf").seq >= hopReg.Get("hop").seq {
t.Fatal("leaf must be registered before hop")
}
if hopReg.Get("hop").seq >= chainReg.Get("chain").seq {
t.Fatal("hop must be registered before chain")
}
if chainReg.Get("chain").seq >= svcReg.Get("svc").seq {
t.Fatal("chain must be registered before service")
}
}
// --- Load tests ---
func TestLoad_NilConfig(t *testing.T) {
if err := Load(nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoad_EmptyConfig(t *testing.T) {
cfg := &config.Config{}
if err := Load(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoad_WithTLS(t *testing.T) {
// BuildDefaultTLSConfig with nil TLS falls back to default cert/key files
// which don't exist, but it then generates a self-signed cert, so it
// should still succeed.
cfg := &config.Config{
TLS: &config.TLSConfig{
Validity: 3600,
},
}
if err := Load(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoad_WithLog(t *testing.T) {
cfg := &config.Config{
Log: &config.LogConfig{
Level: "error",
},
}
if err := Load(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoad_SetsDefaultLogger(t *testing.T) {
cfg := &config.Config{
Log: &config.LogConfig{
Level: "warn",
Format: "json",
},
}
if err := Load(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// After Load, logger.Default() should be non-nil.
if logger.Default() == nil {
t.Fatal("expected default logger to be set")
}
}
// --- defaultLoader singleton tests ---
func TestDefaultLoader_IsSet(t *testing.T) {
if defaultLoader == nil {
t.Fatal("defaultLoader should not be nil")
}
if err := defaultLoader.Load(nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- registerGroup edge cases ---
type countingRegistry[T any] struct {
m map[string]T
close int
}
func newCountingRegistry[T any]() *countingRegistry[T] {
return &countingRegistry[T]{m: make(map[string]T)}
}
func (r *countingRegistry[T]) Register(name string, v T) error { r.m[name] = v; return nil }
func (r *countingRegistry[T]) Unregister(name string) { delete(r.m, name); r.close++ }
func (r *countingRegistry[T]) IsRegistered(name string) bool { _, ok := r.m[name]; return ok }
func (r *countingRegistry[T]) Get(name string) T { return r.m[name] }
func (r *countingRegistry[T]) GetAll() map[string]T { return r.m }
func TestRegisterGroup_UnregistersAllOldEntries(t *testing.T) {
r := newCountingRegistry[int]()
r.Register("a", 1)
r.Register("b", 2)
r.Register("c", 3)
_ = registerGroup([]named[int]{{"x", 10}}, r)
if r.close != 3 {
t.Fatalf("expected 3 unregister calls, got %d", r.close)
}
if !r.IsRegistered("x") {
t.Fatal("expected 'x' to be registered")
}
if r.IsRegistered("a") || r.IsRegistered("b") || r.IsRegistered("c") {
t.Fatal("old entries should be unregistered")
}
}
// --- register error paths ---
func TestRegister_ServiceBadTLS(t *testing.T) {
// A non-empty CertFile that doesn't exist causes
// tls_util.LoadServerConfig to fail before reaching the
// listener registry lookup.
//
// ParseService logs an error via the default logger when the
// TLS cert can't be loaded. We suppress that log here since
// the error is expected.
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
discardLogger := xlogger.NewLogger(xlogger.OutputOption(io.Discard))
t.Cleanup(func() { logger.SetDefault(discardLogger) })
cfg := &config.Config{
Services: []*config.ServiceConfig{
{
Name: "bad-tls",
Addr: ":0",
Listener: &config.ListenerConfig{
TLS: &config.TLSConfig{
CertFile: "/nonexistent/cert.pem",
},
},
},
},
}
if err := register(cfg); err == nil {
t.Fatal("expected error for bad TLS config, got nil")
}
}
func TestRegister_ServiceUnknownListener(t *testing.T) {
// An unregistered listener type triggers the
// "unknown listener" error in ParseService.
cfg := &config.Config{
Services: []*config.ServiceConfig{
{
Name: "unknown-ln",
Addr: ":0",
Listener: &config.ListenerConfig{
Type: "nonexistent",
},
},
},
}
if err := register(cfg); err == nil {
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")
}
}
+9
View File
@@ -14,6 +14,10 @@ import (
"github.com/go-gost/x/registry"
)
// ParseAdmission converts an AdmissionConfig into an admission.Admission. It
// resolves plugin backends (HTTP or gRPC) when cfg.Plugin is set, or
// constructs an in-process admission controller with optional file, Redis, and
// HTTP hot-reload support.
func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
if cfg == nil {
return nil
@@ -31,6 +35,7 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
case "http":
return admission_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -59,6 +64,7 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
opts = append(opts, xadmission.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -73,6 +79,9 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
return xadmission.NewAdmission(opts...)
}
// List resolves one or more admission controller names from the registry. It
// returns only the controllers that were found, skipping any that are not
// registered.
func List(name string, names ...string) []admission.Admission {
var admissions []admission.Admission
if adm := registry.AdmissionRegistry().Get(name); adm != nil {
+144
View File
@@ -0,0 +1,144 @@
package admission
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseAdmission_Nil(t *testing.T) {
adm := ParseAdmission(nil)
if adm != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseAdmission_PluginHTTP(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "http-adm",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_PluginGRPC(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "grpc-adm",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "admission.local",
},
},
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_PluginDefaultType(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "default-adm",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_WithMatchers(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "matcher-adm",
Matchers: []string{"192.168.0.0/16", "10.0.0.1"},
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_Whitelist(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "whitelist-adm",
Whitelist: true,
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_Reverse(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "reverse-adm",
Reverse: true,
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_FileLoader(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "file-adm",
File: &config.FileLoader{Path: "/tmp/admission.txt"},
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_HTTPLoader(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "http-adm",
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/list"},
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestParseAdmission_RedisLoader(t *testing.T) {
adm := ParseAdmission(&config.AdmissionConfig{
Name: "redis-adm",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "admission-set",
},
})
if adm == nil {
t.Fatal("expected non-nil admission")
}
}
func TestList_ReturnsEntries(t *testing.T) {
// The registry returns a lazy wrapper for any non-empty name.
got := List("test-admission", "test-admission-2")
if len(got) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got))
}
}
func TestList_EmptyNameReturnsNothing(t *testing.T) {
// Empty names are filtered by the registry Get.
got := List("")
if len(got) != 0 {
t.Fatal("expected empty list for empty name")
}
}
+65 -1
View File
@@ -1,8 +1,12 @@
package auth
import (
"bufio"
"crypto/tls"
"io"
"net/url"
"os"
"strings"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
@@ -14,6 +18,9 @@ import (
"github.com/go-gost/x/registry"
)
// ParseAuther converts an AutherConfig into an auth.Authenticator. It handles
// plugin backends (HTTP or gRPC), inline auth credentials, and optional file,
// Redis, and HTTP hot-reload sources.
func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
if cfg == nil {
return nil
@@ -31,6 +38,7 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
case "http":
return auth_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -67,6 +75,7 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
opts = append(opts, xauth.RedisLoaderOption(loader.RedisHashLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -80,6 +89,9 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
return xauth.NewAuthenticator(opts...)
}
// ParseAutherFromAuth creates a simple authenticator from a single
// username/password pair in an AuthConfig. Returns nil if the config is nil or
// has no username.
func ParseAutherFromAuth(au *config.AuthConfig) auth.Authenticator {
if au == nil || au.Username == "" {
return nil
@@ -96,8 +108,24 @@ func ParseAutherFromAuth(au *config.AuthConfig) auth.Authenticator {
)
}
// Info extracts authentication credentials from an AuthConfig as a
// url.Userinfo. It supports reading the first line from a password file when
// cfg.File is set, or falls back to inline username/password fields.
func Info(cfg *config.AuthConfig) *url.Userinfo {
if cfg == nil || cfg.Username == "" {
if cfg == nil {
return nil
}
if cfg.File != "" {
if f, _ := os.Open(cfg.File); f != nil {
defer f.Close()
if infos, _ := parseInfo(f, 1); len(infos) > 0 {
return infos[0]
}
}
}
if cfg.Username == "" {
return nil
}
@@ -107,6 +135,42 @@ func Info(cfg *config.AuthConfig) *url.Userinfo {
return url.UserPassword(cfg.Username, cfg.Password)
}
func parseInfo(r io.Reader, max int) (infos []*url.Userinfo, err error) {
if r == nil {
return
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
// line := strings.Replace(scanner.Text(), "\t", " ", -1)
line := strings.TrimSpace(scanner.Text())
if n := strings.IndexByte(line, '#'); n == 0 {
continue
}
sp := strings.SplitN(line, " ", 2)
if len(sp) == 1 {
if k := strings.TrimSpace(sp[0]); k != "" {
infos = append(infos, url.User(k))
}
}
if len(sp) == 2 {
if k := strings.TrimSpace(sp[0]); k != "" {
infos = append(infos, url.UserPassword(k, strings.TrimSpace(sp[1])))
}
}
if max > 0 && len(infos) >= max {
break
}
}
err = scanner.Err()
return
}
// List resolves one or more authenticator names from the registry. It returns
// only the authenticators that were found, skipping any that are not
// registered.
func List(name string, names ...string) []auth.Authenticator {
var authers []auth.Authenticator
if auther := registry.AutherRegistry().Get(name); auther != nil {
+242
View File
@@ -0,0 +1,242 @@
package auth
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestInfo_Nil(t *testing.T) {
if u := Info(nil); u != nil {
t.Fatal("expected nil for nil config")
}
}
func TestInfo_EmptyUsername(t *testing.T) {
u := Info(&config.AuthConfig{Username: ""})
if u != nil {
t.Fatal("expected nil for empty username")
}
}
func TestInfo_UsernameOnly(t *testing.T) {
u := Info(&config.AuthConfig{Username: "user"})
if u == nil {
t.Fatal("expected non-nil userinfo")
}
if u.Username() != "user" {
t.Fatalf("username = %q, want %q", u.Username(), "user")
}
if _, hasPassword := u.Password(); hasPassword {
t.Fatal("expected no password")
}
}
func TestInfo_UsernameAndPassword(t *testing.T) {
u := Info(&config.AuthConfig{Username: "user", Password: "pass"})
if u == nil {
t.Fatal("expected non-nil userinfo")
}
if u.Username() != "user" {
t.Fatalf("username = %q, want %q", u.Username(), "user")
}
if p, ok := u.Password(); !ok || p != "pass" {
t.Fatalf("password = %q, want %q", p, "pass")
}
}
func TestInfo_WithFile(t *testing.T) {
// File that doesn't exist - should fall back to username
u := Info(&config.AuthConfig{
Username: "fallback",
Password: "pass",
File: "/nonexistent/auth_file",
})
if u == nil {
t.Fatal("expected non-nil userinfo (fallback)")
}
if u.Username() != "fallback" {
t.Fatalf("username = %q, want %q", u.Username(), "fallback")
}
}
func TestParseAutherFromAuth_Nil(t *testing.T) {
a := ParseAutherFromAuth(nil)
if a != nil {
t.Fatal("expected nil for nil auth config")
}
}
func TestParseAutherFromAuth_EmptyUsername(t *testing.T) {
a := ParseAutherFromAuth(&config.AuthConfig{Username: ""})
if a != nil {
t.Fatal("expected nil for empty username")
}
}
func TestParseAutherFromAuth_Valid(t *testing.T) {
a := ParseAutherFromAuth(&config.AuthConfig{
Username: "user",
Password: "pass",
})
if a == nil {
t.Fatal("expected non-nil authenticator")
}
}
func TestParseAuther_Nil(t *testing.T) {
a := ParseAuther(nil)
if a != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseAuther_WithUsers(t *testing.T) {
a := ParseAuther(&config.AutherConfig{
Name: "test-auth",
Auths: []*config.AuthConfig{
{Username: "u1", Password: "p1"},
{Username: "u2", Password: "p2"},
{Username: "", Password: "nope"}, // skipped
},
})
if a == nil {
t.Fatal("expected non-nil authenticator")
}
}
func TestParseAuther_WithEmptyAuths(t *testing.T) {
a := ParseAuther(&config.AutherConfig{
Name: "empty-auth",
Auths: []*config.AuthConfig{},
})
if a == nil {
t.Fatal("expected non-nil authenticator")
}
}
func TestParseAuther_PluginHTTP(t *testing.T) {
a := ParseAuther(&config.AutherConfig{
Name: "http-plugin",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if a == nil {
t.Fatal("expected non-nil plugin authenticator")
}
}
func TestParseAuther_PluginGRPC(t *testing.T) {
a := ParseAuther(&config.AutherConfig{
Name: "grpc-plugin",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "auth.local",
},
},
})
if a == nil {
t.Fatal("expected non-nil plugin authenticator")
}
}
func TestParseAuther_PluginDefaultType(t *testing.T) {
// Default plugin type should be treated as gRPC
a := ParseAuther(&config.AutherConfig{
Name: "default-plugin",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if a == nil {
t.Fatal("expected non-nil plugin authenticator")
}
}
func TestInfo_ParseFile(t *testing.T) {
// parseInfo with empty reader should return nil
u := Info(&config.AuthConfig{
File: "/nonexistent/file",
})
if u != nil {
t.Fatal("expected nil for nonexistent file (should not crash)")
}
}
func TestList_ReturnsEntries(t *testing.T) {
// The registry returns a lazy wrapper for any non-empty name.
got := List("test-auther", "test-auther-2")
if len(got) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got))
}
}
func TestList_EmptyNameReturnsNothing(t *testing.T) {
// Empty names are filtered by the registry Get.
got := List("")
if len(got) != 0 {
t.Fatal("expected empty list for empty name")
}
}
func TestParseAuther_HTTPLoader(t *testing.T) {
a := ParseAuther(&config.AutherConfig{
Name: "http-loader-auth",
HTTP: &config.HTTPLoader{
URL: "http://localhost:8080/auth",
},
})
if a == nil {
t.Fatal("expected non-nil authenticator")
}
}
func TestParseAuther_FileLoader(t *testing.T) {
a := ParseAuther(&config.AutherConfig{
Name: "file-loader-auth",
File: &config.FileLoader{
Path: "/tmp/auth.txt",
},
})
if a == nil {
t.Fatal("expected non-nil authenticator")
}
}
func TestParseAuther_RedisLoader(t *testing.T) {
a := ParseAuther(&config.AutherConfig{
Name: "redis-loader-auth",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
},
})
if a == nil {
t.Fatal("expected non-nil authenticator")
}
}
func TestInfo_Integration(t *testing.T) {
// Verify Info returns *url.Userinfo whose String() matches the standard library.
u := Info(&config.AuthConfig{Username: "alice", Password: "secret"})
if u == nil {
t.Fatal("expected non-nil")
}
want := "alice:secret"
if u.String() != want {
t.Fatalf("String() = %q, want %q", u.String(), want)
}
}
+8
View File
@@ -14,6 +14,9 @@ import (
"github.com/go-gost/x/registry"
)
// ParseBypass converts a BypassConfig into a bypass.Bypass. It resolves plugin
// backends (HTTP or gRPC) when cfg.Plugin is set, or constructs an in-process
// bypass with optional file, Redis, and HTTP hot-reload support.
func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
if cfg == nil {
return nil
@@ -31,6 +34,7 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
case "http":
return bypass_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -46,6 +50,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",
@@ -59,6 +64,7 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
opts = append(opts, xbypass.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -73,6 +79,8 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
return xbypass.NewBypass(opts...)
}
// List resolves one or more bypass names from the registry. It returns only
// the bypasses that were found, skipping any that are not registered.
func List(name string, names ...string) []bypass.Bypass {
var bypasses []bypass.Bypass
if bp := registry.BypassRegistry().Get(name); bp != nil {
+144
View File
@@ -0,0 +1,144 @@
package bypass
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseBypass_Nil(t *testing.T) {
bp := ParseBypass(nil)
if bp != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseBypass_PluginHTTP(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "http-bp",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_PluginGRPC(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "grpc-bp",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "bypass.local",
},
},
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_PluginDefaultType(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "default-bp",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_WithMatchers(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "matcher-bp",
Matchers: []string{"127.0.0.1", "10.0.0.0/8", "*.example.com"},
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_Whitelist(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "whitelist-bp",
Whitelist: true,
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_Reverse(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "reverse-bp",
Reverse: true,
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_FileLoader(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "file-bp",
File: &config.FileLoader{Path: "/tmp/bypass.txt"},
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_HTTPLoader(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "http-bp",
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/list"},
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestParseBypass_RedisLoader(t *testing.T) {
bp := ParseBypass(&config.BypassConfig{
Name: "redis-bp",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "bypass-set",
},
})
if bp == nil {
t.Fatal("expected non-nil bypass")
}
}
func TestList_ReturnsEntries(t *testing.T) {
// The registry returns a lazy wrapper for any non-empty name.
got := List("test-bypass", "test-bypass-2")
if len(got) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got))
}
}
func TestList_EmptyNameReturnsNothing(t *testing.T) {
// Empty names are filtered by the registry Get.
got := List("")
if len(got) != 0 {
t.Fatal("expected empty list for empty name")
}
}
+7
View File
@@ -12,6 +12,9 @@ import (
"github.com/go-gost/x/registry"
)
// ParseChain converts a ChainConfig into a chain.Chainer. Each hop reference
// is either looked up from the registry (by name) or parsed inline via
// ParseHop. Returns nil with no error when cfg is nil.
func ParseChain(cfg *config.ChainConfig, log logger.Logger) (chain.Chainer, error) {
if cfg == nil {
return nil, nil
@@ -33,6 +36,10 @@ func ParseChain(cfg *config.ChainConfig, log logger.Logger) (chain.Chainer, erro
)
for _, ch := range cfg.Hops {
if ch == nil {
continue
}
var hop hop.Hop
var err error
+121
View File
@@ -0,0 +1,121 @@
package chain
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func testLogger() logger.Logger {
return xlogger.NewLogger(xlogger.OutputOption(io.Discard))
}
func TestParseChain_Nil(t *testing.T) {
c, err := ParseChain(nil, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseChain_EmptyHops(t *testing.T) {
c, err := ParseChain(&config.ChainConfig{
Name: "empty-hops",
Hops: []*config.HopConfig{},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil chain")
}
}
func TestParseChain_NilHops(t *testing.T) {
c, err := ParseChain(&config.ChainConfig{
Name: "nil-hops",
Hops: nil,
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil chain")
}
}
func TestParseChain_NilHopEntry(t *testing.T) {
c, err := ParseChain(&config.ChainConfig{
Name: "nil-entry",
Hops: []*config.HopConfig{nil},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil chain when hop entry is nil")
}
}
func TestParseChain_WithMetadata(t *testing.T) {
c, err := ParseChain(&config.ChainConfig{
Name: "meta-chain",
Metadata: map[string]any{
"key": "value",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil chain")
}
}
func TestParseChain_NamedHop(t *testing.T) {
// Named hop (Nodes==nil, Plugin==nil) uses registry lookup
c, err := ParseChain(&config.ChainConfig{
Name: "named-hop",
Hops: []*config.HopConfig{
{Name: "nonexistent-hop"},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil chain even with unregistered hop")
}
}
func TestParseChain_InlineHopWithPlugin(t *testing.T) {
// Inline hop (Nodes!=nil or Plugin!=nil) calls hop_parser.ParseHop
c, err := ParseChain(&config.ChainConfig{
Name: "inline-hop",
Hops: []*config.HopConfig{
{
Name: "plugin-hop",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil chain")
}
}
+60 -12
View File
@@ -4,11 +4,10 @@ import (
"crypto/tls"
"strings"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/logger"
mdutil "github.com/go-gost/core/metadata/util"
xbypass "github.com/go-gost/x/bypass"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
@@ -19,8 +18,13 @@ import (
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
)
// ParseHop converts a HopConfig into a hop.Hop. It resolves plugin backends
// (HTTP or gRPC), parses inline nodes with metadata inheritance from the parent
// hop, wires up a node selector, and configures optional file, Redis, and HTTP
// hot-reload sources. Returns nil with no error when cfg is nil.
func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
if cfg == nil {
return nil, nil
@@ -38,6 +42,7 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
case plugin.HTTP:
return hop_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
), nil
@@ -50,14 +55,26 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
}
}
var ppv int
var soMark int
ifce := cfg.Interface
var netns string
if cfg.SockOpts != nil {
soMark = cfg.SockOpts.Mark
}
if cfg.Metadata != nil {
md := metadata.NewMetadata(cfg.Metadata)
if v := mdutil.GetString(md, parsing.MDKeyInterface); v != "" {
ifce = v
}
netns = mdutil.GetString(md, "netns")
if v := mdutil.GetInt(md, parsing.MDKeySoMark); v > 0 {
soMark = v
}
ppv = mdutil.GetInt(md, parsing.MDKeyProxyProtocol)
netns = mdutil.GetString(md, parsing.MDKeyNetns)
}
var nodes []*chain.Node
@@ -66,21 +83,47 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
continue
}
// Build a merged metadata map for inheritance without mutating
// the original node config.
merged := make(map[string]any)
for k, val := range v.Metadata {
merged[k] = val
}
md := metadata.NewMetadata(merged)
if v.Resolver == "" {
v.Resolver = cfg.Resolver
}
if v.Hosts == "" {
v.Hosts = cfg.Hosts
}
if v.Interface == "" {
v.Interface = ifce
}
if v.Netns == "" {
v.Netns = netns
}
if v.SockOpts == nil {
v.SockOpts = cfg.SockOpts
if !md.IsExists(parsing.MDKeyInterface) {
if ifce != "" {
merged[parsing.MDKeyInterface] = ifce
}
if v.Interface != "" {
merged[parsing.MDKeyInterface] = v.Interface
}
}
if !md.IsExists(parsing.MDKeySoMark) {
if soMark != 0 {
merged[parsing.MDKeySoMark] = soMark
}
if v.SockOpts != nil && v.SockOpts.Mark != 0 {
merged[parsing.MDKeySoMark] = v.SockOpts.Mark
}
}
if !md.IsExists(parsing.MDKeyProxyProtocol) && ppv > 0 {
merged[parsing.MDKeyProxyProtocol] = ppv
}
if !md.IsExists(parsing.MDKeyNetns) {
if netns != "" {
merged[parsing.MDKeyNetns] = netns
}
if v.Netns != "" {
merged[parsing.MDKeyNetns] = v.Netns
}
}
if v.Connector == nil {
@@ -97,7 +140,11 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
v.Dialer.Type = "tcp"
}
// Temporarily swap merged metadata so ParseNode sees inherited values.
origMeta := v.Metadata
v.Metadata = merged
node, err := node_parser.ParseNode(cfg.Name, v, log)
v.Metadata = origMeta
if err != nil {
return nil, err
}
@@ -115,7 +162,7 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
xhop.NameOption(cfg.Name),
xhop.NodeOption(nodes...),
xhop.SelectorOption(sel),
xhop.BypassOption(bypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
xhop.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
xhop.ReloadPeriodOption(cfg.Reload),
xhop.LoggerOption(log.WithFields(map[string]any{
"kind": "hop",
@@ -130,6 +177,7 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
opts = append(opts, xhop.RedisLoaderOption(loader.RedisStringLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
+429
View File
@@ -0,0 +1,429 @@
package hop
import (
"io"
"testing"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
xlogger "github.com/go-gost/x/logger"
mdutil "github.com/go-gost/x/metadata/util"
// Register connector and dialer implementations needed for node parsing.
_ "github.com/go-gost/x/connector/http"
_ "github.com/go-gost/x/dialer/tcp"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func testLogger() logger.Logger {
return xlogger.NewLogger(xlogger.OutputOption(io.Discard))
}
func TestParseHop_Nil(t *testing.T) {
h, err := ParseHop(nil, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseHop_PluginHTTP(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "http-hop",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_PluginGRPC(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "grpc-hop",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "hop.local",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_PluginDefaultType(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "default-hop",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_PluginHTTPWithTLS(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "http-tls-hop",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9003",
TLS: &config.TLSConfig{
Secure: false,
ServerName: "hop.example.com",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_PluginGRPCWithTLSInsecure(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "grpc-insecure-hop",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9004",
TLS: &config.TLSConfig{
Secure: false, // InsecureSkipVerify = true
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_NoNodesNoPlugin(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "empty-hop",
Nodes: nil,
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop even with no nodes")
}
}
func TestParseHop_WithBypass(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "bypass-hop",
Bypass: "bypass1",
Bypasses: []string{"bypass2", "bypass3"},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_WithSelector(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "selector-hop",
Selector: &config.SelectorConfig{
Strategy: "round",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_WithNodeNilEntries(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "nil-nodes-hop",
Nodes: []*config.NodeConfig{
nil,
nil,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop when nodes contain nil entries")
}
}
func TestParseHop_SockOptsMark(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "sockopts-hop",
SockOpts: &config.SockOptsConfig{
Mark: 1234,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_MetadataSoMark(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "md-somark-hop",
Metadata: map[string]any{
parsing.MDKeySoMark: 5678,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_MetadataInterface(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "md-iface-hop",
Metadata: map[string]any{
parsing.MDKeyInterface: "eth0",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_MetadataProxyProtocol(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "md-ppv-hop",
Metadata: map[string]any{
parsing.MDKeyProxyProtocol: 2,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_MetadataNetns(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "md-netns-hop",
Metadata: map[string]any{
parsing.MDKeyNetns: "mynetns",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_InterfaceDeprecated(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "iface-deprecated-hop",
Interface: "eth1",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_FileLoader(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "file-hop",
File: &config.FileLoader{Path: "/tmp/hop.txt"},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_HTTPLoader(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "http-hop",
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/hop"},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_RedisLoader(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "redis-hop",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "hop-key",
Username: "user",
Password: "pass",
DB: 1,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
}
func TestParseHop_NodeInheritsInterface(t *testing.T) {
// When hop-level interface is set and node-level is not, node inherits.
h, err := ParseHop(&config.HopConfig{
Name: "inherit-iface-hop",
Interface: "eth0",
Nodes: []*config.NodeConfig{
{
Name: "node1",
Addr: "example.com:8080",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if got := mdutil.GetString(nodes[0].Metadata(), parsing.MDKeyInterface); got != "eth0" {
t.Fatalf("inherited interface = %q, want %q", got, "eth0")
}
}
func TestParseHop_NodeInheritsSoMark(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "inherit-somark-hop",
SockOpts: &config.SockOptsConfig{
Mark: 9999,
},
Nodes: []*config.NodeConfig{
{
Name: "node1",
Addr: "example.com:8080",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if got := mdutil.GetInt(nodes[0].Metadata(), parsing.MDKeySoMark); got != 9999 {
t.Fatalf("inherited so_mark = %d, want %d", got, 9999)
}
}
func TestParseHop_NodeInheritsNetns(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "inherit-netns-hop",
Metadata: map[string]any{
parsing.MDKeyNetns: "mynetns",
},
Nodes: []*config.NodeConfig{
{
Name: "node1",
Addr: "example.com:8080",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if got := mdutil.GetString(nodes[0].Metadata(), parsing.MDKeyNetns); got != "mynetns" {
t.Fatalf("inherited netns = %q, want %q", got, "mynetns")
}
}
func TestParseHop_NodeResolverHostsInheritance(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "inherit-resolver-hop",
Resolver: "my-resolver",
Hosts: "my-hosts",
Nodes: []*config.NodeConfig{
{
Name: "node1",
Addr: "example.com:8080",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h == nil {
t.Fatal("expected non-nil hop")
}
// Verify the node was created (no panic from nil resolver/hosts).
// The resolver/hosts names are inherited, but registry lookup returns
// nil since these names are not registered in this test.
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
}
+6
View File
@@ -14,6 +14,9 @@ import (
"github.com/go-gost/x/internal/plugin"
)
// ParseHostMapper converts a HostsConfig into a hosts.HostMapper. It resolves
// plugin backends (HTTP or gRPC), parses inline IP→hostname mappings, and
// configures optional file, Redis, and HTTP hot-reload support.
func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
if cfg == nil {
return nil
@@ -31,6 +34,7 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
case "http":
return hosts_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -75,6 +79,7 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
opts = append(opts, xhosts.RedisLoaderOption(loader.RedisListLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -82,6 +87,7 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
opts = append(opts, xhosts.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
+154
View File
@@ -0,0 +1,154 @@
package hosts
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseHostMapper_Nil(t *testing.T) {
hm := ParseHostMapper(nil)
if hm != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseHostMapper_PluginHTTP(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "http-hosts",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
func TestParseHostMapper_PluginGRPC(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "grpc-hosts",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "hosts.local",
},
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
func TestParseHostMapper_PluginDefaultType(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "default-hosts",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
func TestParseHostMapper_WithMappings(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "mapping-hosts",
Mappings: []*config.HostMappingConfig{
{IP: "127.0.0.1", Hostname: "localhost"},
{IP: "::1", Hostname: "ipv6-localhost"},
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
func TestParseHostMapper_EmptyMapping(t *testing.T) {
// Mappings with empty IP or Hostname should be skipped
hm := ParseHostMapper(&config.HostsConfig{
Name: "partial-hosts",
Mappings: []*config.HostMappingConfig{
{IP: "", Hostname: "nope"}, // skipped
{IP: "127.0.0.1", Hostname: ""}, // skipped
{IP: "127.0.0.1", Hostname: "valid"}, // kept
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper even with partial mappings")
}
}
func TestParseHostMapper_InvalidIP(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "invalid-ip",
Mappings: []*config.HostMappingConfig{
{IP: "not-an-ip", Hostname: "example.com"}, // skipped
{IP: "127.0.0.1", Hostname: "valid"}, // kept
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper even with invalid IP")
}
}
func TestParseHostMapper_FileLoader(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "file-hosts",
File: &config.FileLoader{Path: "/etc/hosts"},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
func TestParseHostMapper_HTTPLoader(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "http-hosts",
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/hosts"},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
func TestParseHostMapper_RedisLoader(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "redis-hosts",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "hosts-key",
Type: "set",
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
func TestParseHostMapper_RedisListLoader(t *testing.T) {
hm := ParseHostMapper(&config.HostsConfig{
Name: "redis-list-hosts",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "hosts-list",
Type: "list",
},
})
if hm == nil {
t.Fatal("expected non-nil host mapper")
}
}
+6
View File
@@ -13,6 +13,9 @@ import (
"github.com/go-gost/x/internal/plugin"
)
// ParseIngress converts an IngressConfig into an ingress.Ingress. It resolves
// plugin backends (HTTP or gRPC), parses inline hostname→endpoint rules, and
// configures optional file, Redis, and HTTP hot-reload support.
func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
if cfg == nil {
return nil
@@ -30,6 +33,7 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
case "http":
return ingress_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -70,6 +74,7 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
opts = append(opts, xingress.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -77,6 +82,7 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
opts = append(opts, xingress.RedisLoaderOption(loader.RedisHashLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
+141
View File
@@ -0,0 +1,141 @@
package ingress
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseIngress_Nil(t *testing.T) {
ing := ParseIngress(nil)
if ing != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseIngress_PluginHTTP(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "http-ing",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
func TestParseIngress_PluginGRPC(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "grpc-ing",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "ingress.local",
},
},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
func TestParseIngress_PluginDefaultType(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "default-ing",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
func TestParseIngress_WithRules(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "rule-ing",
Rules: []*config.IngressRuleConfig{
{Hostname: "example.com", Endpoint: "10.0.0.1:8080"},
{Hostname: "test.com", Endpoint: "10.0.0.2:9090"},
},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
func TestParseIngress_EmptyRules(t *testing.T) {
// Rules with empty hostname or endpoint should be skipped
ing := ParseIngress(&config.IngressConfig{
Name: "partial-ing",
Rules: []*config.IngressRuleConfig{
{Hostname: "", Endpoint: "nope:8080"}, // skipped
{Hostname: "nope.com", Endpoint: ""}, // skipped
{Hostname: "valid.com", Endpoint: "10.0.0.1:8080"}, // kept
},
})
if ing == nil {
t.Fatal("expected non-nil ingress even with partial rules")
}
}
func TestParseIngress_FileLoader(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "file-ing",
File: &config.FileLoader{Path: "/tmp/ingress.txt"},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
func TestParseIngress_HTTPLoader(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "http-ing",
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/ingress"},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
func TestParseIngress_RedisLoader(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "redis-ing",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "ingress-key",
Type: "hash",
},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
func TestParseIngress_RedisSetLoader(t *testing.T) {
ing := ParseIngress(&config.IngressConfig{
Name: "redis-set-ing",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "ingress-set",
Type: "set",
},
})
if ing == nil {
t.Fatal("expected non-nil ingress")
}
}
+14
View File
@@ -17,6 +17,9 @@ import (
traffic_plugin "github.com/go-gost/x/limiter/traffic/plugin"
)
// ParseTrafficLimiter converts a LimiterConfig into a traffic.TrafficLimiter.
// It resolves plugin backends (HTTP or gRPC), inline rate limits, and optional
// file, Redis, and HTTP hot-reload support.
func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter) {
if cfg == nil {
return nil
@@ -34,6 +37,7 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
case "http":
return traffic_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -57,6 +61,7 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
opts = append(opts, xtraffic.RedisLoaderOption(loader.RedisListLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -64,6 +69,7 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
opts = append(opts, xtraffic.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -87,6 +93,8 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
return xtraffic.NewTrafficLimiter(opts...)
}
// ParseConnLimiter converts a LimiterConfig into a conn.ConnLimiter with
// inline limits and optional file, Redis, and HTTP hot-reload support.
func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
if cfg == nil {
return nil
@@ -103,6 +111,7 @@ func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
opts = append(opts, xconn.RedisLoaderOption(loader.RedisListLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -110,6 +119,7 @@ func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
opts = append(opts, xconn.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -133,6 +143,8 @@ func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
return xconn.NewConnLimiter(opts...)
}
// ParseRateLimiter converts a LimiterConfig into a rate.RateLimiter with
// inline limits and optional file, Redis, and HTTP hot-reload support.
func ParseRateLimiter(cfg *config.LimiterConfig) (lim rate.RateLimiter) {
if cfg == nil {
return nil
@@ -149,6 +161,7 @@ func ParseRateLimiter(cfg *config.LimiterConfig) (lim rate.RateLimiter) {
opts = append(opts, xrate.RedisLoaderOption(loader.RedisListLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -156,6 +169,7 @@ func ParseRateLimiter(cfg *config.LimiterConfig) (lim rate.RateLimiter) {
opts = append(opts, xrate.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
+234
View File
@@ -0,0 +1,234 @@
package limiter
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseTrafficLimiter_Nil(t *testing.T) {
lim := ParseTrafficLimiter(nil)
if lim != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseTrafficLimiter_WithLimits(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "traffic-limiter",
Limits: []string{"100KB", "200KB"},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
func TestParseTrafficLimiter_PluginHTTP(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "http-lim",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
func TestParseTrafficLimiter_PluginGRPC(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "grpc-lim",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "limiter.local",
},
},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
func TestParseTrafficLimiter_PluginDefaultType(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "default-lim",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
func TestParseTrafficLimiter_FileLoader(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "file-lim",
File: &config.FileLoader{Path: "/tmp/limiter.txt"},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
func TestParseTrafficLimiter_HTTPLoader(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "http-lim",
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/limits"},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
func TestParseTrafficLimiter_RedisLoader(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "redis-lim",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "limiter-key",
},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
func TestParseTrafficLimiter_RedisListLoader(t *testing.T) {
lim := ParseTrafficLimiter(&config.LimiterConfig{
Name: "redis-list-lim",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "limiter-list",
Type: "list",
},
})
if lim == nil {
t.Fatal("expected non-nil traffic limiter")
}
}
// --- ConnLimiter ---
func TestParseConnLimiter_Nil(t *testing.T) {
lim := ParseConnLimiter(nil)
if lim != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseConnLimiter_WithLimits(t *testing.T) {
lim := ParseConnLimiter(&config.LimiterConfig{
Name: "conn-limiter",
Limits: []string{"10", "50"},
})
if lim == nil {
t.Fatal("expected non-nil conn limiter")
}
}
func TestParseConnLimiter_RedisLoader(t *testing.T) {
lim := ParseConnLimiter(&config.LimiterConfig{
Name: "redis-conn-lim",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "conn-key",
},
})
if lim == nil {
t.Fatal("expected non-nil conn limiter")
}
}
func TestParseConnLimiter_RedisListLoader(t *testing.T) {
lim := ParseConnLimiter(&config.LimiterConfig{
Name: "redis-list-conn-lim",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "conn-list",
Type: "list",
},
})
if lim == nil {
t.Fatal("expected non-nil conn limiter")
}
}
func TestParseConnLimiter_FileLoader(t *testing.T) {
lim := ParseConnLimiter(&config.LimiterConfig{
Name: "file-conn-lim",
File: &config.FileLoader{Path: "/tmp/conn_limiter.txt"},
})
if lim == nil {
t.Fatal("expected non-nil conn limiter")
}
}
// --- RateLimiter ---
func TestParseRateLimiter_Nil(t *testing.T) {
lim := ParseRateLimiter(nil)
if lim != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseRateLimiter_WithLimits(t *testing.T) {
lim := ParseRateLimiter(&config.LimiterConfig{
Name: "rate-limiter",
Limits: []string{"100", "200"},
})
if lim == nil {
t.Fatal("expected non-nil rate limiter")
}
}
func TestParseRateLimiter_RedisLoader(t *testing.T) {
lim := ParseRateLimiter(&config.LimiterConfig{
Name: "redis-rate-lim",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "rate-key",
},
})
if lim == nil {
t.Fatal("expected non-nil rate limiter")
}
}
func TestParseRateLimiter_RedisListLoader(t *testing.T) {
lim := ParseRateLimiter(&config.LimiterConfig{
Name: "redis-list-rate-lim",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "rate-list",
Type: "list",
},
})
if lim == nil {
t.Fatal("expected non-nil rate limiter")
}
}
func TestParseRateLimiter_FileLoader(t *testing.T) {
lim := ParseRateLimiter(&config.LimiterConfig{
Name: "file-rate-lim",
File: &config.FileLoader{Path: "/tmp/rate_limiter.txt"},
})
if lim == nil {
t.Fatal("expected non-nil rate limiter")
}
}
+6 -1
View File
@@ -12,6 +12,9 @@ import (
"gopkg.in/natefinch/lumberjack.v2"
)
// ParseLogger converts a LoggerConfig into a logger.Logger. It configures
// output destination (stderr, stdout, file, or discard), log level, format,
// and optional log rotation via lumberjack.
func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
if cfg == nil || cfg.Log == nil {
return nil
@@ -25,7 +28,7 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
var out io.Writer = os.Stderr
switch cfg.Log.Output {
case "none", "null":
return xlogger.Nop()
out = io.Discard
case "stdout":
out = os.Stdout
case "stderr", "":
@@ -55,6 +58,8 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
return xlogger.NewLogger(opts...)
}
// List resolves one or more logger names from the registry. It returns only
// the loggers that were found, skipping any that are not registered.
func List(name string, names ...string) []logger.Logger {
var loggers []logger.Logger
if adm := registry.LoggerRegistry().Get(name); adm != nil {
+142
View File
@@ -0,0 +1,142 @@
package logger
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseLogger_Nil(t *testing.T) {
lg := ParseLogger(nil)
if lg != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseLogger_NilLog(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "test",
Log: nil,
})
if lg != nil {
t.Fatal("expected nil when Log is nil")
}
}
func TestParseLogger_Levels(t *testing.T) {
for _, level := range []string{"trace", "debug", "info", "warn", "error", "fatal"} {
t.Run(level, func(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "test-" + level,
Log: &config.LogConfig{
Level: level,
},
})
if lg == nil {
t.Fatal("expected non-nil logger")
}
})
}
}
func TestParseLogger_OutputNone(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "null-logger",
Log: &config.LogConfig{
Output: "none",
},
})
if lg == nil {
t.Fatal("expected non-nil logger")
}
}
func TestParseLogger_OutputStdout(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "stdout-logger",
Log: &config.LogConfig{
Output: "stdout",
},
})
if lg == nil {
t.Fatal("expected non-nil logger")
}
}
func TestParseLogger_OutputStderr(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "stderr-logger",
Log: &config.LogConfig{
Output: "stderr",
},
})
if lg == nil {
t.Fatal("expected non-nil logger")
}
}
func TestParseLogger_OutputDefault(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "default-logger",
Log: &config.LogConfig{
Output: "",
},
})
if lg == nil {
t.Fatal("expected non-nil logger")
}
}
func TestParseLogger_WithFormat(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "format-logger",
Log: &config.LogConfig{
Format: "json",
Level: "info",
},
})
if lg == nil {
t.Fatal("expected non-nil logger")
}
}
func TestParseLogger_FileOutput(t *testing.T) {
lg := ParseLogger(&config.LoggerConfig{
Name: "file-logger",
Log: &config.LogConfig{
Output: "/tmp/gost-test.log",
Rotation: &config.LogRotationConfig{
MaxSize: 10,
MaxAge: 7,
MaxBackups: 5,
LocalTime: true,
Compress: true,
},
},
})
if lg == nil {
t.Fatal("expected non-nil logger")
}
}
func TestList_EmptyLogger(t *testing.T) {
got := List("nonexistent")
if len(got) != 0 {
t.Fatal("expected empty list for unregistered name")
}
}
func TestList_WithNames(t *testing.T) {
got := List("nonexistent", "also_nonexistent")
if len(got) != 0 {
t.Fatal("expected empty list for unregistered names")
}
}
+104 -50
View File
@@ -6,14 +6,12 @@ import (
"regexp"
"strings"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/core/metadata/util"
xauth "github.com/go-gost/x/auth"
xbypass "github.com/go-gost/x/bypass"
xchain "github.com/go-gost/x/chain"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
@@ -21,37 +19,71 @@ import (
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
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"
"github.com/go-gost/x/registry"
"github.com/go-gost/x/routing"
)
// 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 {
pattern, _ := regexp.Compile(v.Match)
rw := chain.HTTPBodyRewriteSettings{
Type: v.Type,
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)
}
if pattern != nil || rw.Rewriter != nil {
out = append(out, rw)
}
}
return out
}
func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) {
if cfg == nil {
return nil, nil
}
if cfg.Connector == nil {
cfg.Connector = &config.ConnectorConfig{
Type: "http",
}
connCfg := cfg.Connector
if connCfg == nil {
connCfg = &config.ConnectorConfig{}
}
if connCfg.Type == "" {
connCfg.Type = "http"
}
if cfg.Dialer == nil {
cfg.Dialer = &config.DialerConfig{
Type: "tcp",
}
dialCfg := cfg.Dialer
if dialCfg == nil {
dialCfg = &config.DialerConfig{}
}
if dialCfg.Type == "" {
dialCfg.Type = "tcp"
}
nodeLogger := log.WithFields(map[string]any{
"hop": hop,
"kind": "node",
"node": cfg.Name,
"connector": cfg.Connector.Type,
"dialer": cfg.Dialer.Type,
"connector": connCfg.Type,
"dialer": dialCfg.Type,
})
serverName, _, _ := net.SplitHostPort(cfg.Addr)
tlsCfg := cfg.Connector.TLS
tlsCfg := connCfg.TLS
if tlsCfg == nil {
tlsCfg = &config.TLSConfig{}
}
@@ -64,34 +96,26 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
return nil, err
}
var nm metadata.Metadata
if cfg.Metadata != nil {
nm = mdx.NewMetadata(cfg.Metadata)
}
connectorLogger := nodeLogger.WithFields(map[string]any{
"kind": "connector",
})
var cr connector.Connector
if rf := registry.ConnectorRegistry().Get(cfg.Connector.Type); rf != nil {
if rf := registry.ConnectorRegistry().Get(connCfg.Type); rf != nil {
cr = rf(
connector.AuthOption(auth_parser.Info(cfg.Connector.Auth)),
connector.AuthOption(auth_parser.Info(connCfg.Auth)),
connector.TLSConfigOption(tlsConfig),
connector.LoggerOption(connectorLogger),
)
} else {
return nil, fmt.Errorf("unregistered connector: %s", cfg.Connector.Type)
return nil, fmt.Errorf("unregistered connector: %s", connCfg.Type)
}
if cfg.Connector.Metadata == nil {
cfg.Connector.Metadata = make(map[string]any)
}
if err := cr.Init(mdx.NewMetadata(cfg.Connector.Metadata)); err != nil {
if err := cr.Init(mdx.NewMetadata(connCfg.Metadata)); err != nil {
connectorLogger.Error("init: ", err)
return nil, err
}
tlsCfg = cfg.Dialer.TLS
tlsCfg = dialCfg.TLS
if tlsCfg == nil {
tlsCfg = &config.TLSConfig{}
}
@@ -104,55 +128,49 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
return nil, err
}
var ppv int
if nm != nil {
ppv = mdutil.GetInt(nm, parsing.MDKeyProxyProtocol)
}
md := mdx.NewMetadata(cfg.Metadata)
dialerLogger := nodeLogger.WithFields(map[string]any{
"kind": "dialer",
})
var d dialer.Dialer
if rf := registry.DialerRegistry().Get(cfg.Dialer.Type); rf != nil {
if rf := registry.DialerRegistry().Get(dialCfg.Type); rf != nil {
d = rf(
dialer.AuthOption(auth_parser.Info(cfg.Dialer.Auth)),
dialer.AuthOption(auth_parser.Info(dialCfg.Auth)),
dialer.TLSConfigOption(tlsConfig),
dialer.LoggerOption(dialerLogger),
dialer.ProxyProtocolOption(ppv),
dialer.ProxyProtocolOption(mdutil.GetInt(md, parsing.MDKeyProxyProtocol)),
)
} else {
return nil, fmt.Errorf("unregistered dialer: %s", cfg.Dialer.Type)
return nil, fmt.Errorf("unregistered dialer: %s", dialCfg.Type)
}
if cfg.Dialer.Metadata == nil {
cfg.Dialer.Metadata = make(map[string]any)
}
if err := d.Init(mdx.NewMetadata(cfg.Dialer.Metadata)); err != nil {
if err := d.Init(mdx.NewMetadata(dialCfg.Metadata)); err != nil {
dialerLogger.Error("init: ", err)
return nil, err
}
var sockOpts *chain.SockOpts
if cfg.SockOpts != nil {
if v := mdutil.GetInt(md, parsing.MDKeySoMark); v != 0 {
sockOpts = &chain.SockOpts{
Mark: cfg.SockOpts.Mark,
Mark: v,
}
}
tr := xchain.NewTransport(d, cr,
chain.AddrTransportOption(cfg.Addr),
chain.InterfaceTransportOption(cfg.Interface),
chain.NetnsTransportOption(cfg.Netns),
chain.InterfaceTransportOption(mdutil.GetString(md, parsing.MDKeyInterface)),
chain.NetnsTransportOption(mdutil.GetString(md, parsing.MDKeyNetns)),
chain.SockOptsTransportOption(sockOpts),
)
opts := []chain.NodeOption{
chain.TransportNodeOption(tr),
chain.BypassNodeOption(bypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
chain.ResoloverNodeOption(registry.ResolverRegistry().Get(cfg.Resolver)),
chain.BypassNodeOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
chain.ResolverNodeOption(registry.ResolverRegistry().Get(cfg.Resolver)),
chain.HostMapperNodeOption(registry.HostsRegistry().Get(cfg.Hosts)),
chain.MetadataNodeOption(nm),
chain.MetadataNodeOption(md),
chain.NetworkNodeOption(cfg.Network),
}
@@ -175,10 +193,37 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
opts = append(opts, chain.NodeFilterOption(settings))
}
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)
// Priority 0 means "use default": automatically set to the
// rule length so longer (more specific) rules outrank shorter
// ones. Use a negative priority to opt out of this behavior
// and always go through the selector.
if priority == 0 {
priority = len(cfg.Matcher.Rule)
}
opts = append(opts, chain.MatcherNodeOption(matcher))
} else {
log.Error(err)
priority = -1
}
}
opts = append(opts, chain.PriorityNodeOption(priority))
}
if cfg.HTTP != nil {
settings := &chain.HTTPNodeSettings{
Host: cfg.HTTP.Host,
Header: cfg.HTTP.Header,
Host: cfg.HTTP.Host,
RequestHeader: cfg.HTTP.RequestHeader,
ResponseHeader: cfg.HTTP.ResponseHeader,
}
if settings.RequestHeader == nil {
settings.RequestHeader = cfg.HTTP.Header
}
if auth := cfg.HTTP.Auth; auth != nil && auth.Username != "" {
@@ -191,14 +236,22 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
})),
)
}
for _, v := range cfg.HTTP.Rewrite {
rewriteURL := cfg.HTTP.RewriteURL
if rewriteURL == nil {
rewriteURL = cfg.HTTP.Rewrite
}
for _, v := range rewriteURL {
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
settings.Rewrite = append(settings.Rewrite, chain.HTTPURLRewriteSetting{
settings.RewriteURL = append(settings.RewriteURL, chain.HTTPURLRewriteSetting{
Pattern: pattern,
Replacement: v.Replacement,
})
}
}
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
opts = append(opts, chain.HTTPNodeOption(settings))
}
@@ -211,6 +264,7 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
tlsCfg.Options.MinVersion = o.MinVersion
tlsCfg.Options.MaxVersion = o.MaxVersion
tlsCfg.Options.CipherSuites = o.CipherSuites
tlsCfg.Options.ALPN = o.ALPN
}
opts = append(opts, chain.TLSNodeOption(tlsCfg))
}
+461
View File
@@ -0,0 +1,461 @@
package node
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
xlogger "github.com/go-gost/x/logger"
// Register connector and dialer implementations needed for node parsing.
_ "github.com/go-gost/x/connector/http"
_ "github.com/go-gost/x/dialer/tcp"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func testLogger() logger.Logger {
return xlogger.NewLogger(xlogger.OutputOption(io.Discard))
}
func TestParseNode_Nil(t *testing.T) {
n, err := ParseNode("test-hop", nil, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseNode_Defaults(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "node1",
Addr: "example.com:8080",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_DefaultsEmptyName(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "",
Addr: "example.com:8080",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_DefaultsEmptyAddr(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "no-addr",
Addr: "",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_ExplicitConnector(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "explicit-conn",
Addr: "example.com:8080",
Connector: &config.ConnectorConfig{
Type: "http",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_UnregisteredConnector(t *testing.T) {
_, err := ParseNode("test-hop", &config.NodeConfig{
Name: "bad-conn",
Addr: "example.com:8080",
Connector: &config.ConnectorConfig{
Type: "nonexistent-connector-type",
},
}, testLogger())
if err == nil {
t.Fatal("expected error for unregistered connector type")
}
}
func TestParseNode_UnregisteredDialer(t *testing.T) {
_, err := ParseNode("test-hop", &config.NodeConfig{
Name: "bad-dial",
Addr: "example.com:8080",
Dialer: &config.DialerConfig{
Type: "nonexistent-dialer-type",
},
}, testLogger())
if err == nil {
t.Fatal("expected error for unregistered dialer type")
}
}
func TestParseNode_ExplicitDialer(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "explicit-dial",
Addr: "example.com:8080",
Dialer: &config.DialerConfig{
Type: "tcp",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithBypass(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "bypass-node",
Addr: "example.com:8080",
Bypass: "bp1",
Bypasses: []string{"bp2", "bp3"},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithResolver(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "resolver-node",
Addr: "example.com:8080",
Resolver: "my-resolver",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHosts(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "hosts-node",
Addr: "example.com:8080",
Hosts: "my-hosts",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithNetwork(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "network-node",
Addr: "example.com:8080",
Network: "tcp",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithMetadata(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "meta-node",
Addr: "example.com:8080",
Metadata: map[string]any{
parsing.MDKeySoMark: 1234,
parsing.MDKeyInterface: "eth0",
parsing.MDKeyNetns: "myns",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithSockOpts(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "sockopts-node",
Addr: "example.com:8080",
SockOpts: &config.SockOptsConfig{
Mark: 9999,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithFilter(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "filter-node",
Addr: "example.com:8080",
Filter: &config.NodeFilterConfig{
Protocol: "http",
Host: "*.example.com",
Path: "/api",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithFilterStarBare(t *testing.T) {
// "*example.com" should become ".example.com"
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "star-bare-node",
Addr: "example.com:8080",
Filter: &config.NodeFilterConfig{
Host: "*example.com",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithMatcher(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "matcher-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: "dstport==80",
Priority: 10,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithMatcherAutoPriority(t *testing.T) {
// Priority auto-set to len(rule) when zero
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "auto-prio-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: "dstport==443",
Priority: 0,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHTTPNode(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "http-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
Host: "custom.example.com",
RequestHeader: map[string]string{"X-Custom": "value"},
ResponseHeader: map[string]string{"X-Response": "value"},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHTTPAuth(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "http-auth-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
Auth: &config.AuthConfig{
Username: "user",
Password: "pass",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHTTPDeprecatedHeader(t *testing.T) {
// Deprecated Header field should populate RequestHeader
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "http-deprecated-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
Header: map[string]string{"X-Old": "value"},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithTLSNode(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "tls-node",
Addr: "example.com:8080",
TLS: &config.TLSNodeConfig{
ServerName: "secure.example.com",
Secure: true,
Options: &config.TLSOptions{
MinVersion: "1.2",
MaxVersion: "1.3",
CipherSuites: []string{"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"},
ALPN: []string{"h2", "http/1.1"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_TLSServerNameFromAddr(t *testing.T) {
// When connector/dialer TLS ServerName is empty, it defaults from addr hostname.
// The server name is set on the connector's TLS config (internal), so the best
// verification is that ParseNode does not error and returns a node.
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "tls-servername-node",
Addr: "tls.example.com:443",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_ConnectorAuth(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "conn-auth-node",
Addr: "example.com:8080",
Connector: &config.ConnectorConfig{
Type: "http",
Auth: &config.AuthConfig{
Username: "connuser",
Password: "connpass",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_DialerAuth(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "dial-auth-node",
Addr: "example.com:8080",
Dialer: &config.DialerConfig{
Type: "tcp",
Auth: &config.AuthConfig{
Username: "dialuser",
Password: "dialpass",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_InvalidMatcherRule(t *testing.T) {
// An invalid matcher rule sets priority to -1
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "invalid-matcher-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: "",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node even with empty matcher rule")
}
}
func TestParseNode_DeprecatedFilterFields(t *testing.T) {
// Deprecated filter fields: Protocol/Host/Path via Filter
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "deprecated-node",
Addr: "example.com:8080",
Filter: &config.NodeFilterConfig{
Protocol: "socks5",
Host: "*.example.com",
Path: "/proxy",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
+4
View File
@@ -10,6 +10,9 @@ import (
observer_plugin "github.com/go-gost/x/observer/plugin"
)
// ParseObserver converts an ObserverConfig into an observer.Observer. It only
// supports plugin backends (HTTP or gRPC); returns nil when cfg or cfg.Plugin
// is nil.
func ParseObserver(cfg *config.ObserverConfig) observer.Observer {
if cfg == nil || cfg.Plugin == nil {
return nil
@@ -26,6 +29,7 @@ func ParseObserver(cfg *config.ObserverConfig) observer.Observer {
case "http":
return observer_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
+75
View File
@@ -0,0 +1,75 @@
package observer
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseObserver_Nil(t *testing.T) {
obs := ParseObserver(nil)
if obs != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseObserver_NilPlugin(t *testing.T) {
obs := ParseObserver(&config.ObserverConfig{
Name: "no-plugin",
Plugin: nil,
})
if obs != nil {
t.Fatal("expected nil when plugin is nil")
}
}
func TestParseObserver_PluginHTTP(t *testing.T) {
obs := ParseObserver(&config.ObserverConfig{
Name: "http-obs",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if obs == nil {
t.Fatal("expected non-nil observer")
}
}
func TestParseObserver_PluginGRPC(t *testing.T) {
obs := ParseObserver(&config.ObserverConfig{
Name: "grpc-obs",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "observer.local",
},
},
})
if obs == nil {
t.Fatal("expected non-nil observer")
}
}
func TestParseObserver_PluginDefaultType(t *testing.T) {
obs := ParseObserver(&config.ObserverConfig{
Name: "default-obs",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if obs == nil {
t.Fatal("expected non-nil observer")
}
}
+92 -11
View File
@@ -1,18 +1,99 @@
// Package parsing converts user-facing configuration structs into registered
// runtime components. Each sub-package (service, hop, chain, auth, …) owns the
// parsing logic for its corresponding core interface.
//
// Metadata keys defined below bridge the gap between named config fields and
// generic key-value metadata. When a value is not represented by an explicit
// field on a config struct it flows through the metadata system instead and
// these keys are used to look it up at component Init time.
package parsing
const (
// MDKeyProxyProtocol sets the HAProxy proxy-protocol version on a listener.
MDKeyProxyProtocol = "proxyProtocol"
MDKeyInterface = "interface"
MDKeySoMark = "so_mark"
MDKeyHash = "hash"
MDKeyPreUp = "preUp"
MDKeyPreDown = "preDown"
MDKeyPostUp = "postUp"
MDKeyPostDown = "postDown"
MDKeyIgnoreChain = "ignoreChain"
MDKeyEnableStats = "enableStats"
MDKeyRecorderDirection = "direction"
// MDKeyInterface binds the outbound connection to a specific network interface.
MDKeyInterface = "interface"
// MDKeySoMark sets the SO_MARK socket option on outbound connections.
MDKeySoMark = "so_mark"
// MDKeyHash selects the hash source used by hash-based selectors.
MDKeyHash = "hash"
// MDKeyPreUp is a shell command executed when a service starts listening.
MDKeyPreUp = "preUp"
// MDKeyPreDown is a shell command executed immediately before a service
// stops listening.
MDKeyPreDown = "preDown"
// MDKeyPostUp is a shell command executed after a service has successfully
// started listening.
MDKeyPostUp = "postUp"
// MDKeyPostDown is a shell command executed after a service has stopped
// listening.
MDKeyPostDown = "postDown"
// MDKeyIgnoreChain disables chain routing for a service. When set the
// listener/handler will not prepend a chain, which is useful for services
// that handle routing themselves (e.g. DNS, TUN).
MDKeyIgnoreChain = "ignoreChain"
// MDKeyEnableStats enables per-service connection statistics collection.
MDKeyEnableStats = "enableStats"
// MDKeyRecorderDirection toggles whether traffic direction (client→server or
// server→client) is recorded alongside the payload.
MDKeyRecorderDirection = "direction"
// MDKeyRecorderTimestampFormat sets the timestamp layout used in recorder
// output (Go time.Format layout).
MDKeyRecorderTimestampFormat = "timeStampFormat"
MDKeyRecorderHexdump = "hexdump"
// MDKeyRecorderHexdump enables hexadecimal dump output in recorders.
MDKeyRecorderHexdump = "hexdump"
// MDKeyRecorderHTTPBody enables capturing the HTTP request/response body in
// HTTP-based recorders.
MDKeyRecorderHTTPBody = "http.body"
// MDKeyRecorderHTTPMaxBodySize limits the maximum body size (in bytes) that
// an HTTP recorder will capture.
MDKeyRecorderHTTPMaxBodySize = "http.maxBodySize"
// MDKeyLimiterRefreshInterval sets how often a limiter reloads its
// configuration from an external source.
MDKeyLimiterRefreshInterval = "limiter.refreshInterval"
// MDKeyLimiterCleanupInterval sets how often a limiter purges expired
// entries from its internal state.
MDKeyLimiterCleanupInterval = "limiter.cleanupInterval"
// MDKeyLimiterScope controls whether a cached traffic limiter's scope is
// per-service ("service") or per-client ("client").
MDKeyLimiterScope = "limiter.scope"
// MDKeyObserverResetTraffic requests a traffic counter reset each time the
// observer reports.
MDKeyObserverResetTraffic = "observer.resetTraffic"
// MDKeyObserverPeriod sets the interval between observer reports.
MDKeyObserverPeriod = "observer.period"
// MDKeyNetns is the network namespace name or path used for the listener
// side (inbound).
MDKeyNetns = "netns"
// MDKeyNetnsOut is the network namespace name or path used for the handler
// (outbound) side.
MDKeyNetnsOut = "netns.out"
// 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"
)
+480
View File
@@ -0,0 +1,480 @@
package parser
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-gost/x/config"
)
func TestMergeConfig_NilBoth(t *testing.T) {
got := mergeConfig(nil, nil)
if got != nil {
t.Fatal("expected nil for two nil inputs")
}
}
func TestMergeConfig_NilFirst(t *testing.T) {
cfg2 := &config.Config{
Services: []*config.ServiceConfig{{Name: "svc"}},
}
got := mergeConfig(nil, cfg2)
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
t.Fatal("expected cfg2 to be returned when cfg1 is nil")
}
}
func TestMergeConfig_NilSecond(t *testing.T) {
cfg1 := &config.Config{
Services: []*config.ServiceConfig{{Name: "svc"}},
}
got := mergeConfig(cfg1, nil)
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
t.Fatal("expected cfg1 to be returned when cfg2 is nil")
}
}
func TestMergeConfig_AppendsAllSlices(t *testing.T) {
cfg1 := &config.Config{
Services: []*config.ServiceConfig{{Name: "s1"}},
Chains: []*config.ChainConfig{{Name: "c1"}},
Hops: []*config.HopConfig{{Name: "h1"}},
Authers: []*config.AutherConfig{{Name: "a1"}},
Admissions: []*config.AdmissionConfig{{Name: "ad1"}},
Bypasses: []*config.BypassConfig{{Name: "b1"}},
Resolvers: []*config.ResolverConfig{{Name: "r1"}},
Hosts: []*config.HostsConfig{{Name: "hosts1"}},
Ingresses: []*config.IngressConfig{{Name: "i1"}},
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"}},
Routers: []*config.RouterConfig{{Name: "rt1"}},
Observers: []*config.ObserverConfig{{Name: "obs1"}},
}
cfg2 := &config.Config{
Services: []*config.ServiceConfig{{Name: "s2"}},
Chains: []*config.ChainConfig{{Name: "c2"}},
Hops: []*config.HopConfig{{Name: "h2"}},
Authers: []*config.AutherConfig{{Name: "a2"}},
Admissions: []*config.AdmissionConfig{{Name: "ad2"}},
Bypasses: []*config.BypassConfig{{Name: "b2"}},
Resolvers: []*config.ResolverConfig{{Name: "r2"}},
Hosts: []*config.HostsConfig{{Name: "hosts2"}},
Ingresses: []*config.IngressConfig{{Name: "i2"}},
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"}},
Routers: []*config.RouterConfig{{Name: "rt2"}},
Observers: []*config.ObserverConfig{{Name: "obs2"}},
}
got := mergeConfig(cfg1, cfg2)
if len(got.Services) != 2 || got.Services[0].Name != "s1" || got.Services[1].Name != "s2" {
t.Fatal("services not properly appended")
}
if len(got.Chains) != 2 {
t.Fatal("chains not properly appended")
}
if len(got.Hops) != 2 {
t.Fatal("hops not properly appended")
}
if len(got.Authers) != 2 {
t.Fatal("authers not properly appended")
}
if len(got.Admissions) != 2 {
t.Fatal("admissions not properly appended")
}
if len(got.Bypasses) != 2 {
t.Fatal("bypasses not properly appended")
}
if len(got.Resolvers) != 2 {
t.Fatal("resolvers not properly appended")
}
if len(got.Hosts) != 2 {
t.Fatal("hosts not properly appended")
}
if len(got.Ingresses) != 2 {
t.Fatal("ingresses not properly appended")
}
if len(got.SDs) != 2 {
t.Fatal("SDs not properly appended")
}
if len(got.Recorders) != 2 {
t.Fatal("recorders not properly appended")
}
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")
}
if len(got.RLimiters) != 2 {
t.Fatal("rlimiters not properly appended")
}
if len(got.Loggers) != 2 {
t.Fatal("loggers not properly appended")
}
if len(got.Routers) != 2 {
t.Fatal("routers not properly appended")
}
if len(got.Observers) != 2 {
t.Fatal("observers not properly appended")
}
}
func TestMergeConfig_ScalarOverrides(t *testing.T) {
cfg1 := &config.Config{
TLS: &config.TLSConfig{ServerName: "old"},
Log: &config.LogConfig{Level: "info"},
API: &config.APIConfig{Addr: ":8080"},
Metrics: &config.MetricsConfig{Addr: ":9090"},
Profiling: &config.ProfilingConfig{Addr: ":6060"},
}
cfg2 := &config.Config{
TLS: &config.TLSConfig{ServerName: "new"},
Log: &config.LogConfig{Level: "debug"},
API: &config.APIConfig{Addr: ":8081"},
Metrics: &config.MetricsConfig{Addr: ":9091"},
Profiling: &config.ProfilingConfig{Addr: ":6061"},
}
got := mergeConfig(cfg1, cfg2)
if got.TLS.ServerName != "new" {
t.Fatalf("TLS not overridden: got %q, want %q", got.TLS.ServerName, "new")
}
if got.Log.Level != "debug" {
t.Fatalf("Log not overridden: got %q, want %q", got.Log.Level, "debug")
}
if got.API.Addr != ":8081" {
t.Fatalf("API not overridden: got %q, want %q", got.API.Addr, ":8081")
}
if got.Metrics.Addr != ":9091" {
t.Fatalf("Metrics not overridden: got %q, want %q", got.Metrics.Addr, ":9091")
}
if got.Profiling.Addr != ":6061" {
t.Fatalf("Profiling not overridden: got %q, want %q", got.Profiling.Addr, ":6061")
}
}
func TestMergeConfig_ScalarKeepsCfg1WhenCfg2Nil(t *testing.T) {
cfg1 := &config.Config{
TLS: &config.TLSConfig{ServerName: "keep"},
Log: &config.LogConfig{Level: "warn"},
API: &config.APIConfig{Addr: ":8080"},
Metrics: &config.MetricsConfig{Addr: ":9090"},
Profiling: &config.ProfilingConfig{Addr: ":6060"},
}
cfg2 := &config.Config{} // all scalars nil
got := mergeConfig(cfg1, cfg2)
if got.TLS.ServerName != "keep" {
t.Fatal("TLS should be kept from cfg1")
}
if got.Log.Level != "warn" {
t.Fatal("Log should be kept from cfg1")
}
if got.API.Addr != ":8080" {
t.Fatal("API should be kept from cfg1")
}
if got.Metrics.Addr != ":9090" {
t.Fatal("Metrics should be kept from cfg1")
}
if got.Profiling.Addr != ":6060" {
t.Fatal("Profiling should be kept from cfg1")
}
}
func TestInit(t *testing.T) {
Init(Args{
CfgFiles: []string{"test.yml"},
Services: []string{"s1"},
Nodes: []string{"n1"},
Debug: true,
Trace: false,
ApiAddr: ":8080",
MetricsAddr: ":9090",
})
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")
}
if len(defaultParser.args.Nodes) != 1 || defaultParser.args.Nodes[0] != "n1" {
t.Fatal("Nodes not set")
}
if !defaultParser.args.Debug {
t.Fatal("Debug not set")
}
if defaultParser.args.Trace {
t.Fatal("Trace should be false")
}
if defaultParser.args.ApiAddr != ":8080" {
t.Fatal("ApiAddr not set")
}
if defaultParser.args.MetricsAddr != ":9090" {
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")
}
}
+361
View File
@@ -0,0 +1,361 @@
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"
"github.com/go-gost/x/config/cmd"
xmd "github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
)
var (
defaultParser = &parser{}
)
// Init stores the parsed CLI arguments so that subsequent calls to Parse can
// merge them with config-file and environment-variable sources.
func Init(args Args) {
defaultParser = &parser{
args: args,
}
}
// Parse reads configuration from the sources specified during Init (config
// file, CLI services/nodes, and environment variables) and returns a merged
// Config ready for loading.
func Parse() (*config.Config, error) {
return defaultParser.Parse()
}
// Args holds the raw CLI flags and positional arguments that Init will merge
// into the final configuration.
type Args struct {
// 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
// Nodes is the list of chain-node definitions from -F flags.
Nodes []string
// Debug toggles debug-level logging.
Debug bool
// Trace toggles trace-level logging (supersedes Debug).
Trace bool
// ApiAddr is the address for the HTTP API server.
ApiAddr string
// MetricsAddr is the address for the Prometheus metrics endpoint.
MetricsAddr string
}
type parser struct {
args Args
}
func (p *parser) Parse() (*config.Config, error) {
cfg := &config.Config{}
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)
if err != nil {
return nil, err
}
cfg = mergeConfig(cfg, cmdCfg)
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
}
}
if v := os.Getenv("GOST_LOGGER_LEVEL"); v != "" {
if cfg.Log == nil {
cfg.Log = &config.LogConfig{}
}
cfg.Log.Level = v
}
if v := os.Getenv("GOST_API"); v != "" {
if cfg.API == nil {
cfg.API = &config.APIConfig{}
}
cfg.API.Addr = v
}
if v := os.Getenv("GOST_METRICS"); v != "" {
if cfg.Metrics == nil {
cfg.Metrics = &config.MetricsConfig{}
}
cfg.Metrics.Addr = v
}
if v := os.Getenv("GOST_PROFILING"); v != "" {
if cfg.Profiling == nil {
cfg.Profiling = &config.ProfilingConfig{}
}
cfg.Profiling.Addr = v
}
if p.args.Debug || p.args.Trace {
if cfg.Log == nil {
cfg.Log = &config.LogConfig{}
}
cfg.Log.Level = string(logger.DebugLevel)
if p.args.Trace {
cfg.Log.Level = string(logger.TraceLevel)
}
}
if p.args.ApiAddr != "" {
cfg.API = &config.APIConfig{
Addr: p.args.ApiAddr,
}
if url, _ := cmd.Norm(p.args.ApiAddr); url != nil {
cfg.API.Addr = url.Host
if url.User != nil {
username := url.User.Username()
password, _ := url.User.Password()
cfg.API.Auth = &config.AuthConfig{
Username: username,
Password: password,
}
}
m := map[string]any{}
for k, v := range url.Query() {
if len(v) > 0 {
m[k] = v[0]
}
}
md := xmd.NewMetadata(m)
cfg.API.PathPrefix = mdutil.GetString(md, "pathPrefix")
cfg.API.AccessLog = mdutil.GetBool(md, "accesslog")
}
}
if p.args.MetricsAddr != "" {
cfg.Metrics = &config.MetricsConfig{
Addr: p.args.MetricsAddr,
}
if url, _ := cmd.Norm(p.args.MetricsAddr); url != nil {
cfg.Metrics.Addr = url.Host
if url.User != nil {
username := url.User.Username()
password, _ := url.User.Password()
cfg.Metrics.Auth = &config.AuthConfig{
Username: username,
Password: password,
}
}
m := map[string]any{}
for k, v := range url.Query() {
if len(v) > 0 {
m[k] = v[0]
}
}
md := xmd.NewMetadata(m)
cfg.Metrics.Path = mdutil.GetString(md, "path")
}
}
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
}
if cfg2 == nil {
return cfg1
}
cfg := &config.Config{
Services: append(cfg1.Services, cfg2.Services...),
Chains: append(cfg1.Chains, cfg2.Chains...),
Hops: append(cfg1.Hops, cfg2.Hops...),
Authers: append(cfg1.Authers, cfg2.Authers...),
Admissions: append(cfg1.Admissions, cfg2.Admissions...),
Bypasses: append(cfg1.Bypasses, cfg2.Bypasses...),
Resolvers: append(cfg1.Resolvers, cfg2.Resolvers...),
Hosts: append(cfg1.Hosts, cfg2.Hosts...),
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
SDs: append(cfg1.SDs, cfg2.SDs...),
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
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...),
Routers: append(cfg1.Routers, cfg2.Routers...),
Observers: append(cfg1.Observers, cfg2.Observers...),
TLS: cfg1.TLS,
Log: cfg1.Log,
API: cfg1.API,
Metrics: cfg1.Metrics,
Profiling: cfg1.Profiling,
}
if cfg2.TLS != nil {
cfg.TLS = cfg2.TLS
}
if cfg2.Log != nil {
cfg.Log = cfg2.Log
}
if cfg2.API != nil {
cfg.API = cfg2.API
}
if cfg2.Metrics != nil {
cfg.Metrics = cfg2.Metrics
}
if cfg2.Profiling != nil {
cfg.Profiling = cfg2.Profiling
}
return cfg
}
+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)
}
+60 -4
View File
@@ -2,15 +2,31 @@ package recorder
import (
"crypto/tls"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/recorder"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xrecorder "github.com/go-gost/x/recorder"
recorder_plugin "github.com/go-gost/x/recorder/plugin"
"gopkg.in/natefinch/lumberjack.v2"
)
type discardCloser struct{}
func (discardCloser) Write(p []byte) (n int, err error) { return len(p), nil }
func (discardCloser) Close() error { return nil }
// ParseRecorder converts a RecorderConfig into a recorder.Recorder. It
// supports plugin backends (HTTP or gRPC), file output (with optional
// rotation), TCP syslog-style delivery, HTTP POST delivery, and Redis storage
// (set, list, or sorted set). Returns nil when cfg is nil or no backend is
// configured.
func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
if cfg == nil {
return nil
@@ -28,6 +44,7 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
case "http":
return recorder_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -41,17 +58,50 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
}
if cfg.File != nil && cfg.File.Path != "" {
return xrecorder.FileRecorder(cfg.File.Path,
xrecorder.SepRecorderOption(cfg.File.Sep),
var out io.WriteCloser = discardCloser{}
if cfg.File.Rotation != nil {
out = &lumberjack.Logger{
Filename: cfg.File.Path,
MaxSize: cfg.File.Rotation.MaxSize,
MaxAge: cfg.File.Rotation.MaxAge,
MaxBackups: cfg.File.Rotation.MaxBackups,
LocalTime: cfg.File.Rotation.LocalTime,
Compress: cfg.File.Rotation.Compress,
}
} else {
os.MkdirAll(filepath.Dir(cfg.File.Path), 0755)
f, err := os.OpenFile(cfg.File.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
logger.Default().Warn(err)
} else {
out = f
}
}
return xrecorder.FileRecorder(out,
xrecorder.RecorderFileRecorderOption(cfg.Name),
xrecorder.SepFileRecorderOption(cfg.File.Sep),
)
}
if cfg.TCP != nil && cfg.TCP.Addr != "" {
return xrecorder.TCPRecorder(cfg.TCP.Addr, xrecorder.TimeoutTCPRecorderOption(cfg.TCP.Timeout))
return xrecorder.TCPRecorder(cfg.TCP.Addr,
xrecorder.RecorderTCPRecorderOption(cfg.Name),
xrecorder.TimeoutTCPRecorderOption(cfg.TCP.Timeout),
)
}
if cfg.HTTP != nil && cfg.HTTP.URL != "" {
return xrecorder.HTTPRecorder(cfg.HTTP.URL, xrecorder.TimeoutHTTPRecorderOption(cfg.HTTP.Timeout))
h := http.Header{}
for k, v := range cfg.HTTP.Header {
h.Add(k, v)
}
return xrecorder.HTTPRecorder(cfg.HTTP.URL,
xrecorder.RecorderHTTPRecorderOption(cfg.Name),
xrecorder.TimeoutHTTPRecorderOption(cfg.HTTP.Timeout),
xrecorder.HeaderHTTPRecorderOption(h),
)
}
if cfg.Redis != nil &&
@@ -60,20 +110,26 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
switch cfg.Redis.Type {
case "list": // redis list
return xrecorder.RedisListRecorder(cfg.Redis.Addr,
xrecorder.RecorderRedisRecorderOption(cfg.Name),
xrecorder.DBRedisRecorderOption(cfg.Redis.DB),
xrecorder.KeyRedisRecorderOption(cfg.Redis.Key),
xrecorder.UsernameRedisRecorderOption(cfg.Redis.Username),
xrecorder.PasswordRedisRecorderOption(cfg.Redis.Password),
)
case "sset": // sorted set
return xrecorder.RedisSortedSetRecorder(cfg.Redis.Addr,
xrecorder.RecorderRedisRecorderOption(cfg.Name),
xrecorder.DBRedisRecorderOption(cfg.Redis.DB),
xrecorder.KeyRedisRecorderOption(cfg.Redis.Key),
xrecorder.UsernameRedisRecorderOption(cfg.Redis.Username),
xrecorder.PasswordRedisRecorderOption(cfg.Redis.Password),
)
default: // redis set
return xrecorder.RedisSetRecorder(cfg.Redis.Addr,
xrecorder.RecorderRedisRecorderOption(cfg.Name),
xrecorder.DBRedisRecorderOption(cfg.Redis.DB),
xrecorder.KeyRedisRecorderOption(cfg.Redis.Key),
xrecorder.UsernameRedisRecorderOption(cfg.Redis.Username),
xrecorder.PasswordRedisRecorderOption(cfg.Redis.Password),
)
}
+199
View File
@@ -0,0 +1,199 @@
package recorder
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseRecorder_Nil(t *testing.T) {
r := ParseRecorder(nil)
if r != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseRecorder_PluginHTTP(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "http-rec",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if r == nil {
t.Fatal("expected non-nil recorder")
}
}
func TestParseRecorder_PluginGRPC(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "grpc-rec",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "recorder.local",
},
},
})
if r == nil {
t.Fatal("expected non-nil recorder")
}
}
func TestParseRecorder_PluginDefaultType(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "default-rec",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if r == nil {
t.Fatal("expected non-nil recorder")
}
}
func TestParseRecorder_TCP(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "tcp-rec",
TCP: &config.TCPRecorder{
Addr: "127.0.0.1:9999",
Timeout: 0,
},
})
if r == nil {
t.Fatal("expected non-nil recorder for TCP")
}
}
func TestParseRecorder_HTTP(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "http-rec",
HTTP: &config.HTTPRecorder{
URL: "http://localhost:8080/record",
Timeout: 0,
Header: map[string]string{"X-Test": "value"},
},
})
if r == nil {
t.Fatal("expected non-nil recorder for HTTP")
}
}
func TestParseRecorder_File(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "file-rec",
File: &config.FileRecorder{
Path: "/tmp/recorder.log",
Sep: "\n",
},
})
if r == nil {
t.Fatal("expected non-nil recorder for file")
}
}
func TestParseRecorder_FileWithRotation(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "file-rot-rec",
File: &config.FileRecorder{
Path: "/tmp/recorder-rot.log",
Rotation: &config.LogRotationConfig{
MaxSize: 100,
MaxAge: 30,
MaxBackups: 10,
LocalTime: true,
Compress: true,
},
},
})
if r == nil {
t.Fatal("expected non-nil recorder for file with rotation")
}
}
func TestParseRecorder_RedisSet(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "redis-set-rec",
Redis: &config.RedisRecorder{
Addr: "127.0.0.1:6379",
Key: "recorder-set",
Type: "set",
},
})
if r == nil {
t.Fatal("expected non-nil recorder for redis set")
}
}
func TestParseRecorder_RedisList(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "redis-list-rec",
Redis: &config.RedisRecorder{
Addr: "127.0.0.1:6379",
Key: "recorder-list",
Type: "list",
},
})
if r == nil {
t.Fatal("expected non-nil recorder for redis list")
}
}
func TestParseRecorder_RedisSortedSet(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "redis-sset-rec",
Redis: &config.RedisRecorder{
Addr: "127.0.0.1:6379",
Key: "recorder-sset",
Type: "sset",
},
})
if r == nil {
t.Fatal("expected non-nil recorder for redis sorted set")
}
}
func TestParseRecorder_EmptyFileReturnsNil(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "empty-rec",
})
if r != nil {
t.Fatal("expected nil recorder when no sub-config is provided")
}
}
func TestParseRecorder_FileWithEmptyPathReturnsNil(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "empty-path-rec",
File: &config.FileRecorder{
Path: "",
},
})
if r != nil {
t.Fatal("expected nil recorder when file path is empty")
}
}
func TestParseRecorder_TCPWithEmptyAddrReturnsNil(t *testing.T) {
r := ParseRecorder(&config.RecorderConfig{
Name: "empty-tcp-rec",
TCP: &config.TCPRecorder{
Addr: "",
},
})
if r != nil {
t.Fatal("expected nil recorder when TCP addr is empty")
}
}
+5
View File
@@ -14,6 +14,10 @@ import (
resolver_plugin "github.com/go-gost/x/resolver/plugin"
)
// ParseResolver converts a ResolverConfig into a resolver.Resolver. It
// supports plugin backends (HTTP or gRPC) and inline nameserver definitions
// with optional chain-based upstream routing, TTL, timeout, and
// prefer/async/only settings. Returns nil with no error when cfg is nil.
func ParseResolver(cfg *config.ResolverConfig) (resolver.Resolver, error) {
if cfg == nil {
return nil, nil
@@ -31,6 +35,7 @@ func ParseResolver(cfg *config.ResolverConfig) (resolver.Resolver, error) {
case "http":
return resolver_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
), nil
+146
View File
@@ -0,0 +1,146 @@
package resolver
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseResolver_Nil(t *testing.T) {
r, err := ParseResolver(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseResolver_PluginHTTP(t *testing.T) {
r, err := ParseResolver(&config.ResolverConfig{
Name: "http-res",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r == nil {
t.Fatal("expected non-nil resolver")
}
}
func TestParseResolver_PluginGRPC(t *testing.T) {
r, err := ParseResolver(&config.ResolverConfig{
Name: "grpc-res",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "resolver.local",
},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r == nil {
t.Fatal("expected non-nil resolver")
}
}
func TestParseResolver_PluginDefaultType(t *testing.T) {
r, err := ParseResolver(&config.ResolverConfig{
Name: "default-res",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r == nil {
t.Fatal("expected non-nil resolver")
}
}
func TestParseResolver_WithNameservers(t *testing.T) {
r, err := ParseResolver(&config.ResolverConfig{
Name: "ns-res",
Nameservers: []*config.NameserverConfig{
{Addr: "8.8.8.8", Prefer: "ipv4"},
{Addr: "8.8.4.4", Prefer: "ipv4"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r == nil {
t.Fatal("expected non-nil resolver")
}
}
func TestParseResolver_EmptyNameservers(t *testing.T) {
r, err := ParseResolver(&config.ResolverConfig{
Name: "empty-ns",
Nameservers: []*config.NameserverConfig{},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r == nil {
t.Fatal("expected non-nil resolver even with empty nameservers")
}
}
func TestParseResolver_NameserverWithClientIP(t *testing.T) {
r, err := ParseResolver(&config.ResolverConfig{
Name: "clientip-res",
Nameservers: []*config.NameserverConfig{
{Addr: "8.8.8.8", ClientIP: "1.2.3.4"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r == nil {
t.Fatal("expected non-nil resolver")
}
}
func TestParseResolver_NameserverWithAllFields(t *testing.T) {
r, err := ParseResolver(&config.ResolverConfig{
Name: "full-res",
Nameservers: []*config.NameserverConfig{
{
Addr: "8.8.8.8:53",
Chain: "chain1",
Prefer: "ipv4",
ClientIP: "1.2.3.4",
Hostname: "dns.example.com",
TTL: 300,
Timeout: 5,
Async: true,
Only: "A",
},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r == nil {
t.Fatal("expected non-nil resolver")
}
}
+48
View File
@@ -0,0 +1,48 @@
package rewriter
import (
"crypto/tls"
"strings"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
rewriter_plugin "github.com/go-gost/x/rewriter/plugin"
)
// ParseRewriter converts a RewriterConfig into a rewriter.Rewriter.
// It currently supports plugin backends only (HTTP or gRPC).
// Returns nil when cfg is nil or no backend is configured.
func ParseRewriter(cfg *config.RewriterConfig) rewriter.Rewriter {
if cfg == nil {
return nil
}
if cfg.Plugin != nil {
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
ServerName: cfg.Plugin.TLS.ServerName,
InsecureSkipVerify: !cfg.Plugin.TLS.Secure,
}
}
switch strings.ToLower(cfg.Plugin.Type) {
case "http":
return rewriter_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
default:
return rewriter_plugin.NewGRPCPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
}
}
return nil
}
+23 -5
View File
@@ -14,6 +14,9 @@ import (
router_plugin "github.com/go-gost/x/router/plugin"
)
// ParseRouter converts a RouterConfig into a router.Router. It supports plugin
// backends (HTTP or gRPC) or inline static route definitions with optional
// file, Redis, and HTTP hot-reload support.
func ParseRouter(cfg *config.RouterConfig) router.Router {
if cfg == nil {
return nil
@@ -31,6 +34,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
case "http":
return router_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
@@ -45,18 +49,29 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
var routes []*router.Route
for _, route := range cfg.Routes {
_, ipNet, _ := net.ParseCIDR(route.Net)
if ipNet == nil {
if route == nil {
continue
}
gw := net.ParseIP(route.Gateway)
if gw == nil {
_, ipNet, _ := net.ParseCIDR(route.Net)
dst := route.Dst
if dst != "" {
if _, parsed, _ := net.ParseCIDR(dst); parsed != nil {
ipNet = parsed
}
} else {
if ipNet != nil {
dst = ipNet.String()
}
}
if dst == "" || route.Gateway == "" {
continue
}
routes = append(routes, &router.Route{
Net: ipNet,
Gateway: gw,
Dst: dst,
Gateway: route.Gateway,
})
}
opts := []xrouter.Option{
@@ -76,6 +91,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
opts = append(opts, xrouter.RedisLoaderOption(loader.RedisListLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -83,6 +99,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
opts = append(opts, xrouter.RedisLoaderOption(loader.RedisSetLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
@@ -90,6 +107,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
opts = append(opts, xrouter.RedisLoaderOption(loader.RedisHashLoader(
cfg.Redis.Addr,
loader.DBRedisLoaderOption(cfg.Redis.DB),
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
loader.KeyRedisLoaderOption(cfg.Redis.Key),
)))
+237
View File
@@ -0,0 +1,237 @@
package router
import (
"context"
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseRouter_Nil(t *testing.T) {
r := ParseRouter(nil)
if r != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseRouter_PluginHTTP(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "http-rt",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_PluginGRPC(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "grpc-rt",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "router.local",
},
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_PluginDefaultType(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "default-rt",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_WithCIDRRoutes(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "cidr-rt",
Routes: []*config.RouterRouteConfig{
{Net: "192.168.1.0/24", Gateway: "10.0.0.1"},
{Net: "10.0.0.0/8", Gateway: "10.0.0.2"},
},
})
if r == nil {
t.Fatal("expected non-nil router with CIDR routes")
}
}
func TestParseRouter_WithDstRoutes(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "dst-rt",
Routes: []*config.RouterRouteConfig{
{Dst: "192.168.1.0/24", Gateway: "10.0.0.1"},
},
})
if r == nil {
t.Fatal("expected non-nil router with dst routes")
}
}
func TestParseRouter_MissingGatewaySkipped(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "skip-rt",
Routes: []*config.RouterRouteConfig{
{Net: "192.168.1.0/24", Gateway: ""}, // skipped: no gateway
{Net: "10.0.0.0/8", Gateway: "10.0.0.1"}, // kept
},
})
if r == nil {
t.Fatal("expected non-nil router (with one valid route)")
}
}
func TestParseRouter_MissingDstSkipped(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "no-dst-rt",
Routes: []*config.RouterRouteConfig{
{Dst: "", Gateway: "10.0.0.1"}, // skipped: no dst
},
})
if r == nil {
t.Fatal("expected non-nil router (all routes skipped)")
}
}
func TestParseRouter_InvalidCIDRSkipped(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "invalid-cidr-rt",
Routes: []*config.RouterRouteConfig{
{Net: "not-a-cidr", Gateway: "10.0.0.1"}, // invalid CIDR with empty Dst, skipped
{Net: "192.168.1.0/24", Gateway: "10.0.0.2"}, // valid
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_NilRouteSkipped(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "nil-route-rt",
Routes: []*config.RouterRouteConfig{
nil,
{Net: "192.168.1.0/24", Gateway: "10.0.0.1"},
},
})
if r == nil {
t.Fatal("expected non-nil router when nil route is in list")
}
}
func TestParseRouter_EmptyRoutes(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "empty-routes",
Routes: []*config.RouterRouteConfig{},
})
if r == nil {
t.Fatal("expected non-nil router with empty routes")
}
}
func TestParseRouter_FileLoader(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "file-rt",
File: &config.FileLoader{Path: "/tmp/routes.txt"},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_HTTPLoader(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "http-rt",
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/routes"},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_RedisHashLoader(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "redis-hash-rt",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "routes-hash",
Type: "hash",
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_RedisListLoader(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "redis-list-rt",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "routes-list",
Type: "list",
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_RedisSetLoader(t *testing.T) {
r := ParseRouter(&config.RouterConfig{
Name: "redis-set-rt",
Redis: &config.RedisLoader{
Addr: "127.0.0.1:6379",
Key: "routes-set",
Type: "set",
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
}
func TestParseRouter_DstSyncedFromNet(t *testing.T) {
// When Dst is empty but Net is provided, Net is converted to Dst
r := ParseRouter(&config.RouterConfig{
Name: "net-to-dst-rt",
Routes: []*config.RouterRouteConfig{
{Net: "192.168.1.0/24", Gateway: "10.0.0.1"},
},
})
if r == nil {
t.Fatal("expected non-nil router")
}
route := r.GetRoute(context.Background(), "192.168.1.5")
if route == nil {
t.Fatal("expected non-nil route for IP in CIDR range")
}
if route.Dst != "192.168.1.0/24" {
t.Fatalf("Dst = %q, want %q", route.Dst, "192.168.1.0/24")
}
if route.Gateway != "10.0.0.1" {
t.Fatalf("Gateway = %q, want %q", route.Gateway, "10.0.0.1")
}
}
+3
View File
@@ -10,6 +10,8 @@ import (
sd_plugin "github.com/go-gost/x/sd/plugin"
)
// ParseSD converts an SDConfig into an sd.SD. It only supports plugin backends
// (HTTP or gRPC); returns nil when cfg or cfg.Plugin is nil.
func ParseSD(cfg *config.SDConfig) sd.SD {
if cfg == nil || cfg.Plugin == nil {
return nil
@@ -26,6 +28,7 @@ func ParseSD(cfg *config.SDConfig) sd.SD {
case "http":
return sd_plugin.NewHTTPPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
plugin.TimeoutOption(cfg.Plugin.Timeout),
)
+75
View File
@@ -0,0 +1,75 @@
package sd
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func TestParseSD_Nil(t *testing.T) {
s := ParseSD(nil)
if s != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseSD_NilPlugin(t *testing.T) {
s := ParseSD(&config.SDConfig{
Name: "no-plugin",
Plugin: nil,
})
if s != nil {
t.Fatal("expected nil when plugin is nil")
}
}
func TestParseSD_PluginHTTP(t *testing.T) {
s := ParseSD(&config.SDConfig{
Name: "http-sd",
Plugin: &config.PluginConfig{
Type: "http",
Addr: "127.0.0.1:9000",
},
})
if s == nil {
t.Fatal("expected non-nil SD")
}
}
func TestParseSD_PluginGRPC(t *testing.T) {
s := ParseSD(&config.SDConfig{
Name: "grpc-sd",
Plugin: &config.PluginConfig{
Type: "grpc",
Addr: "127.0.0.1:9001",
Token: "secret",
TLS: &config.TLSConfig{
Secure: true,
ServerName: "sd.local",
},
},
})
if s == nil {
t.Fatal("expected non-nil SD")
}
}
func TestParseSD_PluginDefaultType(t *testing.T) {
s := ParseSD(&config.SDConfig{
Name: "default-sd",
Plugin: &config.PluginConfig{
Addr: "127.0.0.1:9002",
},
})
if s == nil {
t.Fatal("expected non-nil SD")
}
}
+12
View File
@@ -7,6 +7,9 @@ import (
xs "github.com/go-gost/x/selector"
)
// ParseChainSelector creates a chain-level selector from a SelectorConfig. If
// cfg is nil it returns nil. Strategy defaults to round-robin when unrecognized
// or unset.
func ParseChainSelector(cfg *config.SelectorConfig) selector.Selector[chain.Chainer] {
if cfg == nil {
return nil
@@ -32,6 +35,9 @@ func ParseChainSelector(cfg *config.SelectorConfig) selector.Selector[chain.Chai
)
}
// ParseNodeSelector creates a node-level selector from a SelectorConfig. If
// cfg is nil it returns nil. Supported strategies: round/rr, random/rand,
// fifo/ha, hash, and parallel. Defaults to round-robin.
func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node] {
if cfg == nil {
return nil
@@ -47,6 +53,8 @@ func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node
strategy = xs.FIFOStrategy[*chain.Node]()
case "hash":
strategy = xs.HashStrategy[*chain.Node]()
case "parallel":
strategy = xs.ParallelStrategy[*chain.Node]()
default:
strategy = xs.RoundRobinStrategy[*chain.Node]()
}
@@ -58,6 +66,8 @@ func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node
)
}
// DefaultNodeSelector returns a node selector using round-robin strategy with
// default fail filter settings.
func DefaultNodeSelector() selector.Selector[*chain.Node] {
return xs.NewSelector(
xs.RoundRobinStrategy[*chain.Node](),
@@ -66,6 +76,8 @@ func DefaultNodeSelector() selector.Selector[*chain.Node] {
)
}
// DefaultChainSelector returns a chain selector using round-robin strategy
// with default fail filter settings.
func DefaultChainSelector() selector.Selector[chain.Chainer] {
return xs.NewSelector(
xs.RoundRobinStrategy[chain.Chainer](),
+144
View File
@@ -0,0 +1,144 @@
package selector
import (
"testing"
"github.com/go-gost/x/config"
)
func TestParseChainSelector_Nil(t *testing.T) {
sel := ParseChainSelector(nil)
if sel != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseChainSelector_RoundRobin(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "round"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseChainSelector_RR(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "rr"})
if sel == nil {
t.Fatal("expected non-nil selector for rr strategy")
}
}
func TestParseChainSelector_Random(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "random"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseChainSelector_Rand(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "rand"})
if sel == nil {
t.Fatal("expected non-nil selector for rand strategy")
}
}
func TestParseChainSelector_FIFO(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "fifo"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseChainSelector_HA(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "ha"})
if sel == nil {
t.Fatal("expected non-nil selector for ha strategy")
}
}
func TestParseChainSelector_Hash(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "hash"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseChainSelector_DefaultStrategy(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "unknown"})
if sel == nil {
t.Fatal("expected non-nil selector for unknown strategy (should default to round-robin)")
}
}
func TestParseChainSelector_WithMaxFails(t *testing.T) {
sel := ParseChainSelector(&config.SelectorConfig{
Strategy: "round",
MaxFails: 3,
FailTimeout: 0,
})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseNodeSelector_Nil(t *testing.T) {
sel := ParseNodeSelector(nil)
if sel != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseNodeSelector_RoundRobin(t *testing.T) {
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "round"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseNodeSelector_Random(t *testing.T) {
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "random"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseNodeSelector_FIFO(t *testing.T) {
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "fifo"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseNodeSelector_Hash(t *testing.T) {
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "hash"})
if sel == nil {
t.Fatal("expected non-nil selector")
}
}
func TestParseNodeSelector_Parallel(t *testing.T) {
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "parallel"})
if sel == nil {
t.Fatal("expected non-nil selector for parallel strategy")
}
}
func TestParseNodeSelector_DefaultStrategy(t *testing.T) {
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "unknown"})
if sel == nil {
t.Fatal("expected non-nil selector for unknown strategy (should default to round-robin)")
}
}
func TestDefaultNodeSelector(t *testing.T) {
sel := DefaultNodeSelector()
if sel == nil {
t.Fatal("expected non-nil default node selector")
}
}
func TestDefaultChainSelector(t *testing.T) {
sel := DefaultChainSelector()
if sel == nil {
t.Fatal("expected non-nil default chain selector")
}
}
+134 -18
View File
@@ -6,19 +6,20 @@ import (
"strings"
"time"
"github.com/go-gost/core/admission"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/logger"
mdutil "github.com/go-gost/core/metadata/util"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
"github.com/go-gost/core/selector"
"github.com/go-gost/core/service"
xadmission "github.com/go-gost/x/admission"
xauth "github.com/go-gost/x/auth"
xbypass "github.com/go-gost/x/bypass"
xchain "github.com/go-gost/x/chain"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
@@ -29,12 +30,39 @@ import (
logger_parser "github.com/go-gost/x/config/parsing/logger"
selector_parser "github.com/go-gost/x/config/parsing/selector"
tls_util "github.com/go-gost/x/internal/util/tls"
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xstats "github.com/go-gost/x/observer/stats"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
xservice "github.com/go-gost/x/service"
"github.com/vishvananda/netns"
)
// plaintextListeners are listener types that never terminate TLS on their
// accepted connections, so any certFile/keyFile/caFile configured for them is
// silently ignored. This catches the common footgun where a user writes e.g.
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
// new plaintext listeners are added.
var plaintextListeners = map[string]bool{
"tcp": true, "udp": true,
"ws": true, "mws": true,
"redirect": true, "tproxy": true,
"rtcp": true, "rudp": true,
"unix": true, "runix": true, "serial": true, "stdio": true,
}
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
// It defaults the listener to "tcp" and the handler to "auto", resolves named
// components from the registry (authers, admissions, bypasses, resolvers,
// hosts, chains, hops, limiters, recorders, observers), composes TLS settings,
// and applies metadata-driven options (proxy protocol, netns, sockopts,
// pre/post hooks, stats, etc.). It returns an error if the listener or handler
// type is unknown or if construction of any sub-component fails.
func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
if cfg.Listener == nil {
cfg.Listener = &config.ListenerConfig{}
@@ -73,6 +101,13 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if tlsConfig == nil {
tlsConfig = parsing.DefaultTLSConfig().Clone()
tls_util.SetTLSOptions(tlsConfig, tlsCfg.Options)
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...)
@@ -83,7 +118,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
var auther auth.Authenticator
if len(authers) > 0 {
auther = auth.AuthenticatorGroup(authers...)
auther = xauth.AuthenticatorGroup(authers...)
}
admissions := admission_parser.List(cfg.Admission, cfg.Admissions...)
@@ -99,10 +134,16 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
ifce := cfg.Interface
var preUp, preDown, postUp, postDown []string
var ignoreChain bool
var pStats *stats.Stats
var observePeriod time.Duration
var pStats stats.Stats
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
var limiterScope string
if cfg.Metadata != nil {
md := metadata.NewMetadata(cfg.Metadata)
ppv = mdutil.GetInt(md, parsing.MDKeyProxyProtocol)
@@ -121,12 +162,26 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
ignoreChain = mdutil.GetBool(md, parsing.MDKeyIgnoreChain)
if mdutil.GetBool(md, parsing.MDKeyEnableStats) {
pStats = &stats.Stats{}
pStats = xstats.NewStats(mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic))
}
observePeriod = mdutil.GetDuration(md, "observePeriod")
netnsIn = mdutil.GetString(md, "netns")
netnsOut = mdutil.GetString(md, "netns.out")
dialTimeout = mdutil.GetDuration(md, "dialTimeout")
observerPeriod = mdutil.GetDuration(md, parsing.MDKeyObserverPeriod, "observePeriod")
netnsIn = mdutil.GetString(md, parsing.MDKeyNetns)
netnsOut = mdutil.GetString(md, parsing.MDKeyNetnsOut)
dialTimeout = mdutil.GetDuration(md, parsing.MDKeyDialTimeout)
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{
@@ -154,8 +209,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
listener.AutherOption(auther),
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
listener.TLSConfigOption(tlsConfig),
listener.AdmissionOption(admission.AdmissionGroup(admissions...)),
listener.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Limiter)),
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
listener.TrafficLimiterOption(
cache_limiter.NewCachedTrafficLimiter(
registry.TrafficLimiterRegistry().Get(cfg.Limiter),
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
cache_limiter.ScopeOption(limiterScope),
),
),
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
listener.ServiceOption(cfg.Name),
listener.ProxyProtocolOption(ppv),
@@ -207,6 +269,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
for _, qname := range cfg.Quotas {
ln = quota_wrapper.WrapListener(ln, strings.TrimSpace(qname))
}
handlerLogger := serviceLogger.WithFields(map[string]any{
"kind": "handler",
})
@@ -222,6 +288,8 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if tlsConfig == nil {
tlsConfig = parsing.DefaultTLSConfig().Clone()
tls_util.SetTLSOptions(tlsConfig, tlsCfg.Options)
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
}
authers = auth_parser.List(cfg.Handler.Auther, cfg.Handler.Authers...)
@@ -233,23 +301,67 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
auther = nil
if len(authers) > 0 {
auther = auth.AuthenticatorGroup(authers...)
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),
TimestampFormat: mdutil.GetString(md, parsing.MDKeyRecorderTimestampFormat),
Hexdump: mdutil.GetBool(md, parsing.MDKeyRecorderHexdump),
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),
@@ -273,11 +385,13 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
handler.RouterOption(xchain.NewRouter(routerOpts...)),
handler.AutherOption(auther),
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
handler.BypassOption(bypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
handler.TLSConfigOption(tlsConfig),
handler.RateLimiterOption(registry.RateLimiterRegistry().Get(cfg.RLimiter)),
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
handler.RecordersOption(recorders...),
handler.RewriterOption(rew),
handler.LoggerOption(handlerLogger),
handler.ServiceOption(cfg.Name),
handler.NetnsOption(netnsIn),
@@ -304,7 +418,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
s := xservice.NewService(cfg.Name, ln, h,
xservice.AdmissionOption(admission.AdmissionGroup(admissions...)),
xservice.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
xservice.PreUpOption(preUp),
xservice.PreDownOption(preDown),
xservice.PostUpOption(postUp),
@@ -312,8 +426,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
xservice.RecordersOption(recorders...),
xservice.StatsOption(pStats),
xservice.ObserverOption(registry.ObserverRegistry().Get(cfg.Observer)),
xservice.ObservePeriodOption(observePeriod),
xservice.ObserverPeriodOption(observerPeriod),
xservice.LoggerOption(serviceLogger),
xservice.LabelsOption(labels),
)
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
@@ -369,6 +484,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
Bypass: node.Bypass,
Bypasses: node.Bypasses,
Filter: filter,
Matcher: node.Matcher,
HTTP: httpCfg,
TLS: node.TLS,
Metadata: node.Metadata,
+118 -25
View File
@@ -12,6 +12,9 @@ import (
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/go-gost/core/logger"
@@ -20,47 +23,136 @@ import (
)
var (
defaultTLSConfig *tls.Config
defaultTLSConfig atomic.Value
)
func DefaultTLSConfig() *tls.Config {
return defaultTLSConfig
// 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")
}
func BuildDefaultTLSConfig(cfg *config.TLSConfig) {
// 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
// should clone it first.
func DefaultTLSConfig() *tls.Config {
v, _ := defaultTLSConfig.Load().(*tls.Config)
return v
}
// SetDefaultTLSConfig replaces the global default TLS configuration. It is safe
// to call from multiple goroutines.
func SetDefaultTLSConfig(cfg *tls.Config) {
defaultTLSConfig.Store(cfg)
}
// BuildDefaultTLSConfig loads or generates the default TLS certificate and key
// 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 {
log.Fatal(err)
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
}
defaultTLSConfig = tlsConfig
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) {
@@ -107,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
+275
View File
@@ -0,0 +1,275 @@
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()
}
func TestSetDefaultTLSConfig(t *testing.T) {
// Save and restore original
orig := DefaultTLSConfig()
defer SetDefaultTLSConfig(orig)
cfg := &tls.Config{ServerName: "test.local"}
SetDefaultTLSConfig(cfg)
got := DefaultTLSConfig()
if got == nil {
t.Fatal("expected non-nil default TLS config")
}
if got.ServerName != "test.local" {
t.Fatalf("ServerName = %q, want %q", got.ServerName, "test.local")
}
}
func TestDefaultTLSConfig_InitiallyNil(t *testing.T) {
orig := DefaultTLSConfig()
defer SetDefaultTLSConfig(orig)
// Store nil to reset
SetDefaultTLSConfig(nil)
got := DefaultTLSConfig()
if got != nil {
t.Fatal("expected nil after storing nil")
}
}
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 {
t.Fatalf("unexpected error: %v", err)
}
if tlsCfg == nil {
t.Fatal("expected non-nil config")
}
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",
CommonName: "test.example.com",
}
tlsCfg, err := BuildDefaultTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tlsCfg == nil {
t.Fatal("expected non-nil config")
}
if len(tlsCfg.Certificates) == 0 {
t.Fatal("expected at least one certificate")
}
}
func TestBuildDefaultTLSConfig_TwoCallsSameCerts(t *testing.T) {
testDefaultCertDir = t.TempDir()
t.Cleanup(func() { testDefaultCertDir = "" })
cfg1, err := BuildDefaultTLSConfig(nil)
if err != nil {
t.Fatalf("unexpected error on first call: %v", err)
}
cfg2, err := BuildDefaultTLSConfig(nil)
if err != nil {
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")
}
// 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 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")
}
}
+41
View File
@@ -0,0 +1,41 @@
package direct
import (
"io"
"net"
"time"
)
type conn struct{}
func (c *conn) Close() error {
return nil
}
func (c *conn) Read(b []byte) (n int, err error) {
return 0, io.EOF
}
func (c *conn) Write(b []byte) (n int, err error) {
return 0, io.ErrClosedPipe
}
func (c *conn) LocalAddr() net.Addr {
return &net.TCPAddr{}
}
func (c *conn) RemoteAddr() net.Addr {
return &net.TCPAddr{}
}
func (c *conn) SetDeadline(t time.Time) error {
return nil
}
func (c *conn) SetReadDeadline(t time.Time) error {
return nil
}
func (c *conn) SetWriteDeadline(t time.Time) error {
return nil
}
+35 -16
View File
@@ -1,11 +1,17 @@
package forward
// Package direct implements a direct (transparent) connector that establishes
// connections to destination addresses using the dialer provided via connect
// options. It also supports a "reject" action that returns a dead connection
// (reads return io.EOF, writes return io.ErrClosedPipe) without dialing.
package direct
import (
"context"
"errors"
"net"
"github.com/go-gost/core/connector"
md "github.com/go-gost/core/metadata"
ctxvalue "github.com/go-gost/x/ctx"
"github.com/go-gost/x/registry"
)
@@ -15,9 +21,11 @@ func init() {
}
type directConnector struct {
md metadata
options connector.Options
}
// NewConnector creates a direct connector with the given options.
func NewConnector(opts ...connector.Option) connector.Connector {
options := connector.Options{}
for _, opt := range opts {
@@ -30,7 +38,7 @@ func NewConnector(opts ...connector.Option) connector.Connector {
}
func (c *directConnector) Init(md md.Metadata) (err error) {
return nil
return c.parseMetadata(md)
}
func (c *directConnector) Connect(ctx context.Context, _ net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) {
@@ -39,26 +47,37 @@ func (c *directConnector) Connect(ctx context.Context, _ net.Conn, network, addr
opt(&cOpts)
}
if c.md.action == "reject" {
return &conn{}, nil
}
if cOpts.Dialer == nil {
return nil, errors.New("direct: missing dialer in connect options")
}
conn, err := cOpts.Dialer.Dial(ctx, network, address)
if err != nil {
return nil, err
}
var localAddr, remoteAddr string
if addr := conn.LocalAddr(); addr != nil {
localAddr = addr.String()
}
if addr := conn.RemoteAddr(); addr != nil {
remoteAddr = addr.String()
}
if c.options.Logger != nil {
var localAddr, remoteAddr string
if addr := conn.LocalAddr(); addr != nil {
localAddr = addr.String()
}
if addr := conn.RemoteAddr(); addr != nil {
remoteAddr = addr.String()
}
log := c.options.Logger.WithFields(map[string]any{
"remote": remoteAddr,
"local": localAddr,
"network": network,
"address": address,
})
log.Debugf("connect %s/%s", address, network)
log := c.options.Logger.WithFields(map[string]any{
"remote": remoteAddr,
"local": localAddr,
"network": network,
"address": address,
"sid": string(ctxvalue.SidFromContext(ctx)),
})
log.Debugf("connect %s/%s", address, network)
}
return conn, nil
}

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