Compare commits

...
20 Commits
Author SHA1 Message Date
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
52 changed files with 1026 additions and 70 deletions
+11
View File
@@ -466,6 +466,17 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
}
}
// stdio listener uses url.Host as the forwarding target.
if listener == "stdio" && len(nodes) == 0 && url.Host != "" {
nodes = append(nodes, &config.ForwardNodeConfig{
Name: fmt.Sprintf("%starget-0", namePrefix),
Addr: url.Host,
})
if handler == "auto" {
handler = "tcp"
}
}
if len(nodes) > 0 {
if len(services) == 1 {
services[0].Forwarder = &config.ForwarderConfig{
+30 -1
View File
@@ -34,11 +34,27 @@ import (
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xstats "github.com/go-gost/x/observer/stats"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
xservice "github.com/go-gost/x/service"
"github.com/vishvananda/netns"
)
// plaintextListeners are listener types that never terminate TLS on their
// accepted connections, so any certFile/keyFile/caFile configured for them is
// silently ignored. This catches the common footgun where a user writes e.g.
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
// new plaintext listeners are added.
var plaintextListeners = map[string]bool{
"tcp": true, "udp": true,
"ws": true, "mws": true,
"redirect": true, "tproxy": true,
"rtcp": true, "rudp": true,
"unix": true, "runix": true, "serial": true, "stdio": true,
}
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
// It defaults the listener to "tcp" and the handler to "auto", resolves named
// components from the registry (authers, admissions, bypasses, resolvers,
@@ -88,6 +104,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
}
if (tlsCfg.CertFile != "" || tlsCfg.KeyFile != "" || tlsCfg.CAFile != "") && plaintextListeners[cfg.Listener.Type] {
serviceLogger.Warnf("TLS certificate options are configured but the %q listener does not use TLS and will ignore them; the connection will be plaintext. Use a TLS-capable listener (e.g. mwss, wss, tls, quic, grpc, http2, http3) to enable TLS.",
cfg.Listener.Type)
}
authers := auth_parser.List(cfg.Listener.Auther, cfg.Listener.Authers...)
if len(authers) == 0 {
if auther := auth_parser.ParseAutherFromAuth(cfg.Listener.Auth); auther != nil {
@@ -285,8 +306,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
var recorders []recorder.RecorderObject
for _, r := range cfg.Recorders {
md := metadata.NewMetadata(r.Metadata)
rec := registry.RecorderRegistry().Get(r.Name)
if r.Metadata != nil {
rec = &xrecorder.MetadataRecorder{
Recorder: rec,
Metadata: r.Metadata,
}
}
recorders = append(recorders, recorder.RecorderObject{
Recorder: registry.RecorderRegistry().Get(r.Name),
Recorder: rec,
Record: r.Record,
Options: &recorder.Options{
Direction: mdutil.GetBool(md, parsing.MDKeyRecorderDirection),
@@ -295,6 +323,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
HTTPBody: mdutil.GetBool(md, parsing.MDKeyRecorderHTTPBody),
MaxBodySize: mdutil.GetInt(md, parsing.MDKeyRecorderHTTPMaxBodySize),
},
Metadata: r.Metadata,
})
}
+4 -1
View File
@@ -3,6 +3,7 @@ package quic
import (
"context"
"net"
"time"
"github.com/quic-go/quic-go"
)
@@ -12,7 +13,9 @@ type quicSession struct {
}
func (session *quicSession) GetConn() (*quicConn, error) {
stream, err := session.session.OpenStreamSync(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
return nil, err
}
+8 -2
View File
@@ -75,7 +75,6 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Lock()
defer d.sessionMutex.Unlock()
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
@@ -95,6 +94,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
pc, err = icmp_pkg.ListenPacket("ip4:icmp", "")
}
if err != nil {
d.sessionMutex.Unlock()
return
}
@@ -109,16 +109,22 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
if err != nil {
d.logger.Error(err)
pc.Close()
d.sessionMutex.Unlock()
return nil, err
}
d.sessions[addr] = session
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
if err != nil {
d.sessionMutex.Lock()
if d.sessions[addr] == session {
delete(d.sessions, addr)
}
session.Close()
delete(d.sessions, addr)
d.sessionMutex.Unlock()
return nil, err
}
+11 -4
View File
@@ -14,14 +14,21 @@ type metadata struct {
}
func (d *icmpDialer) parseMetadata(md mdata.Metadata) (err error) {
if mdutil.GetBool(md, "keepalive") {
d.md.keepAlivePeriod = mdutil.GetDuration(md, "ttl")
const (
keepAlive = "keepAlive"
keepAlivePeriod = "ttl"
handshakeTimeout = "handshakeTimeout"
maxIdleTimeout = "maxIdleTimeout"
)
if md == nil || !md.IsExists(keepAlive) || mdutil.GetBool(md, keepAlive) {
d.md.keepAlivePeriod = mdutil.GetDuration(md, keepAlivePeriod)
if d.md.keepAlivePeriod <= 0 {
d.md.keepAlivePeriod = 10 * time.Second
}
}
d.md.handshakeTimeout = mdutil.GetDuration(md, "handshakeTimeout")
d.md.maxIdleTimeout = mdutil.GetDuration(md, "maxIdleTimeout")
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
return
}
+4 -1
View File
@@ -3,6 +3,7 @@ package quic
import (
"context"
"net"
"time"
"github.com/quic-go/quic-go"
)
@@ -12,7 +13,9 @@ type quicSession struct {
}
func (session *quicSession) GetConn() (*quicConn, error) {
stream, err := session.session.OpenStreamSync(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
return nil, err
}
+9 -2
View File
@@ -58,7 +58,6 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Lock()
defer d.sessionMutex.Unlock()
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
@@ -73,11 +72,13 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
c, err := options.Dialer.Dial(ctx, "udp", "")
if err != nil {
d.sessionMutex.Unlock()
return nil, err
}
pc, ok := c.(net.PacketConn)
if !ok {
c.Close()
d.sessionMutex.Unlock()
return nil, errors.New("quic: wrong connection type")
}
@@ -89,16 +90,22 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
if err != nil {
d.logger.Error(err)
pc.Close()
d.sessionMutex.Unlock()
return nil, err
}
d.sessions[addr] = session
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
if err != nil {
d.sessionMutex.Lock()
if d.sessions[addr] == session {
delete(d.sessions, addr)
}
session.Close()
delete(d.sessions, addr)
d.sessionMutex.Unlock()
return nil, err
}
+12
View File
@@ -63,6 +63,18 @@ func (d *sshdDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
delete(d.sessions, addr) // session is dead
ok = false
}
if ok {
// Health check: verify the cached session is still alive before
// returning it. A dead-but-not-yet-detected session (e.g. idle
// connection silently dropped by NAT) would cause "ssh: unexpected
// packet in response to channel open" later in the connector.
if err := session.Ping(d.md.keepaliveTimeout); err != nil {
d.options.Logger.Debugf("sshd session ping failed: %v, recreating", err)
session.Close()
delete(d.sessions, addr)
ok = false
}
}
if !ok {
var options dialer.DialOptions
for _, opt := range opts {
+3 -2
View File
@@ -7,12 +7,12 @@ require (
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.1
github.com/go-gost/core v0.4.1
github.com/go-gost/core v0.4.2
github.com/go-gost/go-shadowsocks2 v0.1.3
github.com/go-gost/gosocks4 v0.1.0
github.com/go-gost/gosocks5 v0.5.0
github.com/go-gost/plugin v0.3.0
github.com/go-gost/relay v0.6.0
github.com/go-gost/relay v0.6.1
github.com/go-gost/tls-dissector v0.2.0
github.com/go-redis/redis/v8 v8.11.5
github.com/gobwas/glob v0.2.3
@@ -60,6 +60,7 @@ require (
require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
)
+6 -4
View File
@@ -51,8 +51,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-gost/core v0.4.1 h1:/MZYv0tSkw9yoK2nl0GLl3GjT1/3qKl5u3E/DADyeZE=
github.com/go-gost/core v0.4.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
github.com/go-gost/core v0.4.2 h1:NBCkdWItIiwt15cA2mkRza/u6ukxQVSW74bc33o70zE=
github.com/go-gost/core v0.4.2/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
github.com/go-gost/go-shadowsocks2 v0.1.3 h1:6CUZLp+mTWXnKP2aK8/Z9ZP+ERMX9gSbywmPu4kGX/A=
github.com/go-gost/go-shadowsocks2 v0.1.3/go.mod h1:866zFNNI3He6Wef1M/IvAjTal74WhcfKfBgRpTlkKys=
github.com/go-gost/gosocks4 v0.1.0 h1:eAzev6qw4fzkFQKC9uCHLVNnnPdHyqCggbnfNN80Pmk=
@@ -61,8 +61,8 @@ github.com/go-gost/gosocks5 v0.5.0 h1:YE37l1MJwde8diIQdynStqogMotG5enoTdborhA5yi
github.com/go-gost/gosocks5 v0.5.0/go.mod h1:1G6I7HP7VFVxveGkoK8mnprnJqSqJjdcASKsdUn4Pp4=
github.com/go-gost/plugin v0.3.0 h1:pmll8nNd9PX92BWMB5+b2y2SEkBAWJLxD2ANfX+WHuw=
github.com/go-gost/plugin v0.3.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
github.com/go-gost/relay v0.6.0 h1:o8r0B3eTw8Rwwent+5x5Cw1g4AyNUl4hNqiyDJXkFvA=
github.com/go-gost/relay v0.6.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
github.com/go-gost/relay v0.6.1 h1:7SqnHFbY8x/DzvjpK03a5zcVH9+TbJAcnW/RT6s1ecc=
github.com/go-gost/relay v0.6.1/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
github.com/go-gost/tls-dissector v0.2.0 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc1SicVg=
github.com/go-gost/tls-dissector v0.2.0/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -206,6 +206,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28=
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+45 -1
View File
@@ -2,6 +2,7 @@ package auto
import (
"bufio"
"bytes"
"context"
"net"
"time"
@@ -12,7 +13,10 @@ import (
"github.com/go-gost/gosocks4"
"github.com/go-gost/gosocks5"
ctxvalue "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/util/sniffing"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
)
@@ -94,6 +98,16 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
}
br := bufio.NewReader(conn)
proto, _ := sniffing.Sniff(ctx, br)
conn = xnet.NewReadWriteConn(br, conn, conn)
if proto == sniffing.ProtoTLS {
return h.handleTLS(ctx, conn, log)
}
// Fall back to 1-byte peek for SOCKS4 (0x04) vs SOCKS5 (0x05) vs HTTP.
// Sniff() already read 5 bytes, but the buffered reader exposes them.
b, err := br.Peek(1)
if err != nil {
log.Error(err)
@@ -101,7 +115,6 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
return err
}
conn = xnet.NewReadWriteConn(br, conn, conn)
switch b[0] {
case gosocks4.Ver4: // socks4
if h.socks4Handler != nil {
@@ -118,3 +131,34 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
}
return nil
}
// handleTLS forwards a TLS connection that arrived at the auto handler.
// It parses the ClientHello for SNI, dials upstream through the router,
// forwards the ClientHello, records ServerHello metadata, and pipes data
// bidirectionally.
func (h *autoHandler) handleTLS(ctx context.Context, conn net.Conn, log logger.Logger) error {
ro := &xrecorder.HandlerRecorderObject{
Network: "tcp",
Service: h.options.Service,
RemoteAddr: conn.RemoteAddr().String(),
LocalAddr: conn.LocalAddr().String(),
SID: ctxvalue.SidFromContext(ctx).String(),
Time: time.Now(),
}
sniffer := &sniffing.Sniffer{}
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
if err != nil {
return nil, err
}
ro.Route = buf.String()
return cc, nil
}
return sniffer.HandleTLS(ctx, "tcp", conn,
sniffing.WithDial(dial),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (h *metricsHandler) Init(md md.Metadata) (err error) {
xmetrics.Enable(true)
h.handler = promhttp.Handler()
h.handler = promhttp.HandlerFor(xmetrics.Registry(), promhttp.HandlerOpts{})
mux := http.NewServeMux()
mux.Handle(h.md.path, http.HandlerFunc(h.handleFunc))
+23 -3
View File
@@ -100,6 +100,26 @@ func (h *socks5Handler) Init(md md.Metadata) (err error) {
return
}
// networkAddr derives the network string from the SOCKS5 address type.
// For IPv4 addresses it returns "tcp4"/"udp4" to prevent dual-stack
// (IPv4-mapped-IPv6) socket creation on Linux, which would cause the
// listener to bind on [::]:port instead of 0.0.0.0:port.
// For IPv6 addresses it returns "tcp6"/"udp6".
// For domain addresses it returns the unmodified network.
func networkAddr(network string, addr *gosocks5.Addr) string {
if addr == nil {
return network
}
switch addr.Type {
case gosocks5.AddrIPv4:
return network + "4"
case gosocks5.AddrIPv6:
return network + "6"
default:
return network
}
}
func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
defer conn.Close()
@@ -179,15 +199,15 @@ func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handl
case gosocks5.CmdConnect:
return h.handleConnect(ctx, conn, "tcp", address, ro, log)
case gosocks5.CmdBind:
return h.handleBind(ctx, conn, "tcp", address, ro, log)
return h.handleBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
case socks.CmdMuxBind:
return h.handleMuxBind(ctx, conn, "tcp", address, ro, log)
return h.handleMuxBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
case gosocks5.CmdUdp:
ro.Network = "udp"
return h.handleUDP(ctx, conn, "udp", ro, log)
case socks.CmdUDPTun:
ro.Network = "udp"
return h.handleUDPTun(ctx, conn, "udp", address, ro, log)
return h.handleUDPTun(ctx, conn, networkAddr("udp", req.Addr), address, ro, log)
default:
err = ErrUnknownCmd
log.Error(err)
+2
View File
@@ -26,6 +26,7 @@ type metadata struct {
enableBind bool
enableUDP bool
udpBufferSize int
udpResolveDomain bool
compatibilityMode bool
hash string
muxCfg *mux.Config
@@ -58,6 +59,7 @@ func (h *socks5Handler) parseMetadata(md mdata.Metadata) (err error) {
h.md.enableBind = mdutil.GetBool(md, "bind")
h.md.enableUDP = mdutil.GetBool(md, "udp")
h.md.udpBufferSize = mdutil.GetInt(md, "udp.bufferSize", "udpBufferSize")
h.md.udpResolveDomain = mdutil.GetBool(md, "udp.resolveDomain", "udpResolveDomain")
h.md.compatibilityMode = mdutil.GetBool(md, "comp")
h.md.hash = mdutil.GetString(md, "hash")
+68
View File
@@ -0,0 +1,68 @@
package v5
import (
"testing"
"github.com/go-gost/gosocks5"
)
func TestNetworkAddr(t *testing.T) {
tests := []struct {
name string
network string
addr *gosocks5.Addr
want string
}{
{
name: "nil addr returns unmodified",
network: "tcp",
addr: nil,
want: "tcp",
},
{
name: "IPv4 tcp returns tcp4",
network: "tcp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
want: "tcp4",
},
{
name: "IPv6 tcp returns tcp6",
network: "tcp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
want: "tcp6",
},
{
name: "IPv4 udp returns udp4",
network: "udp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
want: "udp4",
},
{
name: "IPv6 udp returns udp6",
network: "udp",
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
want: "udp6",
},
{
name: "domain addr returns unmodified",
network: "tcp",
addr: &gosocks5.Addr{Type: gosocks5.AddrDomain},
want: "tcp",
},
{
name: "unknown addr type returns unmodified",
network: "udp",
addr: &gosocks5.Addr{Type: 99},
want: "udp",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := networkAddr(tt.network, tt.addr)
if got != tt.want {
t.Errorf("networkAddr(%q, %v) = %q, want %q", tt.network, tt.addr, got, tt.want)
}
})
}
}
+75 -2
View File
@@ -6,11 +6,14 @@ import (
"errors"
"io"
"net"
"strconv"
"time"
"github.com/go-gost/core/hosts"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/resolver"
"github.com/go-gost/gosocks5"
ctxvalue "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
@@ -129,12 +132,23 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
}
}
r := udp.NewRelay(socks.UDPConn(
pc1 := socks.UDPConn(
&filteredPacketConn{
PacketConn: cc,
filterIP: conn.RemoteAddr().(*net.TCPAddr).IP,
},
h.md.udpBufferSize), pc).
h.md.udpBufferSize)
if h.md.udpResolveDomain {
pc1 = &domainResolvePacketConn{
PacketConn: pc1,
resolver: h.options.Router.Options().Resolver,
hostMapper: h.options.Router.Options().HostMapper,
log: log,
}
}
r := udp.NewRelay(pc1, pc).
WithService(h.options.Service).
WithBypass(h.options.Bypass).
WithBufferSize(h.md.udpBufferSize).
@@ -170,3 +184,62 @@ func (f *filteredPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error
}
}
}
// domainResolvePacketConn wraps a net.PacketConn and resolves domain
// addresses to IPs in WriteTo calls. This guarantees that SOCKS5 UDP
// response datagrams never carry ATYP=Domain (0x03), which some clients
// (e.g., tun2proxy, Surge) cannot parse.
type domainResolvePacketConn struct {
net.PacketConn
resolver resolver.Resolver
hostMapper hosts.HostMapper
log logger.Logger
}
func (c *domainResolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
host, portStr, err := net.SplitHostPort(addr.String())
if err != nil {
return c.PacketConn.WriteTo(b, addr)
}
if net.ParseIP(host) != nil {
// Already an IP address — pass through unchanged.
return c.PacketConn.WriteTo(b, addr)
}
// Resolve the domain using the router's infrastructure
// (host mapper → resolver → system fallback) so the SOCKS5 encoder
// will use AddrIPv4 or AddrIPv6 instead of AddrDomain.
var ips []net.IP
if c.hostMapper != nil {
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
}
if len(ips) == 0 && c.resolver != nil {
ips, err = c.resolver.Resolve(context.Background(), "ip", host)
if err != nil && c.log != nil {
c.log.Warnf("socks5 udp: resolve %s: %v", host, err)
}
}
if len(ips) == 0 {
ips, err = net.LookupIP(host)
}
if err != nil || len(ips) == 0 {
// Cannot resolve the domain — drop the datagram rather than
// forwarding an ATYP=Domain response the client cannot parse.
if c.log != nil {
c.log.Warnf("socks5 udp: dropping datagram to %s (DNS resolution failed)", host)
}
return 0, nil
}
// Pick the first IPv4 address from the result set, falling back to
// the first entry when no IPv4 is available.
ip := ips[0]
for _, candidate := range ips {
if candidate.To4() != nil {
ip = candidate
break
}
}
port, _ := strconv.Atoi(portStr)
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
}
+21 -2
View File
@@ -13,7 +13,8 @@ import (
// Resolve resolves addr to a concrete IP address. If hosts is non-nil, it is
// consulted first. If r is non-nil and no host mapping matched, the resolver
// is used. If neither is available, addr is returned unchanged.
// is used. If neither is available, Go's native pure-Go resolver is used as a
// fallback to avoid cgo-based DNS lookups that would block OS threads.
func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hosts hosts.HostMapper, log logger.Logger) (string, error) {
if addr == "" {
return addr, nil
@@ -51,5 +52,23 @@ func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hos
}
return net.JoinHostPort(ips[0].String(), port), nil
}
return addr, nil
// A literal IP address needs no resolution.
if net.ParseIP(host) != nil {
return addr, nil
}
// Fallback to Go's native (pure-Go) resolver when no custom resolver or
// host mapper is configured. This avoids cgo-based DNS lookups in
// downstream net.Dialer.DialContext which would block OS threads and
// cause thread exhaustion under high connection concurrency.
lr := &net.Resolver{PreferGo: true}
ips, err := lr.LookupIP(ctx, "ip", host)
if err != nil {
return "", err
}
if len(ips) == 0 {
return "", fmt.Errorf("resolver: domain %s does not exist", host)
}
return net.JoinHostPort(ips[0].String(), port), nil
}
+8 -3
View File
@@ -115,13 +115,18 @@ func TestResolve_ResolverEmptyResult(t *testing.T) {
}
func TestResolve_NoResolverNoHosts(t *testing.T) {
// When no resolver and no host mapper are configured, the function now
// resolves via Go's native DNS resolver (PreferGo: true). This test
// uses an IP address which is short-circuited at the top of Resolve,
// avoiding the need for actual DNS resolution in unit tests.
// DNS fallback behavior is verified by the e2e tests.
ctx := context.Background()
addr, err := Resolve(ctx, "tcp", "example.com:8080", nil, nil, nil)
addr, err := Resolve(ctx, "tcp", "1.2.3.4:8080", nil, nil, nil)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if addr != "example.com:8080" {
t.Errorf("expected example.com:8080, got %s", addr)
if addr != "1.2.3.4:8080" {
t.Errorf("expected 1.2.3.4:8080, got %s", addr)
}
}
+38 -1
View File
@@ -81,7 +81,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
if err != nil {
return err
}
defer cc.Close()
defer func() { cc.Close() }()
upstreamHost := normalizeHost(ro.HTTP.Host, "80")
ho.log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
log = ho.log
@@ -112,6 +114,41 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
log.Trace(string(dump))
}
// When DNS override directs multiple domains to the same proxy IP,
// the browser may reuse a keep-alive connection for a different host.
// Re-dial to ensure requests reach the correct upstream.
if reqHost := normalizeHost(req.Host, "80"); reqHost != "" && reqHost != upstreamHost {
cc.Close()
newNode, res, resolveErr := resolveHTTPNode(ctx, reqHost, req, &ho)
if resolveErr != nil {
ro.HTTP.StatusCode = res.StatusCode
res.Write(conn)
return resolveErr
}
dial := ho.dial
if dial == nil {
dial = (&net.Dialer{}).DialContext
}
newCC, dialErr := dial(ctx, "tcp", newNode.Addr)
if dialErr != nil {
return dialErr
}
newCC = tlsWrapConn(newCC, newNode.Options().TLS)
upstreamHost = reqHost
node = newNode
cc = newCC
ro.Host = reqHost
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
log = log.WithFields(map[string]any{
"host": reqHost,
"node": node.Name,
"dst": node.Addr,
"src": cc.LocalAddr().String(),
})
ho.log = log
}
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, node, req, &pStats, &ho); err != nil || shouldClose {
return err
}
+1 -1
View File
@@ -54,7 +54,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, conn net.Conn, opts ...HandleOp
if ho.log != nil {
ho.log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
}
return nil
return errors.New("tls: sni is empty, closing connection")
}
ro.Host = host
+23 -1
View File
@@ -93,7 +93,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
if err != nil {
return err
}
defer cc.Close()
defer func() { cc.Close() }()
upstreamHost := host
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
@@ -122,6 +124,26 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
log.Trace(string(dump))
}
// When DNS override directs multiple domains to the same proxy IP,
// the browser may reuse a keep-alive connection for a different host.
// Re-dial to ensure requests reach the correct upstream.
if reqHost := normalizeHost(req.Host, "80"); reqHost != upstreamHost {
cc.Close()
cc, err = dial(ctx, network, reqHost)
if err != nil {
return err
}
upstreamHost = reqHost
ro.Host = reqHost
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
log = log.WithFields(map[string]any{
"host": reqHost,
"src": cc.LocalAddr().String(),
"dst": cc.RemoteAddr().String(),
})
}
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, req, readTimeout, ro, &pStats, log); err != nil || shouldClose {
return err
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"io"
"net"
"time"
@@ -52,7 +53,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, network string, conn net.Conn,
if log != nil {
log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
}
return nil
return errors.New("tls: sni is empty, closing connection")
}
ro.Host = host
+19
View File
@@ -114,6 +114,25 @@ func (s *Session) Keepalive(interval, timeout time.Duration, retries int) {
}
}
// Ping checks if the SSH session is alive by sending a keepalive request.
// It blocks until a response is received or the timeout expires.
// A nil error indicates the session is healthy.
func (s *Session) Ping(timeout time.Duration) error {
if timeout <= 0 {
timeout = defaultKeepaliveTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
select {
case err := <-s.ping():
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Session) ping() <-chan error {
ch := make(chan error, 1)
go func() {
+3
View File
@@ -59,6 +59,9 @@ func (l *mtcpListener) Init(md md.Metadata) (err error) {
lc.SetMultipathTCP(true)
l.logger.Debugf("mptcp enabled: %v", lc.MultipathTCP())
}
if l.md.reuseport {
lc.Control = l.setReusePort
}
ln, err := lc.Listen(context.Background(), network, l.options.Addr)
if err != nil {
return
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows
package mtcp
import (
"syscall"
"golang.org/x/sys/unix"
)
func (l *mtcpListener) setReusePort(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
}
})
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package mtcp
import (
"syscall"
)
func (l *mtcpListener) setReusePort(network, address string, c syscall.RawConn) error {
return nil
}
+4 -2
View File
@@ -13,8 +13,9 @@ const (
)
type metadata struct {
mptcp bool
muxCfg *mux.Config
mptcp bool
reuseport bool
muxCfg *mux.Config
backlog int
keepalive bool
keepaliveIdle time.Duration
@@ -24,6 +25,7 @@ type metadata struct {
func (l *mtcpListener) parseMetadata(md md.Metadata) (err error) {
l.md.mptcp = mdutil.GetBool(md, "mptcp")
l.md.reuseport = mdutil.GetBool(md, "reuseport")
l.md.muxCfg = &mux.Config{
Version: mdutil.GetInt(md, "mux.version"),
+1 -1
View File
@@ -53,7 +53,7 @@ func (l *redirectListener) Init(md md.Metadata) (err error) {
network = "tcp4"
}
lc := net.ListenConfig{}
if l.md.tproxy {
if l.md.tproxy || l.md.reuseport {
lc.Control = l.control
}
if l.md.mptcp {
+9
View File
@@ -2,8 +2,17 @@ package tcp
import (
"syscall"
"golang.org/x/sys/unix"
)
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
if l.md.reuseport {
return c.Control(func(fd uintptr) {
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
}
})
}
return nil
}
+11 -4
View File
@@ -8,11 +8,18 @@ import (
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
if err := unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1); err != nil {
l.logger.Errorf("SetsockoptInt(SOL_IP, IP_TRANSPARENT, 1): %v", err)
if l.md.tproxy {
if err := unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1); err != nil {
l.logger.Errorf("failed to set IP_TRANSPARENT: %v", err)
}
if err := unix.SetsockoptInt(int(fd), unix.SOL_IPV6, unix.IPV6_TRANSPARENT, 1); err != nil {
l.logger.Errorf("failed to set IPV6_TRANSPARENT: %v", err)
}
}
if err := unix.SetsockoptInt(int(fd), unix.SOL_IPV6, unix.IPV6_TRANSPARENT, 1); err != nil {
l.logger.Errorf("SetsockoptInt(SOL_IPV6, IPV6_TRANSPARENT, 1): %v", err)
if l.md.reuseport {
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
}
}
})
}
+14 -2
View File
@@ -1,12 +1,24 @@
//go:build !linux && !darwin
//go:build !linux && !darwin && !windows
package tcp
import (
"errors"
"syscall"
"golang.org/x/sys/unix"
)
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
return errors.New("TProxy is not available on non-linux platform")
if l.md.tproxy {
return errors.New("TProxy is not available on non-linux platform")
}
if l.md.reuseport {
return c.Control(func(fd uintptr) {
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
}
})
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package tcp
import (
"errors"
"syscall"
)
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
if l.md.tproxy {
return errors.New("TProxy is not available on non-linux platform")
}
// SO_REUSEADDR is the default on Windows, so reuseport is a no-op.
return nil
}
+4 -2
View File
@@ -6,13 +6,15 @@ import (
)
type metadata struct {
tproxy bool
mptcp bool
tproxy bool
mptcp bool
reuseport bool
}
func (l *redirectListener) parseMetadata(md mdata.Metadata) (err error) {
l.md.tproxy = mdutil.GetBool(md, "tproxy")
l.md.mptcp = mdutil.GetBool(md, "mptcp")
l.md.reuseport = mdutil.GetBool(md, "reuseport")
return
}
+135
View File
@@ -0,0 +1,135 @@
// Package stdio implements a listener that wraps os.Stdin/os.Stdout as a
// net.Listener. It accepts exactly one connection, making it suitable for
// 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
package stdio
import (
"net"
"os"
"sync"
"time"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/x/registry"
)
func init() {
registry.ListenerRegistry().Register("stdio", NewListener)
}
type (
stdioListener struct {
conn net.Conn
done chan struct{}
once sync.Once
accepted bool
mu sync.Mutex
laddr net.Addr
logger logger.Logger
md metadata
options listener.Options
}
)
// NewListener creates a stdio listener. The returned listener wraps
// os.Stdin and os.Stdout as a single-use net.Conn.
func NewListener(opts ...listener.Option) listener.Listener {
options := listener.Options{}
for _, opt := range opts {
opt(&options)
}
return &stdioListener{
logger: options.Logger,
options: options,
}
}
func (l *stdioListener) Init(md md.Metadata) (err error) {
if err = l.parseMetadata(md); err != nil {
return
}
l.laddr = &stdioAddr{addr: "stdio"}
l.done = make(chan struct{})
l.conn = &stdioConn{l: l}
return
}
// Accept returns the stdio connection on the first call.
// Subsequent calls block until the listener is closed.
func (l *stdioListener) Accept() (net.Conn, error) {
l.mu.Lock()
if !l.accepted {
l.accepted = true
l.mu.Unlock()
return l.conn, nil
}
l.mu.Unlock()
<-l.done
return nil, net.ErrClosed
}
func (l *stdioListener) Addr() net.Addr {
return l.laddr
}
func (l *stdioListener) Close() error {
l.close()
return nil
}
// close signals listener completion. Idempotent and safe for concurrent use.
func (l *stdioListener) close() {
l.once.Do(func() { close(l.done) })
}
// stdioConn implements net.Conn over os.Stdin and os.Stdout.
type stdioConn struct {
l *stdioListener
}
func (c *stdioConn) Read(b []byte) (int, error) { return os.Stdin.Read(b) }
func (c *stdioConn) Write(b []byte) (int, error) { return os.Stdout.Write(b) }
// Close terminates the process.
//
// A stdio listener serves exactly one connection: the process's own
// stdin/stdout, piped from a parent such as SSH's ProxyCommand. When that
// connection ends the parent has closed the pipe and there is nothing left
// to serve, so the whole process must exit.
//
// Closing the listener alone is not enough: the service's accept loop runs
// in a fire-and-forget goroutine (see x/service.Serve) and the process
// blocks on the go-svc signal wait (see gost/cmd/gost/main.go), so it would
// linger forever after the parent quits. os.Exit is the only reliable way
// to terminate here. The handler's deferred stats/recording/logging already
// ran before this Close (LIFO order), so nothing is lost.
func (c *stdioConn) Close() error {
c.l.close()
os.Exit(0)
return nil
}
func (c *stdioConn) LocalAddr() net.Addr { return &stdioAddr{addr: "stdio"} }
func (c *stdioConn) RemoteAddr() net.Addr { return &stdioAddr{addr: "pipe"} }
// Deadline methods are no-ops: os.Stdin/os.Stdout do not support
// deadline-based I/O.
func (c *stdioConn) SetDeadline(t time.Time) error { return nil }
func (c *stdioConn) SetReadDeadline(t time.Time) error { return nil }
func (c *stdioConn) SetWriteDeadline(t time.Time) error { return nil }
// stdioAddr is a trivial net.Addr for stdio connections.
type stdioAddr struct {
addr string
}
func (a *stdioAddr) Network() string { return "tcp" }
func (a *stdioAddr) String() string { return a.addr }
+13
View File
@@ -0,0 +1,13 @@
package stdio
import (
md "github.com/go-gost/core/metadata"
)
type metadata struct {
// reserved for future options (e.g., timeouts)
}
func (l *stdioListener) parseMetadata(md md.Metadata) (err error) {
return
}
+8 -1
View File
@@ -2,6 +2,7 @@ package tap
import (
"context"
"fmt"
"net"
"time"
@@ -68,7 +69,13 @@ func (l *tapListener) Init(md mdata.Metadata) (err error) {
func (l *tapListener) listenLoop() {
for {
ctx, cancel := context.WithCancel(context.Background())
err := func() error {
err := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic in createTap: %v", r)
}
}()
ifce, name, ip, err := l.createTap()
if err != nil {
if ifce != nil {
+3
View File
@@ -56,6 +56,9 @@ func (l *tcpListener) Init(md md.Metadata) (err error) {
lc.SetMultipathTCP(true)
l.logger.Debugf("mptcp enabled: %v", lc.MultipathTCP())
}
if l.md.reuseport {
lc.Control = l.setReusePort
}
ln, err := lc.Listen(context.Background(), network, l.options.Addr)
if err != nil {
return
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows
package tcp
import (
"syscall"
"golang.org/x/sys/unix"
)
func (l *tcpListener) setReusePort(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
}
})
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package tcp
import (
"syscall"
)
func (l *tcpListener) setReusePort(network, address string, c syscall.RawConn) error {
return nil
}
+3 -1
View File
@@ -8,7 +8,8 @@ import (
)
type metadata struct {
mptcp bool
mptcp bool
reuseport bool
keepalive bool
keepaliveIdle time.Duration
@@ -18,6 +19,7 @@ type metadata struct {
func (l *tcpListener) parseMetadata(md md.Metadata) (err error) {
l.md.mptcp = mdutil.GetBool(md, "mptcp")
l.md.reuseport = mdutil.GetBool(md, "reuseport")
l.md.keepalive = mdutil.GetBool(md, "keepalive")
l.md.keepaliveIdle = mdutil.GetDuration(md, "keepalive.idle")
+8 -1
View File
@@ -2,6 +2,7 @@ package tun
import (
"context"
"fmt"
"net"
"time"
@@ -75,7 +76,13 @@ func (l *tunListener) Init(md mdata.Metadata) (err error) {
func (l *tunListener) listenLoop(ready context.CancelCauseFunc) {
for {
ctx, cancel := context.WithCancel(context.Background())
err := func() error {
err := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic in createTun: %v", r)
}
}()
ifce, name, ip, err := l.createTun()
if err != nil {
if ifce != nil {
+23 -5
View File
@@ -52,8 +52,17 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
}
}
if len(l.md.config.Net) > 0 {
ipNet := l.md.config.Net[0]
// Loop over every configured address so dual-stack (IPv4+IPv6) setups
// install both addresses, selecting the netsh context by family: "ip"
// for IPv4, "ipv6" for IPv6. A failing address is logged and skipped so
// one bad entry doesn't prevent the rest from being applied; the first
// successfully installed address is reported as the interface IP.
//
// Caveat: unlike Linux's netlink.AddrAdd, "netsh interface ip set
// address" replaces rather than adds, so only a single IPv4 address is
// supported (one IPv4 + one IPv6 works; multiple IPv4 addresses
// silently overwrite).
for _, ipNet := range l.md.config.Net {
cmd := fmt.Sprintf("netsh interface ip set address name=%s "+
"source=static addr=%s mask=%s gateway=none",
name, ipNet.IP.String(), ipMask(ipNet.Mask))
@@ -65,10 +74,19 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
args := strings.Split(cmd, " ")
if er := exec.Command(args[0], args[1:]...).Run(); er != nil {
err = fmt.Errorf("%s: %v", cmd, er)
return
l.log.Errorf("%s: %v", cmd, er)
continue
}
ip = ipNet.IP
if ip == nil {
ip = ipNet.IP
}
}
// If not a single address could be configured, surface an error rather
// than returning a nil IP (and thus a nil peer address) to the caller.
if ip == nil && len(l.md.config.Net) > 0 {
err = fmt.Errorf("failed to configure any address on interface %s", name)
return
}
if err = l.addRoutes(name, l.md.config.Gateway); err != nil {
+8 -1
View File
@@ -2,6 +2,7 @@ package tungo
import (
"context"
"fmt"
"net"
"time"
@@ -68,7 +69,13 @@ func (l *tunListener) Init(md mdata.Metadata) (err error) {
func (l *tunListener) listenLoop(ready context.CancelCauseFunc) {
for {
ctx, cancel := context.WithCancel(context.Background())
err := func() error {
err := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic in createTun: %v", r)
}
}()
ifce, name, ip, err := l.createTun()
if err != nil {
if ifce != nil {
+23 -5
View File
@@ -56,8 +56,17 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
}
}
if len(l.md.config.Net) > 0 {
ipNet := l.md.config.Net[0]
// Loop over every configured address so dual-stack (IPv4+IPv6) setups
// install both addresses, selecting the netsh context by family: "ip"
// for IPv4, "ipv6" for IPv6. A failing address is logged and skipped so
// one bad entry doesn't prevent the rest from being applied; the first
// successfully installed address is reported as the interface IP.
//
// Caveat: unlike Linux's netlink.AddrAdd, "netsh interface ip set
// address" replaces rather than adds, so only a single IPv4 address is
// supported (one IPv4 + one IPv6 works; multiple IPv4 addresses
// silently overwrite).
for _, ipNet := range l.md.config.Net {
cmd := fmt.Sprintf("netsh interface ip set address name=%s "+
"source=static addr=%s mask=%s",
name, ipNet.IP.String(), ipMask(ipNet.Mask))
@@ -73,10 +82,19 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
l.log.Debugf("%s: %s", cmd, windows.ByteSliceToString(output))
}
if er != nil {
err = fmt.Errorf("%s: %v", cmd, er)
return
l.log.Errorf("%s: %v", cmd, er)
continue
}
ip = ipNet.IP
if ip == nil {
ip = ipNet.IP
}
}
// If not a single address could be configured, surface an error rather
// than returning a nil IP (and thus a nil peer address) to the caller.
if ip == nil && len(l.md.config.Net) > 0 {
err = fmt.Errorf("failed to configure any address on interface %s", name)
return
}
if err = l.addRoutes(name, l.md.config.Gateway); err != nil {
+25 -1
View File
@@ -2,9 +2,11 @@ package ws
import (
"context"
stdlog "log"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/go-gost/core/limiter"
@@ -85,6 +87,12 @@ func (l *wsListener) Init(md md.Metadata) (err error) {
Addr: l.options.Addr,
Handler: mux,
ReadHeaderTimeout: l.md.readHeaderTimeout,
// Route HTTP server errors (TLS handshake failures, malformed
// requests) to the GOST logger instead of the default stderr
// output via log.Printf. Without this, network scanners and
// protocol filters probing the WSS port produce uncontrolled
// noise: "http: TLS handshake error from ...".
ErrorLog: stdlog.New(&logWriter{log: l.log}, "", 0),
}
l.cqueue = make(chan net.Conn, l.md.backlog)
@@ -186,7 +194,9 @@ func (l *wsListener) upgrade(w http.ResponseWriter, r *http.Request) {
conn, err := l.upgrader.Upgrade(w, r, l.md.header)
if err != nil {
log.Error(err)
// Upgrade failures (plain HTTP requests, websocket
// handshake errors) are operational noise — log at Warn.
log.Warn(err)
return
}
@@ -208,3 +218,17 @@ func (l *wsListener) upgrade(w http.ResponseWriter, r *http.Request) {
log.Warnf("connection queue is full, client %s discarded", conn.RemoteAddr())
}
}
// logWriter adapts a GOST logger to the io.Writer interface required by
// http.Server.ErrorLog. It writes each log entry as a Debug-level message
// so that HTTP server errors (TLS handshake failures, malformed requests)
// are captured in GOST's logging pipeline instead of escaping to stderr
// via log.Printf.
type logWriter struct {
log logger.Logger
}
func (w *logWriter) Write(p []byte) (n int, err error) {
w.log.Debug(strings.TrimSpace(string(p)))
return len(p), nil
}
+25 -2
View File
@@ -4,6 +4,8 @@ import (
"sync/atomic"
"github.com/go-gost/core/metrics"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
const (
@@ -30,10 +32,31 @@ const (
)
var (
defaultMetrics metrics.Metrics = NewMetrics()
enabled atomic.Bool
defaultMetrics metrics.Metrics
defaultRegistry *prometheus.Registry
enabled atomic.Bool
)
func init() {
defaultRegistry = prometheus.NewRegistry()
// Register Go and process collectors on our custom registry so the metrics
// endpoint is self-contained. The process collector is platform-specific:
// Linux/Windows use the standard procfs/Win32-based collector; all other
// platforms (FreeBSD, Darwin, etc.) use a gopsutil-based collector.
defaultRegistry.MustRegister(collectors.NewGoCollector())
registerProcessCollector(defaultRegistry)
defaultMetrics = NewMetrics(defaultRegistry)
}
// Registry returns the custom Prometheus registry that holds all GOST metrics
// plus the Go and process collectors. The metrics HTTP endpoint should serve
// from this registry rather than prometheus.DefaultGatherer.
func Registry() *prometheus.Registry {
return defaultRegistry
}
// Enable enables or disables metrics collection globally. When disabled, all
// GetCounter, GetGauge, and GetObserver calls return noop implementations.
func Enable(b bool) {
+154
View File
@@ -0,0 +1,154 @@
//go:build !linux && !windows
package metrics
import (
"os"
"time"
"github.com/prometheus/client_golang/prometheus"
psutil "github.com/shirou/gopsutil/v3/process"
)
// gopsutilProcessCollector implements prometheus.Collector using gopsutil/v3
// for cross-platform process metrics on non-Linux, non-Windows systems
// (FreeBSD, Darwin, etc.) where procfs is unavailable.
type gopsutilProcessCollector struct {
pidFn func() (int, error)
cpuTotal *prometheus.Desc
openFDs *prometheus.Desc
maxFDs *prometheus.Desc
vsize *prometheus.Desc
maxVsize *prometheus.Desc
rss *prometheus.Desc
startTime *prometheus.Desc
reportErrors bool
}
func getPID() (int, error) { return os.Getpid(), nil }
func newGopsutilProcessCollector() prometheus.Collector {
return &gopsutilProcessCollector{
pidFn: getPID,
cpuTotal: prometheus.NewDesc(
"process_cpu_seconds_total",
"Total user and system CPU time spent in seconds.",
nil, nil,
),
openFDs: prometheus.NewDesc(
"process_open_fds",
"Number of open file descriptors.",
nil, nil,
),
maxFDs: prometheus.NewDesc(
"process_max_fds",
"Maximum number of open file descriptors.",
nil, nil,
),
vsize: prometheus.NewDesc(
"process_virtual_memory_bytes",
"Virtual memory size in bytes.",
nil, nil,
),
maxVsize: prometheus.NewDesc(
"process_virtual_memory_max_bytes",
"Maximum amount of virtual memory available in bytes.",
nil, nil,
),
rss: prometheus.NewDesc(
"process_resident_memory_bytes",
"Resident memory size in bytes.",
nil, nil,
),
startTime: prometheus.NewDesc(
"process_start_time_seconds",
"Start time of the process since unix epoch in seconds.",
nil, nil,
),
reportErrors: false,
}
}
func (c *gopsutilProcessCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.cpuTotal
ch <- c.openFDs
ch <- c.maxFDs
ch <- c.vsize
ch <- c.maxVsize
ch <- c.rss
ch <- c.startTime
}
func (c *gopsutilProcessCollector) Collect(ch chan<- prometheus.Metric) {
pid, err := c.pidFn()
if err != nil {
c.reportError(ch, nil, err)
return
}
p, err := psutil.NewProcess(int32(pid))
if err != nil {
c.reportError(ch, nil, err)
return
}
// Memory metrics: RSS (resident) and VMS (virtual).
if mem, err := p.MemoryInfo(); err == nil {
ch <- prometheus.MustNewConstMetric(c.rss, prometheus.GaugeValue, float64(mem.RSS))
ch <- prometheus.MustNewConstMetric(c.vsize, prometheus.GaugeValue, float64(mem.VMS))
} else {
c.reportError(ch, c.rss, err)
}
// CPU time.
if times, err := p.Times(); err == nil {
cpuSeconds := times.User + times.System
ch <- prometheus.MustNewConstMetric(c.cpuTotal, prometheus.CounterValue, cpuSeconds)
} else {
c.reportError(ch, c.cpuTotal, err)
}
// Open file descriptors.
if fds, err := p.NumFDs(); err == nil {
ch <- prometheus.MustNewConstMetric(c.openFDs, prometheus.GaugeValue, float64(fds))
} else {
c.reportError(ch, c.openFDs, err)
}
// Max file descriptors — not reliably available via gopsutil on all platforms.
// Try RLIMIT_NOFILE via gopsutil's RlimitUsage.
if rlimits, err := p.RlimitUsage(false); err == nil && len(rlimits) > 0 {
for _, rl := range rlimits {
switch rl.Resource {
case psutil.RLIMIT_NOFILE:
ch <- prometheus.MustNewConstMetric(c.maxFDs, prometheus.GaugeValue, float64(rl.Hard))
case psutil.RLIMIT_AS:
ch <- prometheus.MustNewConstMetric(c.maxVsize, prometheus.GaugeValue, float64(rl.Hard))
}
}
}
// Process start time.
if ct, err := p.CreateTime(); err == nil {
createTime := time.Unix(ct/1000, (ct%1000)*1e6)
ch <- prometheus.MustNewConstMetric(c.startTime, prometheus.GaugeValue, float64(createTime.Unix()))
} else {
c.reportError(ch, c.startTime, err)
}
}
func (c *gopsutilProcessCollector) reportError(ch chan<- prometheus.Metric, desc *prometheus.Desc, err error) {
if !c.reportErrors {
return
}
if desc == nil {
desc = prometheus.NewInvalidDesc(err)
}
ch <- prometheus.NewInvalidMetric(desc, err)
}
// registerProcessCollector registers a gopsutil-based process metrics collector
// for platforms where procfs is unavailable.
func registerProcessCollector(reg *prometheus.Registry) {
reg.MustRegister(newGopsutilProcessCollector())
}
+14
View File
@@ -0,0 +1,14 @@
//go:build linux || windows
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
// registerProcessCollector registers the standard prometheus ProcessCollector
// which uses procfs on Linux and Win32 syscalls on Windows.
func registerProcessCollector(reg *prometheus.Registry) {
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
}
+10 -5
View File
@@ -16,8 +16,13 @@ type promMetrics struct {
}
// NewMetrics returns a Prometheus-based Metrics implementation. All metrics are
// automatically registered with the default Prometheus registry.
func NewMetrics() metrics.Metrics {
// registered with the given registry. Use nil to register with the default
// Prometheus registry.
func NewMetrics(reg *prometheus.Registry) metrics.Metrics {
if reg == nil {
reg = prometheus.DefaultRegisterer.(*prometheus.Registry)
}
host, _ := os.Hostname()
m := &promMetrics{
host: host,
@@ -95,13 +100,13 @@ func NewMetrics() metrics.Metrics {
},
}
for k := range m.gauges {
prometheus.MustRegister(m.gauges[k])
reg.MustRegister(m.gauges[k])
}
for k := range m.counters {
prometheus.MustRegister(m.counters[k])
reg.MustRegister(m.counters[k])
}
for k := range m.histograms {
prometheus.MustRegister(m.histograms[k])
reg.MustRegister(m.histograms[k])
}
return m
+11 -1
View File
@@ -7,7 +7,10 @@ import (
"github.com/go-gost/core/auth"
"github.com/go-gost/core/service"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
xmetrics "github.com/go-gost/x/metrics"
)
const (
@@ -46,6 +49,8 @@ type metricService struct {
}
// NewService creates a metrics Service that exposes Prometheus metrics over HTTP.
// It serves from the GOST custom registry rather than the default one so that
// process metrics are available on all platforms.
func NewService(network, addr string, opts ...Option) (service.Service, error) {
if network == "" {
network = "tcp"
@@ -82,7 +87,12 @@ func NewService(network, addr string, opts ...Option) (service.Service, error) {
return
}
}
promhttp.Handler().ServeHTTP(w, r)
reg := xmetrics.Registry()
if reg == nil {
reg = prometheus.DefaultRegisterer.(*prometheus.Registry)
}
promhttp.HandlerFor(reg, promhttp.HandlerOpts{}).ServeHTTP(w, r)
}))
return &metricService{
s: &http.Server{
+6 -3
View File
@@ -2,6 +2,8 @@ package wrapper
import (
"net"
xmetrics "github.com/go-gost/x/metrics"
)
type listener struct {
@@ -10,10 +12,11 @@ type listener struct {
}
// WrapListener wraps a net.Listener so that all accepted connections are
// automatically wrapped with WrapConn for metrics tracking. If ln is nil,
// nil is returned.
// automatically wrapped with WrapConn for metrics tracking. If metrics are
// not enabled, ln is returned unchanged to allow splice(2) optimization
// on bare *net.TCPConn. If ln is nil, nil is returned.
func WrapListener(service string, ln net.Listener) net.Listener {
if ln == nil {
if ln == nil || !xmetrics.IsEnabled() {
return ln
}
return &listener{
+17
View File
@@ -10,6 +10,23 @@ import (
xctx "github.com/go-gost/x/ctx"
)
// MetadataRecorder wraps a Recorder and automatically appends metadata
// to every Record call.
type MetadataRecorder struct {
recorder.Recorder
Metadata any
}
func (r *MetadataRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
if r.Recorder == nil {
return nil
}
if r.Metadata == nil {
return r.Recorder.Record(ctx, b, opts...)
}
return r.Recorder.Record(ctx, b, append(opts, recorder.MetadataRecordOption(r.Metadata))...)
}
const (
RecorderServiceHandler = "recorder.service.handler"
RecorderServiceHandlerSerial = "recorder.service.handler.serial"