diff --git a/dialer/utls/dialer.go b/dialer/utls/dialer.go new file mode 100644 index 00000000..dc5bc7bb --- /dev/null +++ b/dialer/utls/dialer.go @@ -0,0 +1,106 @@ +package utls + +import ( + "context" + "crypto/tls" + "net" + "time" + "unsafe" + + "github.com/go-gost/core/dialer" + "github.com/go-gost/core/logger" + md "github.com/go-gost/core/metadata" + xctx "github.com/go-gost/x/ctx" + xnet "github.com/go-gost/x/internal/net" + "github.com/go-gost/x/internal/net/proxyproto" + "github.com/go-gost/x/registry" + utls "github.com/refraction-networking/utls" +) + +func init() { + registry.DialerRegistry().Register("utls", NewDialer) +} + +type utlsDialer struct { + md metadata + log logger.Logger + options dialer.Options +} + +func NewDialer(opts ...dialer.Option) dialer.Dialer { + options := dialer.Options{} + for _, opt := range opts { + opt(&options) + } + + return &utlsDialer{ + log: options.Logger, + options: options, + } +} + +func (d *utlsDialer) Init(md md.Metadata) (err error) { + return d.parseMetadata(md) +} + +func (d *utlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + var options dialer.DialOptions + for _, opt := range opts { + opt(&options) + } + + conn, err := options.Dialer.Dial(ctx, "tcp", addr) + if err != nil { + d.log.Error(err) + } + + if d.md.keepalive { + xnet.ApplyKeepalive(conn, net.KeepAliveConfig{ + Enable: true, + Idle: d.md.keepaliveIdle, + Interval: d.md.keepaliveInterval, + Count: d.md.keepaliveCount, + }) + } + + conn = proxyproto.WrapClientConn( + d.options.ProxyProtocol, + xctx.SrcAddrFromContext(ctx), + xctx.DstAddrFromContext(ctx), + conn) + + return conn, err +} + +// Handshake implements dialer.Handshaker. +func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...dialer.HandshakeOption) (net.Conn, error) { + if d.md.handshakeTimeout > 0 { + conn.SetDeadline(time.Now().Add(d.md.handshakeTimeout)) + defer conn.SetDeadline(time.Time{}) + } + + clientHelloID, ok := GetClientHelloID(d.md.fingerprint) + if ok { + d.log.Debugf("utls handshake with fingerprint: %s", d.md.fingerprint) + utlsCfg := (*utls.Config)(unsafe.Pointer(d.options.TLSConfig)) + uconn := utls.UClient(conn, utlsCfg, clientHelloID) + if err := uconn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + return uconn, nil + } + + // fingerprint not set, empty, "golang", or unknown: fall through to + // standard crypto/tls. + if d.md.fingerprint != "" { + d.log.Warnf("unknown utls fingerprint: %s, falling back to standard TLS", d.md.fingerprint) + } + tlsConn := tls.Client(conn, d.options.TLSConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + + return tlsConn, nil +} diff --git a/dialer/utls/fingerprint.go b/dialer/utls/fingerprint.go new file mode 100644 index 00000000..c4352106 --- /dev/null +++ b/dialer/utls/fingerprint.go @@ -0,0 +1,33 @@ +package utls + +import ( + utls "github.com/refraction-networking/utls" +) + +// fingerprintMap maps user-facing string names to uTLS ClientHelloID presets. +// Each _Auto alias automatically tracks the latest known fingerprint for that +// browser in the uTLS library. +var fingerprintMap = map[string]utls.ClientHelloID{ + "chrome": utls.HelloChrome_Auto, + "firefox": utls.HelloFirefox_Auto, + "ios": utls.HelloIOS_Auto, + "safari": utls.HelloSafari_Auto, + "edge": utls.HelloEdge_Auto, + "randomized": utls.HelloRandomized, + "randomized-alpn": utls.HelloRandomizedALPN, + "randomized-noalpn": utls.HelloRandomizedNoALPN, + "golang": utls.HelloGolang, + "custom": utls.HelloCustom, +} + +// GetClientHelloID returns the utls.ClientHelloID for the given fingerprint +// name. The empty string and "golang" both return ok=false, signalling the +// caller to fall through to standard crypto/tls. Unknown names also return +// ok=false. +func GetClientHelloID(name string) (utls.ClientHelloID, bool) { + if name == "" || name == "golang" { + return utls.ClientHelloID{}, false + } + id, ok := fingerprintMap[name] + return id, ok +} diff --git a/dialer/utls/metadata.go b/dialer/utls/metadata.go new file mode 100644 index 00000000..2a6bf8ad --- /dev/null +++ b/dialer/utls/metadata.go @@ -0,0 +1,37 @@ +package utls + +import ( + "time" + + mdata "github.com/go-gost/core/metadata" + mdutil "github.com/go-gost/x/metadata/util" +) + +type metadata struct { + handshakeTimeout time.Duration + + keepalive bool + keepaliveIdle time.Duration + keepaliveInterval time.Duration + keepaliveCount int + + fingerprint string +} + +func (d *utlsDialer) parseMetadata(md mdata.Metadata) (err error) { + const ( + handshakeTimeout = "handshakeTimeout" + fingerprint = "fingerprint" + ) + + d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout) + + d.md.keepalive = mdutil.GetBool(md, "keepalive") + d.md.keepaliveIdle = mdutil.GetDuration(md, "keepalive.idle") + d.md.keepaliveInterval = mdutil.GetDuration(md, "keepalive.interval") + d.md.keepaliveCount = mdutil.GetInt(md, "keepalive.count") + + d.md.fingerprint = mdutil.GetString(md, fingerprint) + + return +} diff --git a/docs/utls-fingerprint-plan.md b/docs/utls-fingerprint-plan.md new file mode 100644 index 00000000..08de2167 --- /dev/null +++ b/docs/utls-fingerprint-plan.md @@ -0,0 +1,75 @@ +# Plan: Add uTLS Fingerprint Dialer (`utls`) + +## Context + +**Issue**: [go-gost/gost#31](https://github.com/go-gost/gost/issues/31) — Feature request to add TLS ClientHello fingerprint simulation using the [uTLS](https://github.com/refraction-networking/utls) library. GOST currently uses Go's `crypto/tls` which produces a distinctive Go TLS fingerprint that's easily blocked. Users want to mimic Chrome/Firefox/Safari/etc. fingerprints. + +**Approach**: New `"utls"` dialer type (standalone, not modifying the existing `"tls"` dialer). The fingerprint is dialer-only metadata — not on the shared `TLSConfig` struct. This keeps the standard TLS dialer untouched and avoids pulling in the uTLS dependency for all users. + +## Files to Create + +### 1. `x/dialer/utls/fingerprint.go` — String→ClientHelloID map + +Maps user-facing fingerprint names to `utls.ClientHelloID` presets. Supported names: `chrome`, `firefox`, `ios`, `safari`, `edge`, `randomized`, `randomized-alpn`, `randomized-noalpn`, `golang`, `custom`. Empty string and `"golang"` both mean "fall through to standard crypto/tls" (return `ok=false`). Unknown names get a warning log and also fall through. + +### 2. `x/dialer/utls/metadata.go` — Metadata parsing + +Mirrors `dialer/tls/metadata.go` exactly, adding a `fingerprint string` field. Parsed keys: +- `handshakeTimeout` (duration) +- `keepalive`, `keepalive.idle`, `keepalive.interval`, `keepalive.count` (TCP keepalive) +- `fingerprint` (string) — **new** + +### 3. `x/dialer/utls/dialer.go` — Core dialer + +Mirrors `dialer/tls/dialer.go`. Registered as `"utls"`. The `Dial()` method is identical to the TLS dialer. The `Handshake()` method diverges: looks up the fingerprint; if `ok`, uses `utls.UClient(conn, tlsConfig, clientHelloID)`; otherwise falls through to `crypto/tls.Client()`. + +## Files to Modify + +### 4. `x/go.mod` — Add uTLS dependency + +Add `github.com/refraction-networking/utls v1.8.2` to the require block, then run `go mod tidy` from `x/`. + +### 5. `gost/cmd/gost/register.go` — Blank import + +Add `_ "github.com/go-gost/x/dialer/utls"` in the "Register dialers" section, alphabetically between the `unix` and `ws` imports. + +## No Changes Needed + +- **Config parsing** — `node/parse.go` already passes `dialCfg.Metadata` to `d.Init()`; no changes required to any parsing code. +- **`config/config.go`** — `DialerConfig.Metadata` already supports arbitrary keys; `fingerprint` flows through naturally. +- **Metadata key constants** — No new constant in `config/parsing/parse.go`; local `const` in `parseMetadata()` follows existing dialer convention. + +## Usage Example + +```yaml +services: + - name: service-0 + addr: :8080 + handler: + type: tcp + listener: + type: tcp + forwarder: + nodes: + - name: target-0 + addr: example.com:443 + dialer: + type: utls + metadata: + fingerprint: chrome +``` + +## Verification + +```bash +# 1. Tidy dependencies +cd x && go mod tidy + +# 2. Build + vet the x module +cd x && go build ./... && go vet ./... + +# 3. Build + vet the gost binary (verifies blank import) +cd gost && go build ./cmd/gost/... && go vet ./... +``` + +No unit tests needed — the existing codebase has no tests in `x/dialer/`. diff --git a/go.mod b/go.mod index 33481eb2..bc3d4ab2 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/prometheus/client_golang v1.19.1 github.com/quic-go/quic-go v0.59.1 github.com/quic-go/webtransport-go v0.10.0 + github.com/refraction-networking/utls v1.8.2 github.com/rs/xid v1.3.0 github.com/shadowsocks/go-shadowsocks2 v0.1.5 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 @@ -56,7 +57,11 @@ require ( gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 ) -require github.com/sirupsen/logrus v1.9.3 // indirect +require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect +) require ( github.com/alessio/shellescape v1.4.1 // indirect diff --git a/go.sum b/go.sum index b554e071..769663b6 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAu github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -123,6 +125,8 @@ github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9q github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -188,6 +192,8 @@ github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBi github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI= github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=