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
This commit is contained in:
ginuerzh
2026-06-20 15:43:25 +08:00
parent 66f502b153
commit 9fcd7d6890
6 changed files with 263 additions and 1 deletions
+106
View File
@@ -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
}
+33
View File
@@ -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
}
+37
View File
@@ -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
}
+75
View File
@@ -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/`.
+6 -1
View File
@@ -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
+6
View File
@@ -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=