Compare commits

...

10 Commits

Author SHA1 Message Date
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
32 changed files with 1250 additions and 82 deletions
+95
View File
@@ -4,11 +4,13 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"net"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/go-gost/core/auth" "github.com/go-gost/core/auth"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/loader" "github.com/go-gost/x/internal/loader"
xlogger "github.com/go-gost/x/logger" xlogger "github.com/go-gost/x/logger"
) )
@@ -1003,3 +1005,96 @@ type staticAuther struct {
func (s *staticAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) { func (s *staticAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
return s.id, s.ok return s.id, s.ok
} }
// --- whitelist tests ---
func TestWhitelistedAuthenticator_IPMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || !ok {
t.Fatalf("expected (\"\", true) for matched IP, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_IPNoMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for non-matched IP, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_CIDRMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.1.2.3"), Port: 9999})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || !ok {
t.Fatalf("expected (\"\", true) for matched CIDR, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_CIDRNoMatch(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for non-matched CIDR, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_EmptyPatterns(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for empty patterns, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_NoSrcAddr(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
id, ok := w.Authenticate(context.Background(), "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) with no src addr in context, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_NilAuther(t *testing.T) {
// When IP matches, the whitelist returns OK regardless of nil auther.
w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || !ok {
t.Fatalf("expected (\"\", true) for matched whitelist even with nil auther, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_NilAutherNoMatch(t *testing.T) {
// When IP does not match and auther is nil, it falls through to ("", false).
w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "" || ok {
t.Fatalf("expected (\"\", false) for nil auther with non-matching IP, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_UnderlyingDenies(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: false}, []string{"10.0.0.0/8"})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || ok {
t.Fatalf("expected (\"test\", false) when underlying auther denies, got (%q, %v)", id, ok)
}
}
func TestWhitelistedAuthenticator_InvalidPatterns(t *testing.T) {
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"not-an-ip", "also-not-cidr/", ""})
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
id, ok := w.Authenticate(ctx, "user", "pass")
if id != "test" || !ok {
t.Fatalf("expected (\"test\", true) for all-invalid patterns, got (%q, %v)", id, ok)
}
}
+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
}
+20 -2
View File
@@ -316,6 +316,11 @@ type RecorderObject struct {
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"` 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 { type LimiterConfig struct {
Name string `json:"name"` Name string `json:"name"`
Limits []string `yaml:",omitempty" json:"limits,omitempty"` Limits []string `yaml:",omitempty" json:"limits,omitempty"`
@@ -397,6 +402,8 @@ type HTTPBodyRewriteConfig struct {
Type string Type string
Match string Match string
Replacement string Replacement string
// name of the rewriter plugin (via registry)
Rewriter string
} }
type NodeFilterConfig struct { type NodeFilterConfig struct {
@@ -405,6 +412,15 @@ type NodeFilterConfig struct {
Path string `yaml:",omitempty" json:"path,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 { type NodeMatcherConfig struct {
Rule string `yaml:",omitempty" json:"rule,omitempty"` Rule string `yaml:",omitempty" json:"rule,omitempty"`
Priority int `yaml:",omitempty" json:"priority,omitempty"` Priority int `yaml:",omitempty" json:"priority,omitempty"`
@@ -478,8 +494,9 @@ type ServiceConfig struct {
Logger string `yaml:",omitempty" json:"logger,omitempty"` Logger string `yaml:",omitempty" json:"logger,omitempty"`
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"` Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
Observer string `yaml:",omitempty" json:"observer,omitempty"` Observer string `yaml:",omitempty" json:"observer,omitempty"`
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"` Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"` Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"` Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"` Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"` Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
@@ -628,6 +645,7 @@ type Config struct {
Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"` Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"`
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"` SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"` Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"` Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"` Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"` CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
+12
View File
@@ -22,6 +22,7 @@ import (
reg "github.com/go-gost/core/registry" reg "github.com/go-gost/core/registry"
"github.com/go-gost/core/resolver" "github.com/go-gost/core/resolver"
"github.com/go-gost/core/router" "github.com/go-gost/core/router"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/sd" "github.com/go-gost/core/sd"
"github.com/go-gost/x/config" "github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing" "github.com/go-gost/x/config/parsing"
@@ -38,6 +39,7 @@ import (
quota_parser "github.com/go-gost/x/config/parsing/quota" quota_parser "github.com/go-gost/x/config/parsing/quota"
recorder_parser "github.com/go-gost/x/config/parsing/recorder" recorder_parser "github.com/go-gost/x/config/parsing/recorder"
resolver_parser "github.com/go-gost/x/config/parsing/resolver" 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" router_parser "github.com/go-gost/x/config/parsing/router"
sd_parser "github.com/go-gost/x/config/parsing/sd" sd_parser "github.com/go-gost/x/config/parsing/sd"
service_parser "github.com/go-gost/x/config/parsing/service" service_parser "github.com/go-gost/x/config/parsing/service"
@@ -244,6 +246,16 @@ func register(cfg *config.Config) error {
} }
} }
{
var entries []named[rewriter.Rewriter]
for _, c := range cfg.Rewriters {
entries = append(entries, named[rewriter.Rewriter]{c.Name, rewriter_parser.ParseRewriter(c)})
}
if err := registerGroup(entries, registry.RewriterRegistry()); err != nil {
return err
}
}
{ {
var entries []named[traffic.TrafficLimiter] var entries []named[traffic.TrafficLimiter]
for _, c := range cfg.Limiters { for _, c := range cfg.Limiters {
+30 -27
View File
@@ -29,6 +29,29 @@ import (
// metadata-driven options (so_mark, interface, netns, proxy protocol), sets up // metadata-driven options (so_mark, interface, netns, proxy protocol), sets up
// bypass rules, node filters, HTTP settings, and TLS node settings. The hop // bypass rules, node filters, HTTP settings, and TLS node settings. The hop
// parameter is used only for logging context. // 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) { func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) {
if cfg == nil { if cfg == nil {
return nil, nil return nil, nil
@@ -176,6 +199,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" { if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
if matcher, err := routing.NewMatcher(rule); err == nil { if matcher, err := routing.NewMatcher(rule); err == nil {
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule) 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 { if priority == 0 {
priority = len(cfg.Matcher.Rule) priority = len(cfg.Matcher.Rule)
} }
@@ -222,33 +249,9 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
}) })
} }
} }
for _, v := range cfg.HTTP.RewriteBody { settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
if pattern, _ := regexp.Compile(v.Match); pattern != nil { settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{ settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
Type: v.Type,
Pattern: pattern,
Replacement: []byte(v.Replacement),
})
}
}
for _, v := range cfg.HTTP.RewriteResponseBody {
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
Type: v.Type,
Pattern: pattern,
Replacement: []byte(v.Replacement),
})
}
}
for _, v := range cfg.HTTP.RewriteRequestBody {
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
settings.RewriteRequestBody = append(settings.RewriteRequestBody, chain.HTTPBodyRewriteSettings{
Type: v.Type,
Pattern: pattern,
Replacement: []byte(v.Replacement),
})
}
}
opts = append(opts, chain.HTTPNodeOption(settings)) opts = append(opts, chain.HTTPNodeOption(settings))
} }
+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
}
+36
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-gost/core/handler" "github.com/go-gost/core/handler"
"github.com/go-gost/core/hop" "github.com/go-gost/core/hop"
"github.com/go-gost/core/listener" "github.com/go-gost/core/listener"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/logger" "github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats" "github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder" "github.com/go-gost/core/recorder"
@@ -303,6 +304,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
auther = xauth.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 var recorders []recorder.RecorderObject
for _, r := range cfg.Recorders { for _, r := range cfg.Recorders {
md := metadata.NewMetadata(r.Metadata) md := metadata.NewMetadata(r.Metadata)
@@ -327,6 +354,14 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}) })
} }
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{ routerOpts = []chain.RouterOption{
chain.RetriesRouterOption(cfg.Handler.Retries), chain.RetriesRouterOption(cfg.Handler.Retries),
chain.TimeoutRouterOption(dialTimeout), chain.TimeoutRouterOption(dialTimeout),
@@ -356,6 +391,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)), handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)), handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
handler.RecordersOption(recorders...), handler.RecordersOption(recorders...),
handler.RewriterOption(rew),
handler.LoggerOption(handlerLogger), handler.LoggerOption(handlerLogger),
handler.ServiceOption(cfg.Name), handler.ServiceOption(cfg.Name),
handler.NetnsOption(netnsIn), handler.NetnsOption(netnsIn),
+24 -2
View File
@@ -2,9 +2,12 @@ package http
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/binary"
"fmt" "fmt"
"hash/crc32"
"net" "net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
@@ -53,10 +56,16 @@ func (c *httpConnector) Connect(ctx context.Context, conn net.Conn, network, add
}) })
log.Debugf("connect %s/%s", address, network) log.Debugf("connect %s/%s", address, network)
connectHost := address
if c.md.host != "" {
// 伪装的地址
connectHost = c.md.host
}
req := &http.Request{ req := &http.Request{
Method: http.MethodConnect, Method: http.MethodConnect,
URL: &url.URL{Host: address}, URL: &url.URL{Host: connectHost},
Host: address, Host: connectHost,
ProtoMajor: 1, ProtoMajor: 1,
ProtoMinor: 1, ProtoMinor: 1,
Header: c.md.header, Header: c.md.header,
@@ -67,6 +76,12 @@ func (c *httpConnector) Connect(ctx context.Context, conn net.Conn, network, add
} }
req.Header.Set("Proxy-Connection", "keep-alive") req.Header.Set("Proxy-Connection", "keep-alive")
// 真实连接的地址
if c.md.host != "" {
targetAddr := encodeServerName(address)
req.Header.Set("Gost-Target", targetAddr)
}
if user := c.options.Auth; user != nil { if user := c.options.Auth; user != nil {
u := user.Username() u := user.Username()
p, _ := user.Password() p, _ := user.Password()
@@ -129,3 +144,10 @@ func (c *httpConnector) Connect(ctx context.Context, conn net.Conn, network, add
return conn, nil return conn, nil
} }
func encodeServerName(name string) string {
buf := &bytes.Buffer{}
binary.Write(buf, binary.BigEndian, crc32.ChecksumIEEE([]byte(name)))
buf.WriteString(base64.RawURLEncoding.EncodeToString([]byte(name)))
return base64.RawURLEncoding.EncodeToString(buf.Bytes())
}
+4
View File
@@ -11,15 +11,19 @@ import (
type metadata struct { type metadata struct {
connectTimeout time.Duration connectTimeout time.Duration
header http.Header header http.Header
//伪装混淆用host
host string
} }
func (c *httpConnector) parseMetadata(md mdata.Metadata) (err error) { func (c *httpConnector) parseMetadata(md mdata.Metadata) (err error) {
const ( const (
connectTimeout = "timeout" connectTimeout = "timeout"
header = "header" header = "header"
host = "host"
) )
c.md.connectTimeout = mdutil.GetDuration(md, connectTimeout) c.md.connectTimeout = mdutil.GetDuration(md, connectTimeout)
c.md.host = mdutil.GetString(md, host)
if mm := mdutil.GetStringMapString(md, header); len(mm) > 0 { if mm := mdutil.GetStringMapString(md, header); len(mm) > 0 {
hd := http.Header{} hd := http.Header{}
+2 -2
View File
@@ -2,7 +2,6 @@ package mtls
import ( import (
"context" "context"
"crypto/tls"
"errors" "errors"
"net" "net"
"sync" "sync"
@@ -16,6 +15,7 @@ import (
"github.com/go-gost/x/internal/net/proxyproto" "github.com/go-gost/x/internal/net/proxyproto"
"github.com/go-gost/x/internal/util/mux" "github.com/go-gost/x/internal/util/mux"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
"github.com/go-gost/x/util"
) )
func init() { func init() {
@@ -141,7 +141,7 @@ func (d *mtlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
} }
func (d *mtlsDialer) initSession(ctx context.Context, conn net.Conn) (*muxSession, error) { func (d *mtlsDialer) initSession(ctx context.Context, conn net.Conn) (*muxSession, error) {
tlsConn := tls.Client(conn, d.options.TLSConfig) tlsConn := util.NewUTLSChromeClient(conn, d.options.TLSConfig)
if err := tlsConn.HandshakeContext(ctx); err != nil { if err := tlsConn.HandshakeContext(ctx); err != nil {
return nil, err return nil, err
} }
+18 -4
View File
@@ -17,6 +17,7 @@ import (
"github.com/go-gost/x/internal/util/mux" "github.com/go-gost/x/internal/util/mux"
ws_util "github.com/go-gost/x/internal/util/ws" ws_util "github.com/go-gost/x/internal/util/ws"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
"github.com/go-gost/x/util"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -172,17 +173,30 @@ func (d *mwsDialer) initSession(ctx context.Context, host string, conn net.Conn,
}, },
} }
url := url.URL{Scheme: "ws", Host: host, Path: d.md.path} urlObj := url.URL{Scheme: "ws", Host: host, Path: d.md.path}
if d.tlsEnabled { if d.tlsEnabled {
url.Scheme = "wss" urlObj.Scheme = "wss"
dialer.TLSClientConfig = d.options.TLSConfig dialer.TLSClientConfig = d.options.TLSConfig
dialer.NetDialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
client, err := util.NewUTLSWebSocketClient(conn, d.options.TLSConfig, d.md.useH2)
if err != nil {
return nil, err
}
if err := client.Handshake(); err != nil {
return nil, err
}
return client, nil
}
} }
if d.md.handshakeTimeout > 0 { if d.md.handshakeTimeout > 0 {
conn.SetReadDeadline(time.Now().Add(d.md.handshakeTimeout)) conn.SetReadDeadline(time.Now().Add(d.md.handshakeTimeout))
} }
urlStr, errUnescape := url.QueryUnescape(urlObj.String())
c, resp, err := dialer.DialContext(ctx, url.String(), d.md.header) if errUnescape != nil {
d.options.Logger.Debugf("[mws] URL QueryUnescape Error URL.String() -> %s", urlObj.String())
}
c, resp, err := dialer.DialContext(ctx, urlStr, d.md.header)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+5 -1
View File
@@ -5,8 +5,8 @@ import (
"time" "time"
mdata "github.com/go-gost/core/metadata" mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux" "github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
) )
const ( const (
@@ -18,6 +18,8 @@ type metadata struct {
host string host string
path string path string
useH2 bool
handshakeTimeout time.Duration handshakeTimeout time.Duration
readHeaderTimeout time.Duration readHeaderTimeout time.Duration
readBufferSize int readBufferSize int
@@ -77,5 +79,7 @@ func (d *mwsDialer) parseMetadata(md mdata.Metadata) (err error) {
d.md.tcpKeepaliveInterval = mdutil.GetDuration(md, "tcp.keepalive.interval") d.md.tcpKeepaliveInterval = mdutil.GetDuration(md, "tcp.keepalive.interval")
d.md.tcpKeepaliveCount = mdutil.GetInt(md, "tcp.keepalive.count") d.md.tcpKeepaliveCount = mdutil.GetInt(md, "tcp.keepalive.count")
d.md.useH2 = mdutil.GetBool(md, "h2")
return return
} }
+2 -2
View File
@@ -2,7 +2,6 @@ package tls
import ( import (
"context" "context"
"crypto/tls"
"net" "net"
"time" "time"
@@ -13,6 +12,7 @@ import (
xnet "github.com/go-gost/x/internal/net" xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/net/proxyproto" "github.com/go-gost/x/internal/net/proxyproto"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
"github.com/go-gost/x/util"
) )
func init() { func init() {
@@ -77,7 +77,7 @@ func (d *tlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...dia
defer conn.SetDeadline(time.Time{}) defer conn.SetDeadline(time.Time{})
} }
tlsConn := tls.Client(conn, d.options.TLSConfig) tlsConn := util.NewUTLSChromeClient(conn, d.options.TLSConfig)
if err := tlsConn.HandshakeContext(ctx); err != nil { if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close() conn.Close()
return nil, err return nil, err
+2 -2
View File
@@ -5,7 +5,6 @@ import (
"crypto/tls" "crypto/tls"
"net" "net"
"time" "time"
"unsafe"
"github.com/go-gost/core/dialer" "github.com/go-gost/core/dialer"
"github.com/go-gost/core/logger" "github.com/go-gost/core/logger"
@@ -14,6 +13,7 @@ import (
xnet "github.com/go-gost/x/internal/net" xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/net/proxyproto" "github.com/go-gost/x/internal/net/proxyproto"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
"github.com/go-gost/x/util"
utls "github.com/refraction-networking/utls" utls "github.com/refraction-networking/utls"
) )
@@ -82,7 +82,7 @@ func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
clientHelloID, ok := GetClientHelloID(d.md.fingerprint) clientHelloID, ok := GetClientHelloID(d.md.fingerprint)
if ok { if ok {
d.log.Debugf("utls handshake with fingerprint: %s", d.md.fingerprint) d.log.Debugf("utls handshake with fingerprint: %s", d.md.fingerprint)
utlsCfg := (*utls.Config)(unsafe.Pointer(d.options.TLSConfig)) utlsCfg := util.NewUTLSConfig(d.options.TLSConfig)
uconn := utls.UClient(conn, utlsCfg, clientHelloID) uconn := utls.UClient(conn, utlsCfg, clientHelloID)
if err := uconn.HandshakeContext(ctx); err != nil { if err := uconn.HandshakeContext(ctx); err != nil {
conn.Close() conn.Close()
+18 -4
View File
@@ -13,6 +13,7 @@ import (
"github.com/go-gost/x/internal/net/proxyproto" "github.com/go-gost/x/internal/net/proxyproto"
ws_util "github.com/go-gost/x/internal/util/ws" ws_util "github.com/go-gost/x/internal/util/ws"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
"github.com/go-gost/x/util"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -110,13 +111,26 @@ func (d *wsDialer) Handshake(ctx context.Context, conn net.Conn, options ...dial
}, },
} }
url := url.URL{Scheme: "ws", Host: host, Path: d.md.path} urlObj := url.URL{Scheme: "ws", Host: host, Path: d.md.path}
if d.tlsEnabled { if d.tlsEnabled {
url.Scheme = "wss" urlObj.Scheme = "wss"
dialer.TLSClientConfig = d.options.TLSConfig dialer.TLSClientConfig = d.options.TLSConfig
dialer.NetDialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
client, err := util.NewUTLSWebSocketClient(conn, d.options.TLSConfig, d.md.useH2)
if err != nil {
return nil, err
}
if err := client.Handshake(); err != nil {
return nil, err
}
return client, nil
}
} }
urlStr, errUnescape := url.QueryUnescape(urlObj.String())
c, resp, err := dialer.DialContext(ctx, url.String(), d.md.header) if errUnescape != nil {
d.options.Logger.Debugf("[ws] URL QueryUnescape Error URL.String() -> %s", urlObj.String())
}
c, resp, err := dialer.DialContext(ctx, urlStr, d.md.header)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+4 -2
View File
@@ -14,8 +14,9 @@ const (
) )
type metadata struct { type metadata struct {
host string host string
path string path string
useH2 bool
handshakeTimeout time.Duration handshakeTimeout time.Duration
readHeaderTimeout time.Duration readHeaderTimeout time.Duration
@@ -66,5 +67,6 @@ func (d *wsDialer) parseMetadata(md mdata.Metadata) (err error) {
d.md.tcpKeepaliveInterval = mdutil.GetDuration(md, "tcp.keepalive.interval") d.md.tcpKeepaliveInterval = mdutil.GetDuration(md, "tcp.keepalive.interval")
d.md.tcpKeepaliveCount = mdutil.GetInt(md, "tcp.keepalive.count") d.md.tcpKeepaliveCount = mdutil.GetInt(md, "tcp.keepalive.count")
d.md.useH2 = mdutil.GetBool(md, "h2")
return return
} }
+2 -2
View File
@@ -7,11 +7,11 @@ require (
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-gost/core v0.4.3 github.com/go-gost/core v0.5.1
github.com/go-gost/go-shadowsocks2 v0.1.3 github.com/go-gost/go-shadowsocks2 v0.1.3
github.com/go-gost/gosocks4 v0.1.0 github.com/go-gost/gosocks4 v0.1.0
github.com/go-gost/gosocks5 v0.5.0 github.com/go-gost/gosocks5 v0.5.0
github.com/go-gost/plugin v0.3.0 github.com/go-gost/plugin v0.4.0
github.com/go-gost/relay v0.6.1 github.com/go-gost/relay v0.6.1
github.com/go-gost/tls-dissector v0.2.0 github.com/go-gost/tls-dissector v0.2.0
github.com/go-redis/redis/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5
+6 -4
View File
@@ -51,16 +51,18 @@ 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-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 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-gost/core v0.4.3 h1:9VEHRzuSq/MVdDZYrp/TtVG0+0r6NVGUQDXsJozrvJs= github.com/go-gost/core v0.5.0 h1:3SkPojjqw2Av9l5OSyRVlBpRvWhaXgU5BWMXB9aR4SM=
github.com/go-gost/core v0.4.3/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A= github.com/go-gost/core v0.5.0/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
github.com/go-gost/core v0.5.1 h1:HbIn3naEOC661Z4SwzaSYUzffWSRbU0P6gkxiHmuG0I=
github.com/go-gost/core v0.5.1/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 h1:6CUZLp+mTWXnKP2aK8/Z9ZP+ERMX9gSbywmPu4kGX/A=
github.com/go-gost/go-shadowsocks2 v0.1.3/go.mod h1:866zFNNI3He6Wef1M/IvAjTal74WhcfKfBgRpTlkKys= github.com/go-gost/go-shadowsocks2 v0.1.3/go.mod h1:866zFNNI3He6Wef1M/IvAjTal74WhcfKfBgRpTlkKys=
github.com/go-gost/gosocks4 v0.1.0 h1:eAzev6qw4fzkFQKC9uCHLVNnnPdHyqCggbnfNN80Pmk= github.com/go-gost/gosocks4 v0.1.0 h1:eAzev6qw4fzkFQKC9uCHLVNnnPdHyqCggbnfNN80Pmk=
github.com/go-gost/gosocks4 v0.1.0/go.mod h1:hzVjwijJuZR1pp3GqpTj+AKcSGrx68RlWTrQMFMYBP0= github.com/go-gost/gosocks4 v0.1.0/go.mod h1:hzVjwijJuZR1pp3GqpTj+AKcSGrx68RlWTrQMFMYBP0=
github.com/go-gost/gosocks5 v0.5.0 h1:YE37l1MJwde8diIQdynStqogMotG5enoTdborhA5yic= github.com/go-gost/gosocks5 v0.5.0 h1:YE37l1MJwde8diIQdynStqogMotG5enoTdborhA5yic=
github.com/go-gost/gosocks5 v0.5.0/go.mod h1:1G6I7HP7VFVxveGkoK8mnprnJqSqJjdcASKsdUn4Pp4= 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.4.0 h1:M7MR5PL7QAFCrdWfcXVX+5iLNTVIZlhl8FfdV/K8dZY=
github.com/go-gost/plugin v0.3.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts= github.com/go-gost/plugin v0.4.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
github.com/go-gost/relay v0.6.1 h1:7SqnHFbY8x/DzvjpK03a5zcVH9+TbJAcnW/RT6s1ecc= 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/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 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc1SicVg=
+64
View File
@@ -93,6 +93,21 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
return err return err
} }
// A direct (no-chain) UDP association yields a raw *net.UDPConn, which
// cannot consume the domainAddr returned by udpConn.ReadFrom for
// ATYP=DOMAINNAME datagrams. Wrap it so domains are resolved through the
// configured resolver (hostMapper → resolver → system DNS) instead of the
// previous hardcoded net.ResolveUDPAddr in the SOCKS5 decode path.
// Chain-backed PacketConns (udpTunConn, udpRelayConn, ...) encode domains
// as ATYP=Domain themselves and are left untouched.
if _, isDirect := pc.(*net.UDPConn); isDirect {
pc = &resolvePacketConn{
PacketConn: pc,
resolver: h.options.Router.Options().Resolver,
hostMapper: h.options.Router.Options().HostMapper,
}
}
saddr := gosocks5.Addr{} saddr := gosocks5.Addr{}
saddr.ParseFrom(cc.LocalAddr().String()) saddr.ParseFrom(cc.LocalAddr().String())
@@ -203,6 +218,55 @@ func (f *filteredPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error
} }
} }
// resolvePacketConn wraps the upstream net.PacketConn for a direct (no-chain)
// UDP association and resolves domain addresses to IPs in WriteTo calls.
//
// udpConn.ReadFrom now returns a domainAddr for ATYP=DOMAINNAME datagrams so
// the domain survives to the chain; a raw *net.UDPConn cannot consume a
// domainAddr (WriteTo needs *net.UDPAddr), so direct connections are wrapped
// here. Resolution order is hostMapper → configured resolver → system DNS, so a
// configured resolver (e.g. resolver=1.1.1.1) is honored instead of leaking
// through the system resolver.
type resolvePacketConn struct {
net.PacketConn
resolver resolver.Resolver
hostMapper hosts.HostMapper
}
func (c *resolvePacketConn) 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 {
return c.PacketConn.WriteTo(b, addr)
}
var ips []net.IP
if c.hostMapper != nil {
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
}
if len(ips) == 0 && c.resolver != nil {
ips, _ = c.resolver.Resolve(context.Background(), "ip", host)
}
if len(ips) == 0 {
ips, _ = net.LookupIP(host)
}
if len(ips) == 0 {
return 0, fmt.Errorf("socks5 udp: cannot resolve %s", host)
}
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})
}
// domainResolvePacketConn wraps a net.PacketConn and resolves domain // domainResolvePacketConn wraps a net.PacketConn and resolves domain
// addresses to IPs in WriteTo calls. This guarantees that SOCKS5 UDP // addresses to IPs in WriteTo calls. This guarantees that SOCKS5 UDP
// response datagrams never carry ATYP=Domain (0x03), which some clients // response datagrams never carry ATYP=Domain (0x03), which some clients
+7 -3
View File
@@ -208,13 +208,17 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
}) })
if nodes[0].Options().Priority > 0 && if nodes[0].Options().Priority > 0 &&
!anyBackupNode(nodes) { !anyBackupNode(nodes) &&
nodes[0].Options().Priority > nodes[1].Options().Priority {
// Priority short-circuit: highest-priority non-backup node wins. // Priority short-circuit: highest-priority non-backup node wins.
// Conditions: (1) top priority > 0 means a matcher indicated routing // Conditions: (1) top priority > 0 means a matcher indicated routing
// specificity, so the top node is authoritative for this request; // specificity, so the top node is authoritative for this request;
// (2) no backup node is present, otherwise BackupFilter would be // (2) no backup node is present, otherwise BackupFilter would be
// silently bypassed. When both hold the selector (FailFilter, // silently bypassed; (3) the top priority is strictly higher than
// BackupFilter, strategy) is skipped and the best node wins directly. // the second-highest — when multiple nodes share the same matcher
// rule they have equal priority and the selector (FailFilter,
// BackupFilter, strategy) should still apply for load balancing.
// When all three hold the selector is skipped and the best node wins directly.
p.logger.Debugf("priority shortcut: node %s selected", nodes[0].Name) p.logger.Debugf("priority shortcut: node %s selected", nodes[0].Name)
return nodes[0] return nodes[0]
} }
+25
View File
@@ -522,6 +522,31 @@ func TestSelect_WithSelector(t *testing.T) {
} }
} }
func TestSelect_EqualPriorityDoesNotShortcut(t *testing.T) {
// Two nodes with identical matchers get the same default priority.
// The priority shortcut must NOT trigger when multiple nodes share
// the highest priority — the selector should still apply.
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: true}),
chain.PriorityNodeOption(50),
)
n2 := chain.NewNode("n2", "127.0.0.1:9090",
chain.MatcherNodeOption(&testMatcher{match: true}),
chain.PriorityNodeOption(50),
)
sel := &testNodeSelector{selectedIdx: 1} // picks n2
h := newTestHop(NodeOption(n1, n2), SelectorOption(sel))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (selected by selector, not shortcut), got %q", node.Name)
}
}
func TestSelect_NilNodesSkipped(t *testing.T) { func TestSelect_NilNodesSkipped(t *testing.T) {
h := newTestHop() h := newTestHop()
defer h.Close() defer h.Close()
+6 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"runtime"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -145,8 +146,12 @@ func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, i
default: default:
return nil, fmt.Errorf("dial: unsupported network %s", network) return nil, fmt.Errorf("dial: unsupported network %s", network)
} }
var resolver *net.Resolver
if runtime.GOOS != "android" {
resolver = &net.Resolver{PreferGo: true}
}
netd := net.Dialer{ netd := net.Dialer{
Resolver: &net.Resolver{PreferGo: true}, Resolver: resolver,
LocalAddr: ifAddr, LocalAddr: ifAddr,
Control: func(network, address string, c syscall.RawConn) error { Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) { return c.Control(func(fd uintptr) {
+3 -2
View File
@@ -356,6 +356,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
if re.Pattern.MatchString(req.URL.Path) { if re.Pattern.MatchString(req.URL.Path) {
if s := re.Pattern.ReplaceAllString(req.URL.Path, re.Replacement); s != "" { if s := re.Pattern.ReplaceAllString(req.URL.Path, re.Replacement); s != "" {
req.URL.Path = s req.URL.Path = s
ro.HTTP.URI = req.URL.RequestURI()
break break
} }
} }
@@ -368,7 +369,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
// Rewrite request body before wrapping for recording, // Rewrite request body before wrapping for recording,
// so the recorder sees the rewritten content. // so the recorder sees the rewritten content.
if err = rewriteReqBody(req, reqBodyRewrites...); err != nil { if err = rewriteReqBody(ctx, req, reqBodyRewrites...); err != nil {
log.Errorf("rewrite request body: %v", err) log.Errorf("rewrite request body: %v", err)
return return
} }
@@ -445,7 +446,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
resp.Header.Set("Connection", "close") resp.Header.Set("Connection", "close")
} }
if err = rewriteRespBody(resp, respBodyRewrites...); err != nil { if err = rewriteRespBody(ctx, resp, respBodyRewrites...); err != nil {
log.Errorf("rewrite body: %v", err) log.Errorf("rewrite body: %v", err)
return return
} }
+18 -4
View File
@@ -41,7 +41,7 @@ func (h *Sniffer) handleUpgradeResponse(ctx context.Context, rw, cc io.ReadWrite
return xnet.Pipe(ctx, rw, cc) return xnet.Pipe(ctx, rw, cc)
} }
func rewriteRespBody(resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error { func rewriteRespBody(ctx context.Context, resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
if resp == nil || len(rewrites) == 0 || resp.ContentLength <= 0 { if resp == nil || len(rewrites) == 0 || resp.ContentLength <= 0 {
return nil return nil
} }
@@ -65,7 +65,14 @@ func rewriteRespBody(resp *http.Response, rewrites ...chain.HTTPBodyRewriteSetti
continue continue
} }
if rewrite.Pattern != nil { if rewrite.Rewriter != nil {
if rewrite.Pattern == nil || rewrite.Pattern.Match(body) {
body, err = rewrite.Rewriter.Rewrite(ctx, body)
if err != nil {
return err
}
}
} else if rewrite.Pattern != nil {
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement) body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
} }
} }
@@ -90,7 +97,7 @@ func drainBody(b io.ReadCloser) (body []byte, err error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func rewriteReqBody(req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error { func rewriteReqBody(ctx context.Context, req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error {
if req == nil || len(rewrites) == 0 || req.Body == nil || req.ContentLength <= 0 { if req == nil || len(rewrites) == 0 || req.Body == nil || req.ContentLength <= 0 {
return nil return nil
} }
@@ -114,7 +121,14 @@ func rewriteReqBody(req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings
continue continue
} }
if rewrite.Pattern != nil { if rewrite.Rewriter != nil {
if rewrite.Pattern == nil || rewrite.Pattern.Match(body) {
body, err = rewrite.Rewriter.Rewrite(ctx, body)
if err != nil {
return err
}
}
} else if rewrite.Pattern != nil {
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement) body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
} }
} }
+218 -17
View File
@@ -17,6 +17,7 @@ import (
"github.com/go-gost/core/chain" "github.com/go-gost/core/chain"
"github.com/go-gost/core/hop" "github.com/go-gost/core/hop"
"github.com/go-gost/core/recorder" "github.com/go-gost/core/recorder"
"github.com/go-gost/core/rewriter"
xlogger "github.com/go-gost/x/logger" xlogger "github.com/go-gost/x/logger"
xrecorder "github.com/go-gost/x/recorder" xrecorder "github.com/go-gost/x/recorder"
) )
@@ -164,7 +165,7 @@ func TestRewriteReqBody(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.req != nil { if tt.req != nil {
_ = rewriteReqBody(tt.req, tt.rewrites...) _ = rewriteReqBody(context.Background(), tt.req, tt.rewrites...)
if tt.req.Body != nil { if tt.req.Body != nil {
body, _ := io.ReadAll(tt.req.Body) body, _ := io.ReadAll(tt.req.Body)
tt.req.Body.Close() tt.req.Body.Close()
@@ -178,7 +179,7 @@ func TestRewriteReqBody(t *testing.T) {
t.Errorf("ContentLength = %d, want %d", tt.req.ContentLength, tt.wantCL) t.Errorf("ContentLength = %d, want %d", tt.req.ContentLength, tt.wantCL)
} }
} else { } else {
_ = rewriteReqBody(nil) // should not panic _ = rewriteReqBody(context.Background(), nil) // should not panic
} }
}) })
} }
@@ -344,8 +345,8 @@ func TestDrainBody(t *testing.T) {
func TestRewriteRespBody(t *testing.T) { func TestRewriteRespBody(t *testing.T) {
t.Run("nil response", func(t *testing.T) { t.Run("nil response", func(t *testing.T) {
if err := rewriteRespBody(nil); err != nil { if err := rewriteRespBody(context.Background(), nil); err != nil {
t.Errorf("rewriteRespBody(nil) = %v, want nil", err) t.Errorf("rewriteRespBody(context.Background(), nil) = %v, want nil", err)
} }
}) })
@@ -354,7 +355,7 @@ func TestRewriteRespBody(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
_ = rewriteRespBody(resp) _ = rewriteRespBody(context.Background(), resp)
// Body unchanged // Body unchanged
got, _ := io.ReadAll(resp.Body) got, _ := io.ReadAll(resp.Body)
if string(got) != "original" { if string(got) != "original" {
@@ -367,7 +368,7 @@ func TestRewriteRespBody(t *testing.T) {
ContentLength: 0, ContentLength: 0,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{ _ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("."), Pattern: regexp.MustCompile("."),
Type: "*", Type: "*",
Replacement: []byte("replaced"), Replacement: []byte("replaced"),
@@ -385,7 +386,7 @@ func TestRewriteRespBody(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{ _ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*", Type: "*",
Replacement: []byte("replaced"), Replacement: []byte("replaced"),
}) })
@@ -413,7 +414,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced")) _ = rewriteRespBody(context.Background(), resp, makeRewrite("text/html", "replaced"))
got, _ := io.ReadAll(resp.Body) got, _ := io.ReadAll(resp.Body)
if string(got) != "replaced" { if string(got) != "replaced" {
t.Errorf("body = %q, want %q", string(got), "replaced") t.Errorf("body = %q, want %q", string(got), "replaced")
@@ -426,7 +427,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")), Body: io.NopCloser(strings.NewReader("hello")),
} }
_ = rewriteRespBody(resp, makeRewrite("*", "world")) _ = rewriteRespBody(context.Background(), resp, makeRewrite("*", "world"))
got, _ := io.ReadAll(resp.Body) got, _ := io.ReadAll(resp.Body)
if string(got) != "world" { if string(got) != "world" {
t.Errorf("body = %q, want %q", string(got), "world") t.Errorf("body = %q, want %q", string(got), "world")
@@ -439,7 +440,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced")) _ = rewriteRespBody(context.Background(), resp, makeRewrite("text/html", "replaced"))
got, _ := io.ReadAll(resp.Body) got, _ := io.ReadAll(resp.Body)
if string(got) != "original" { if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original") t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
@@ -454,7 +455,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
} }
// Empty type defaults to "text/html", and response has no Content-Type -> "" containing "text/html"? No. // Empty type defaults to "text/html", and response has no Content-Type -> "" containing "text/html"? No.
// strings.Contains("text/html", "") is always true, so rewrite should happen. // strings.Contains("text/html", "") is always true, so rewrite should happen.
_ = rewriteRespBody(resp, makeRewrite("", "replaced")) _ = rewriteRespBody(context.Background(), resp, makeRewrite("", "replaced"))
got, _ := io.ReadAll(resp.Body) got, _ := io.ReadAll(resp.Body)
if string(got) != "replaced" { if string(got) != "replaced" {
t.Errorf("body = %q, want %q (should be rewritten)", string(got), "replaced") t.Errorf("body = %q, want %q (should be rewritten)", string(got), "replaced")
@@ -1146,7 +1147,7 @@ func TestRewriteRespBody_PatternReplacement(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("<title>Old Title</title>")), Body: io.NopCloser(strings.NewReader("<title>Old Title</title>")),
} }
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{ err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("Old"), Pattern: regexp.MustCompile("Old"),
Type: "text/html", Type: "text/html",
Replacement: []byte("New"), Replacement: []byte("New"),
@@ -1169,7 +1170,7 @@ func TestRewriteRespBody_MultipleRewrites(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")), Body: io.NopCloser(strings.NewReader("hello")),
} }
err := rewriteRespBody(resp, err := rewriteRespBody(context.Background(), resp,
chain.HTTPBodyRewriteSettings{ chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("h.*o"), Pattern: regexp.MustCompile("h.*o"),
Type: "text/html", Type: "text/html",
@@ -1196,7 +1197,7 @@ func TestRewriteRespBody_NilPattern(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{ err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: nil, Pattern: nil,
Type: "*", Type: "*",
Replacement: []byte("replaced"), Replacement: []byte("replaced"),
@@ -1215,7 +1216,7 @@ func TestRewriteRespBody_EmptyContentTypeUsesDefault(t *testing.T) {
ContentLength: 100, ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{ err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"), Pattern: regexp.MustCompile(".*"),
Type: "", Type: "",
Replacement: []byte("rewritten"), Replacement: []byte("rewritten"),
@@ -1235,7 +1236,7 @@ func TestRewriteRespBody_NegativeContentLength(t *testing.T) {
ContentLength: -1, ContentLength: -1,
Body: io.NopCloser(strings.NewReader("original")), Body: io.NopCloser(strings.NewReader("original")),
} }
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{ err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"), Pattern: regexp.MustCompile(".*"),
Type: "*", Type: "*",
Replacement: []byte("replaced"), Replacement: []byte("replaced"),
@@ -1250,9 +1251,209 @@ func TestRewriteRespBody_NegativeContentLength(t *testing.T) {
} }
// ============================================================================= // =============================================================================
// copyWebsocketFrame Additional Tests // Rewriter Plugin Tests
// ============================================================================= // =============================================================================
// mockRewriter is a simple rewriter.Rewriter for testing.
type mockRewriter struct {
cb func(b []byte) []byte
called bool
}
func (m *mockRewriter) Rewrite(_ context.Context, b []byte, _ ...rewriter.RewriteOption) ([]byte, error) {
m.called = true
if m.cb != nil {
return m.cb(b), nil
}
return b, nil
}
func TestRewriteRespBody_RewriterNilPattern(t *testing.T) {
rw := &mockRewriter{}
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Rewriter: rw,
})
if err != nil {
t.Fatal(err)
}
if !rw.called {
t.Error("rewriter was not called")
}
}
func TestRewriteRespBody_RewriterMatchPass(t *testing.T) {
rw := &mockRewriter{
cb: func(b []byte) []byte {
return []byte("rewritten")
},
}
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello world")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Pattern: regexp.MustCompile("hello"),
Rewriter: rw,
})
if err != nil {
t.Fatal(err)
}
if !rw.called {
t.Fatal("rewriter was not called")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "rewritten" {
t.Errorf("body = %q, want %q", string(body), "rewritten")
}
if resp.ContentLength != int64(len("rewritten")) {
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
}
}
func TestRewriteRespBody_RewriterMatchSkip(t *testing.T) {
rw := &mockRewriter{}
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello world")),
}
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Pattern: regexp.MustCompile("xyz"),
Rewriter: rw,
})
if err != nil {
t.Fatal(err)
}
if rw.called {
t.Fatal("rewriter should not have been called: body did not match pattern")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "hello world" {
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello world")
}
}
func TestRewriteRespBody_RewriterContentTypeFilter(t *testing.T) {
rw := &mockRewriter{
cb: func(b []byte) []byte { return []byte("rewritten") },
}
t.Run("type matches calls rewriter", func(t *testing.T) {
rw.called = false
resp := &http.Response{
Header: http.Header{"Content-Type": {"application/json"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader(`{"key":"value"}`)),
}
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "application/json",
Rewriter: rw,
})
if !rw.called {
t.Error("rewriter should have been called")
}
})
t.Run("type mismatch skips rewriter", func(t *testing.T) {
rw.called = false
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/plain"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")),
}
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
Type: "application/json",
Rewriter: rw,
})
if rw.called {
t.Error("rewriter should not have been called: content type mismatch")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "hello" {
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello")
}
})
}
func TestRewriteReqBody_Rewriter(t *testing.T) {
rw := &mockRewriter{
cb: func(b []byte) []byte {
return []byte("rewritten")
},
}
t.Run("nil pattern calls rewriter", func(t *testing.T) {
rw.called = false
req := &http.Request{
Body: io.NopCloser(strings.NewReader("hello")),
ContentLength: 5,
Header: http.Header{"Content-Type": {"text/plain"}},
}
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
Type: "*",
Rewriter: rw,
})
if !rw.called {
t.Fatal("rewriter was not called")
}
body, _ := io.ReadAll(req.Body)
if string(body) != "rewritten" {
t.Errorf("body = %q, want %q", string(body), "rewritten")
}
})
t.Run("match passes through to rewriter", func(t *testing.T) {
rw.called = false
req := &http.Request{
Body: io.NopCloser(strings.NewReader("hello world")),
ContentLength: 11,
Header: http.Header{"Content-Type": {"text/html"}},
}
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
Type: "text/html",
Pattern: regexp.MustCompile("hello"),
Rewriter: rw,
})
if !rw.called {
t.Fatal("rewriter was not called")
}
body, _ := io.ReadAll(req.Body)
if string(body) != "rewritten" {
t.Errorf("body = %q, want %q", string(body), "rewritten")
}
})
t.Run("non-match skips rewriter", func(t *testing.T) {
rw.called = false
req := &http.Request{
Body: io.NopCloser(strings.NewReader("hello world")),
ContentLength: 11,
Header: http.Header{"Content-Type": {"text/html"}},
}
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
Type: "text/html",
Pattern: regexp.MustCompile("xyz"),
Rewriter: rw,
})
if rw.called {
t.Fatal("rewriter should not have been called: body did not match pattern")
}
body, _ := io.ReadAll(req.Body)
if string(body) != "hello world" {
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello world")
}
})
}
func TestCopyWebsocketFrame_EmptyPayload(t *testing.T) { func TestCopyWebsocketFrame_EmptyPayload(t *testing.T) {
h := &Sniffer{ h := &Sniffer{
Recorder: &noopRecorder{}, Recorder: &noopRecorder{},
+20 -1
View File
@@ -3,6 +3,7 @@ package socks
import ( import (
"bytes" "bytes"
"net" "net"
"strconv"
"github.com/go-gost/core/common/bufpool" "github.com/go-gost/core/common/bufpool"
"github.com/go-gost/gosocks5" "github.com/go-gost/gosocks5"
@@ -106,6 +107,20 @@ func UDPConn(c net.PacketConn, bufferSize int) net.PacketConn {
} }
} }
// domainAddr is a net.Addr that preserves a domain name without resolving it.
// Returned by udpConn.ReadFrom when a SOCKS5 UDP datagram carries
// ATYP=DOMAINNAME, so that downstream consumers (e.g. relay-chain WriteTo) can
// forward the domain verbatim instead of forcing a local DNS lookup that would
// leak the query through the system resolver and bypass any configured resolver.
type domainAddr struct {
network string
host string
port int
}
func (a *domainAddr) Network() string { return a.network }
func (a *domainAddr) String() string { return net.JoinHostPort(a.host, strconv.Itoa(a.port)) }
// ReadFrom reads an UDP datagram. // ReadFrom reads an UDP datagram.
// NOTE: for server side, // NOTE: for server side,
// the returned addr is the target address the client want to relay to. // the returned addr is the target address the client want to relay to.
@@ -128,7 +143,11 @@ func (c *udpConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
} }
n = copy(b, buf[hlen:n]) n = copy(b, buf[hlen:n])
addr, err = net.ResolveUDPAddr("udp", socksAddr.String()) if net.ParseIP(socksAddr.Host) != nil {
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
} else {
addr = &domainAddr{network: "udp", host: socksAddr.Host, port: int(socksAddr.Port)}
}
return return
} }
+7
View File
@@ -21,6 +21,7 @@ import (
reg "github.com/go-gost/core/registry" reg "github.com/go-gost/core/registry"
"github.com/go-gost/core/resolver" "github.com/go-gost/core/resolver"
"github.com/go-gost/core/router" "github.com/go-gost/core/router"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/sd" "github.com/go-gost/core/sd"
"github.com/go-gost/core/service" "github.com/go-gost/core/service"
"github.com/go-gost/x/limiter/quota" "github.com/go-gost/x/limiter/quota"
@@ -46,6 +47,7 @@ var (
resolverReg reg.Registry[resolver.Resolver] = new(resolverRegistry) resolverReg reg.Registry[resolver.Resolver] = new(resolverRegistry)
hostsReg reg.Registry[hosts.HostMapper] = new(hostsRegistry) hostsReg reg.Registry[hosts.HostMapper] = new(hostsRegistry)
recorderReg reg.Registry[recorder.Recorder] = new(recorderRegistry) recorderReg reg.Registry[recorder.Recorder] = new(recorderRegistry)
rewriterReg reg.Registry[rewriter.Rewriter] = new(rewriterRegistry)
trafficLimiterReg reg.Registry[traffic.TrafficLimiter] = new(trafficLimiterRegistry) trafficLimiterReg reg.Registry[traffic.TrafficLimiter] = new(trafficLimiterRegistry)
connLimiterReg reg.Registry[conn.ConnLimiter] = new(connLimiterRegistry) connLimiterReg reg.Registry[conn.ConnLimiter] = new(connLimiterRegistry)
@@ -187,6 +189,11 @@ func RecorderRegistry() reg.Registry[recorder.Recorder] {
return recorderReg return recorderReg
} }
// RewriterRegistry returns the global registry of rewriter instances.
func RewriterRegistry() reg.Registry[rewriter.Rewriter] {
return rewriterReg
}
// TrafficLimiterRegistry returns the global registry of traffic limiter instances. // TrafficLimiterRegistry returns the global registry of traffic limiter instances.
func TrafficLimiterRegistry() reg.Registry[traffic.TrafficLimiter] { func TrafficLimiterRegistry() reg.Registry[traffic.TrafficLimiter] {
return trafficLimiterReg return trafficLimiterReg
+43
View File
@@ -0,0 +1,43 @@
package registry
import (
"context"
"github.com/go-gost/core/rewriter"
)
// rewriterRegistry implements a hot-reload-safe registry for rewriter.Rewriter.
type rewriterRegistry struct {
registry[rewriter.Rewriter]
}
// Register stores a Rewriter under the given name.
func (r *rewriterRegistry) Register(name string, v rewriter.Rewriter) error {
return r.registry.Register(name, v)
}
// Get returns a wrapper that delegates to the currently registered Rewriter.
// Returns nil if name is empty.
func (r *rewriterRegistry) Get(name string) rewriter.Rewriter {
if name != "" {
return &rewriterWrapper{name: name, r: r}
}
return nil
}
func (r *rewriterRegistry) get(name string) rewriter.Rewriter {
return r.registry.Get(name)
}
type rewriterWrapper struct {
name string
r *rewriterRegistry
}
func (w *rewriterWrapper) Rewrite(ctx context.Context, b []byte, opts ...rewriter.RewriteOption) ([]byte, error) {
v := w.r.get(w.name)
if v == nil {
return b, nil
}
return v.Rewrite(ctx, b, opts...)
}
+90
View File
@@ -0,0 +1,90 @@
package rewriter
import (
"context"
"encoding/json"
"io"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/plugin/rewriter/proto"
"github.com/go-gost/x/internal/plugin"
"google.golang.org/grpc"
)
type grpcPlugin struct {
conn grpc.ClientConnInterface
client proto.RewriterClient
log logger.Logger
}
// NewGRPCPlugin creates a Rewriter plugin based on gRPC.
func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) rewriter.Rewriter {
var options plugin.Options
for _, opt := range opts {
opt(&options)
}
log := logger.Default().WithFields(map[string]any{
"kind": "rewriter",
"rewriter": name,
})
conn, err := plugin.NewGRPCConn(addr, &options)
if err != nil {
log.Error(err)
}
if conn == nil {
return nil
}
p := &grpcPlugin{
conn: conn,
log: log,
}
p.client = proto.NewRewriterClient(conn)
return p
}
func (p *grpcPlugin) Rewrite(ctx context.Context, b []byte, opts ...rewriter.RewriteOption) ([]byte, error) {
if p.client == nil {
return b, nil
}
var options rewriter.RewriteOptions
for _, opt := range opts {
opt(&options)
}
md, err := json.Marshal(options.Metadata)
if err != nil {
return b, err
}
reply, err := p.client.Rewrite(ctx,
&proto.RewriteRequest{
Data: b,
Metadata: md,
})
if err != nil {
p.log.Error(err)
return b, err
}
if reply == nil || !reply.Ok {
return b, nil
}
if reply.Data != nil {
return reply.Data, nil
}
return b, nil
}
func (p *grpcPlugin) Close() error {
if p.conn == nil {
return nil
}
if closer, ok := p.conn.(io.Closer); ok {
return closer.Close()
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package rewriter
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/x/internal/plugin"
)
type httpPluginRequest struct {
Data []byte `json:"data"`
Metadata []byte `json:"metadata"`
}
type httpPluginResponse struct {
OK bool `json:"ok"`
Data []byte `json:"data"`
}
type httpPlugin struct {
url string
client *http.Client
header http.Header
log logger.Logger
}
// NewHTTPPlugin creates a Rewriter plugin based on HTTP.
func NewHTTPPlugin(name string, url string, opts ...plugin.Option) rewriter.Rewriter {
var options plugin.Options
for _, opt := range opts {
opt(&options)
}
return &httpPlugin{
url: url,
client: plugin.NewHTTPClient(&options),
header: options.Header,
log: logger.Default().WithFields(map[string]any{
"kind": "rewriter",
"rewriter": name,
}),
}
}
func (p *httpPlugin) Rewrite(ctx context.Context, b []byte, opts ...rewriter.RewriteOption) ([]byte, error) {
if p.client == nil {
return b, nil
}
var options rewriter.RewriteOptions
for _, opt := range opts {
opt(&options)
}
md, err := json.Marshal(options.Metadata)
if err != nil {
return b, err
}
rb := httpPluginRequest{
Data: b,
Metadata: md,
}
v, err := json.Marshal(&rb)
if err != nil {
return b, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(v))
if err != nil {
return b, err
}
if p.header != nil {
req.Header = p.header.Clone()
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return b, err
}
defer resp.Body.Close()
defer io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return b, nil
}
res := httpPluginResponse{}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return b, err
}
if !res.OK {
return b, nil
}
if res.Data != nil {
return res.Data, nil
}
return b, nil
}
+145
View File
@@ -0,0 +1,145 @@
package util
import (
stdtls "crypto/tls"
"encoding/hex"
"net"
tls "github.com/refraction-networking/utls"
)
func NewUTLSClient(conn net.Conn, config *stdtls.Config, clientHelloID tls.ClientHelloID) *tls.UConn {
return tls.UClient(conn, NewUTLSConfig(config), clientHelloID)
}
func NewUTLSChromeClient(conn net.Conn, config *stdtls.Config) *tls.UConn {
return NewUTLSClient(conn, config, tls.HelloChrome_Auto)
}
func NewUTLSWebSocketClient(conn net.Conn, config *stdtls.Config, useH2 bool) (*tls.UConn, error) {
utlsConfig := NewLegacyUTLSConfig(config)
if useH2 {
return tls.UClient(conn, utlsConfig, tls.HelloChrome_Auto), nil
}
client := tls.UClient(conn, utlsConfig, tls.HelloCustom)
spec, err := NewWebSocketClientHelloSpec()
if err != nil {
return nil, err
}
if err := client.ApplyPreset(spec); err != nil {
return nil, err
}
return client, nil
}
func NewLegacyUTLSConfig(config *stdtls.Config) *tls.Config {
if config == nil {
return &tls.Config{}
}
cfg := &tls.Config{
InsecureSkipVerify: config.InsecureSkipVerify,
ServerName: config.ServerName,
ClientAuth: tls.ClientAuthType(config.ClientAuth),
ClientCAs: config.ClientCAs,
RootCAs: config.RootCAs,
}
cfg.Certificates = make([]tls.Certificate, 0, len(config.Certificates))
for _, cert := range config.Certificates {
cfg.Certificates = append(cfg.Certificates, tls.Certificate{
Certificate: cert.Certificate,
PrivateKey: cert.PrivateKey,
OCSPStaple: cert.OCSPStaple,
SignedCertificateTimestamps: cert.SignedCertificateTimestamps,
Leaf: cert.Leaf,
})
}
return cfg
}
func NewUTLSConfig(config *stdtls.Config) *tls.Config {
if config == nil {
return &tls.Config{}
}
cfg := &tls.Config{
Rand: config.Rand,
Time: config.Time,
RootCAs: config.RootCAs,
NextProtos: append([]string(nil), config.NextProtos...),
ServerName: config.ServerName,
ClientAuth: tls.ClientAuthType(config.ClientAuth),
ClientCAs: config.ClientCAs,
InsecureSkipVerify: config.InsecureSkipVerify,
CipherSuites: append([]uint16(nil), config.CipherSuites...),
SessionTicketsDisabled: config.SessionTicketsDisabled,
MinVersion: config.MinVersion,
MaxVersion: config.MaxVersion,
DynamicRecordSizingDisabled: config.DynamicRecordSizingDisabled,
Renegotiation: tls.RenegotiationSupport(config.Renegotiation),
KeyLogWriter: config.KeyLogWriter,
VerifyPeerCertificate: config.VerifyPeerCertificate,
}
cfg.Certificates = make([]tls.Certificate, 0, len(config.Certificates))
for _, cert := range config.Certificates {
cfg.Certificates = append(cfg.Certificates, toUTLSCertificate(cert))
}
cfg.CurvePreferences = make([]tls.CurveID, 0, len(config.CurvePreferences))
for _, curve := range config.CurvePreferences {
cfg.CurvePreferences = append(cfg.CurvePreferences, tls.CurveID(curve))
}
if config.VerifyConnection != nil {
cfg.VerifyConnection = func(state tls.ConnectionState) error {
return config.VerifyConnection(toStdTLSConnectionState(state))
}
}
return cfg
}
func toUTLSCertificate(cert stdtls.Certificate) tls.Certificate {
signatureAlgorithms := make([]tls.SignatureScheme, 0, len(cert.SupportedSignatureAlgorithms))
for _, sig := range cert.SupportedSignatureAlgorithms {
signatureAlgorithms = append(signatureAlgorithms, tls.SignatureScheme(sig))
}
return tls.Certificate{
Certificate: cert.Certificate,
PrivateKey: cert.PrivateKey,
SupportedSignatureAlgorithms: signatureAlgorithms,
OCSPStaple: cert.OCSPStaple,
SignedCertificateTimestamps: cert.SignedCertificateTimestamps,
Leaf: cert.Leaf,
}
}
func toStdTLSConnectionState(state tls.ConnectionState) stdtls.ConnectionState {
return stdtls.ConnectionState{
Version: state.Version,
HandshakeComplete: state.HandshakeComplete,
DidResume: state.DidResume,
CipherSuite: state.CipherSuite,
NegotiatedProtocol: state.NegotiatedProtocol,
NegotiatedProtocolIsMutual: state.NegotiatedProtocolIsMutual,
ServerName: state.ServerName,
PeerCertificates: state.PeerCertificates,
VerifiedChains: state.VerifiedChains,
SignedCertificateTimestamps: state.SignedCertificateTimestamps,
OCSPResponse: state.OCSPResponse,
TLSUnique: state.TLSUnique,
ECHAccepted: state.ECHAccepted,
}
}
func NewWebSocketClientHelloSpec() (*tls.ClientHelloSpec, error) {
fingerprinter := &tls.Fingerprinter{}
rawCapturedClientHelloBytes, _ := hex.DecodeString("16030107b8010007b403036252c641762f4a4f55d93dcbe1a01e62839ba1b48e9082896737ca23dc6bd02120c719a58f491c56be1f21fcd68bd097f2ee5a370c576a7c512aba46b7ed73841a0020eaea130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f00350100074baaaa0000000a000c000a5a5a11ec001d00170018000d0012001004030804040105030805050108060601ff0100010000170000002b0007065a5a03040303001b00030200020010000b000908687474702f312e31000b00020100002d00020101fe0d00ba0000010001be0020a5fb3e1a8aa228913bdf2dc9c52670860d2032a04d71140d6dc08f4cb41b2a1c0090420727529f82e9a8b097215adb52126184c638a98f0a6f8121b04edb939a7ff2a9564b44a917160ceb0a41f7a2dc12b89c622bc6687a21759c813858c81fe2e9024b02c6501c4b4c9e87ed2bd1538a420b6045a0785a66b044e53260e19b6f09042b903fa6758ce571dff16b91e95a19dbe5da0a46cab946036f48ad1394534866ee874f8dc75b3e4efa444be660262f003304ef04ed5a5a00010011ec04c098b01e12861f0d1738ebdb83b5fb876e7a1382f50149279a62479352b110f9791d1710c99cd988348175a20884650682e5e6a48bd2b27bb52360882bd84c6f97b6bc142c300ba7ab420bbec1872c13ba6970501e2b473f5c325da09a1dda800060d3c14454a95e6a67ddf28be60140cc3b07d393826b8babaf9a074b97bfa6fb8a2da784e98c5a332c62d7a60b7ac5b85e6606d128a4e9d89056680c6ff3ad5c3a3018218006db0fd81843e4ccb3f3e42ec858554be50aaa5b9afb613a1c07a631551706c4840bdb11a41564db56cdd2e58d1c619fc34567b74a2c54755f4174a0d589bf5938388a0976456c2a2aca2c08b2af11a2b550da9b22e4a39e2a7759c887e884b362b0b0e8394a149b44e82b6f5e680cd280cd2167953c94b111c20c4d028b2fc0926ac1a0454977fdf4a6876a555048a0c8b23531215f8543b5af5a2a69fc4151a870f481204e3a1535ec35321697d0ea0a470239e7b40b99e9b7ee01205bf567c42479c137c346b8af4dd825d4324c6d34875dd50b1841a78c42b79075af259a0f45252f311997f39902337088ee6817565b2e63858924a2354d86b1ba77bfa324339ec4a72663134db7227e652aea43700f751f7f519c5a66a036e0274a48c58716a3eb8a6de4730d19d534dd52ae83b9a7f07ccbc8d63b073a5127e3170dc58031524b81b611c6c4cf2aaba01fc55747b643fb417c85c0a21aa823709a037e418fc8e9c16c51a0c105c07d7c2f56dbcd4516a17a10036531768fb04300f0cdcc7c5135517b1643c3204b47575b97d0a513287985a0353523d93d511b694d6bb5ecc0698823855a606b83c77723808dd6737c9129a5c50457e71a063f927299214e902861e89a81423715b60717e8551b29126f857499810a0328d7182b538276d15517aa21b828ba7afcc13c1cc4cd1574a1c97bcfe4490f766d0e95a0c2911bc0d7a47161bcf5c344558314e7a1ab6c4227950b76c3319acaa53a7776a91e3b62ecd38f0732504674247947855a95892cc43b7f56a09f0c4281976e2b73820ff48180f054f482638078b08cf01d2b221a35214002b5cc8e394a414230a4f562af739e70ec540c260c651009a0616c5830658e0cbb2d2a2f94dab9f539a9ef19750f4925f7106a3a9c0a68e4a7d442202678beeef25b92c8709d93133c08728603ac900719db6615d294ad1aa965546a0c655a6891ab8e9fea31b7262dbba5af94372262934a4feac79ad72a3277aa7e97ab48f9a06f6857d26a2d48306b80a4bb600785f5b26f00202d2eb6af0226774e93615a484fa42a91bfe6034e890f6642c95a979d8df8a39ab10977846b1dc1998b51a8dde62975dc54bf92ada8dc0d7c0a020f429d4ff0b11ba95b6e076ec0443d01f0cb963441ddca9244631b776a029f874b799534c9c14808863c756b2d8a0309a0fab26bc37db05601cd5741acf9acc4c84a9e6b76b1e8282908a8d2268562e72109c3c8cb93507e1accf48723a056bd4acab4317771d4e432216a0a39377792469757a8c186d8085078aa2a8bce201628bad8c7ce98bb41dc56d6715a1dc3cb1cab48cb5a8f18d04a651b9efdd751bd199c266aac08726177bb432ecba334b06e516c12449846dcf90a4080248e2b15cb435e021ee8c4a8c17c095babf8011d67e0a5f16e25dc7357fc1c78e5884fe32598695589294e9c431c94ac2f5d2053757f9a905df36b001d002022b1fc0ef89cbfbd8f197c3e230879a2e71c155a1a6cb1bc33136cf93380c41d00000011000f00000c7373682e6576616e2e72756e0012000000230000000500050100000000baba0001000029010b00e600e0d1ae9857a5731dd0d5ca9d726bf57748ff9f57a7bb61ee7cc84e0f06358291fd2694914ab4ab3abc95fec3a1b393f458cca49eddd4dc24c896429eba5464d54ed59c562be84fe5660c8f74263db3c5eefbd36f2c667b9bd8219e20d84162a545978844bce25f2dc0f1c56f032bcee7f0bcd9fc762cfa115f368d0fe3b2e03fce32673bfdafd419eb8bc36bcf9c4a0d49c8d750ea7373e41c2ac13eb97f8fdb6c619742e98951c6447b76b263e0040ae874c7bd486421af99541b3b40e5b6eb3c0ae555dc1dfa7448aee19f05e54f70eb6655a91eb03eb93015a45eb29adc73d82437927e002120022bd91157ac53b55c75c03be16f0bc0040f38fa0085949f0157f8e7db794cc5")
spec, err := fingerprinter.FingerprintClientHello(rawCapturedClientHelloBytes)
return spec, err
}
+109
View File
@@ -0,0 +1,109 @@
package util
import (
stdtls "crypto/tls"
"net"
"testing"
tls "github.com/refraction-networking/utls"
)
func TestNewUTLSConfigPreservesClientSettings(t *testing.T) {
called := false
cfg := NewUTLSConfig(&stdtls.Config{
ServerName: "example.com",
InsecureSkipVerify: true,
NextProtos: []string{"h2", "http/1.1"},
MinVersion: stdtls.VersionTLS12,
MaxVersion: stdtls.VersionTLS13,
CipherSuites: []uint16{stdtls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256},
CurvePreferences: []stdtls.CurveID{stdtls.X25519, stdtls.CurveP256},
DynamicRecordSizingDisabled: true,
Renegotiation: stdtls.RenegotiateOnceAsClient,
VerifyConnection: func(state stdtls.ConnectionState) error {
called = true
if state.ServerName != "example.com" {
t.Fatalf("ServerName = %q, want example.com", state.ServerName)
}
return nil
},
})
if cfg.ServerName != "example.com" {
t.Fatalf("ServerName = %q, want example.com", cfg.ServerName)
}
if !cfg.InsecureSkipVerify {
t.Fatal("InsecureSkipVerify was not preserved")
}
if len(cfg.NextProtos) != 2 || cfg.NextProtos[0] != "h2" || cfg.NextProtos[1] != "http/1.1" {
t.Fatalf("NextProtos = %v", cfg.NextProtos)
}
if cfg.MinVersion != tls.VersionTLS12 || cfg.MaxVersion != tls.VersionTLS13 {
t.Fatalf("version range = %x-%x", cfg.MinVersion, cfg.MaxVersion)
}
if len(cfg.CipherSuites) != 1 || cfg.CipherSuites[0] != tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 {
t.Fatalf("CipherSuites = %v", cfg.CipherSuites)
}
if len(cfg.CurvePreferences) != 2 || cfg.CurvePreferences[0] != tls.X25519 || cfg.CurvePreferences[1] != tls.CurveP256 {
t.Fatalf("CurvePreferences = %v", cfg.CurvePreferences)
}
if !cfg.DynamicRecordSizingDisabled {
t.Fatal("DynamicRecordSizingDisabled was not preserved")
}
if cfg.Renegotiation != tls.RenegotiateOnceAsClient {
t.Fatalf("Renegotiation = %v", cfg.Renegotiation)
}
if cfg.VerifyConnection == nil {
t.Fatal("VerifyConnection was not preserved")
}
if err := cfg.VerifyConnection(tls.ConnectionState{ServerName: "example.com"}); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("VerifyConnection wrapper was not called")
}
}
func TestNewUTLSChromeClientBuildsChromeHello(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
client := NewUTLSChromeClient(c1, &stdtls.Config{ServerName: "example.com"})
if client.ClientHelloID != tls.HelloChrome_Auto {
t.Fatalf("ClientHelloID = %+v, want %+v", client.ClientHelloID, tls.HelloChrome_Auto)
}
if err := client.BuildHandshakeStateWithoutSession(); err != nil {
t.Fatal(err)
}
if len(client.Extensions) == 0 {
t.Fatal("Chrome ClientHello has no extensions")
}
}
func TestNewUTLSWebSocketClientAppliesCustomSpec(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
client, err := NewUTLSWebSocketClient(c1, &stdtls.Config{
ServerName: "example.com",
NextProtos: []string{"h2"},
MinVersion: stdtls.VersionTLS13,
}, false)
if err != nil {
t.Fatal(err)
}
if client.ClientHelloID != tls.HelloCustom {
t.Fatalf("ClientHelloID = %+v, want %+v", client.ClientHelloID, tls.HelloCustom)
}
if len(client.Extensions) == 0 {
t.Fatal("custom WebSocket ClientHello preset has no extensions")
}
if err := client.BuildHandshakeStateWithoutSession(); err != nil {
t.Fatal(err)
}
if len(client.HandshakeState.Hello.AlpnProtocols) != 1 || client.HandshakeState.Hello.AlpnProtocols[0] != "http/1.1" {
t.Fatalf("ALPN = %v, want [http/1.1]", client.HandshakeState.Hello.AlpnProtocols)
}
}