mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d024c7786e | ||
|
|
d8317d876f | ||
|
|
69c397ded9 | ||
|
|
7f36d10605 | ||
|
|
5b1ff8330b | ||
|
|
3193eebd6c | ||
|
|
c71b16490b | ||
|
|
ae73d1ba61 | ||
|
|
4c086685b1 | ||
|
|
56c72823cb | ||
|
|
2a9e059bc2 | ||
|
|
f63f36805b | ||
|
|
1b07475bf3 | ||
|
|
3b25e0317f | ||
|
|
fb0cf72446 | ||
|
|
ee7b1e9462 | ||
|
|
143438a30e | ||
|
|
cd97e5e746 | ||
|
|
c1d954d689 | ||
|
|
d4e9450bad | ||
|
|
b777bcb81a | ||
|
|
39580f04ca | ||
|
|
b01f5d065f | ||
|
|
8fd5200bf1 | ||
|
|
ec4791485c | ||
|
|
c06ba17916 | ||
|
|
a32ffd5608 | ||
|
|
ffa7ceaed7 | ||
|
|
ea3ca94e53 | ||
|
|
5d6b00c3a6 | ||
|
|
0637486025 | ||
|
|
99a0f7323d | ||
|
|
4057ac54b8 | ||
|
|
6a0ce8914e | ||
|
|
03f4b1291e | ||
|
|
2e16457467 | ||
|
|
a1d7da4fec | ||
|
|
0522ae8155 | ||
|
|
55a246869e | ||
|
|
3ec3362ac4 | ||
|
|
3f01456480 | ||
|
|
9ea10763e4 | ||
|
|
e814852ca1 | ||
|
|
a102f9c6cf | ||
|
|
9ccf402ed1 | ||
|
|
61eb3e2a3b | ||
|
|
92002fa35e | ||
|
|
a424148018 | ||
|
|
d4d823df45 | ||
|
|
c45d27113e | ||
|
|
9fab332772 |
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -466,6 +466,17 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// stdio listener uses url.Host as the forwarding target.
|
||||
if listener == "stdio" && len(nodes) == 0 && url.Host != "" {
|
||||
nodes = append(nodes, &config.ForwardNodeConfig{
|
||||
Name: fmt.Sprintf("%starget-0", namePrefix),
|
||||
Addr: url.Host,
|
||||
})
|
||||
if handler == "auto" {
|
||||
handler = "tcp"
|
||||
}
|
||||
}
|
||||
|
||||
if len(nodes) > 0 {
|
||||
if len(services) == 1 {
|
||||
services[0].Forwarder = &config.ForwarderConfig{
|
||||
|
||||
+21
-2
@@ -316,6 +316,11 @@ type RecorderObject struct {
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RewriterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
type LimiterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
|
||||
@@ -397,6 +402,9 @@ type HTTPBodyRewriteConfig struct {
|
||||
Type string
|
||||
Match string
|
||||
Replacement string
|
||||
// name of the rewriter plugin (via registry)
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
MaxChunkSize int `yaml:"maxChunkSize,omitempty" json:"maxChunkSize,omitempty"`
|
||||
}
|
||||
|
||||
type NodeFilterConfig struct {
|
||||
@@ -405,6 +413,15 @@ type NodeFilterConfig struct {
|
||||
Path string `yaml:",omitempty" json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMatcherConfig defines a routing-rule matcher for a hop node.
|
||||
//
|
||||
// Priority controls election among multiple matching nodes:
|
||||
// - 0 (default): auto-computed from the rule string length — longer rules
|
||||
// (more specific) get higher priority.
|
||||
// - negative: the node participates in matching but priority short-circuit
|
||||
// is disabled; the selector (round-robin, random, hash, etc.) always applies.
|
||||
// - positive: explicit priority; when a single node has strictly higher
|
||||
// priority than all others, it wins directly, bypassing the selector.
|
||||
type NodeMatcherConfig struct {
|
||||
Rule string `yaml:",omitempty" json:"rule,omitempty"`
|
||||
Priority int `yaml:",omitempty" json:"priority,omitempty"`
|
||||
@@ -478,8 +495,9 @@ type ServiceConfig struct {
|
||||
Logger string `yaml:",omitempty" json:"logger,omitempty"`
|
||||
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
|
||||
Observer string `yaml:",omitempty" json:"observer,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
|
||||
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
@@ -628,6 +646,7 @@ type Config struct {
|
||||
Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"`
|
||||
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
|
||||
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
|
||||
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
|
||||
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
reg "github.com/go-gost/core/registry"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/core/router"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/sd"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
@@ -38,6 +39,7 @@ import (
|
||||
quota_parser "github.com/go-gost/x/config/parsing/quota"
|
||||
recorder_parser "github.com/go-gost/x/config/parsing/recorder"
|
||||
resolver_parser "github.com/go-gost/x/config/parsing/resolver"
|
||||
rewriter_parser "github.com/go-gost/x/config/parsing/rewriter"
|
||||
router_parser "github.com/go-gost/x/config/parsing/router"
|
||||
sd_parser "github.com/go-gost/x/config/parsing/sd"
|
||||
service_parser "github.com/go-gost/x/config/parsing/service"
|
||||
@@ -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]
|
||||
for _, c := range cfg.Limiters {
|
||||
|
||||
@@ -29,6 +29,30 @@ import (
|
||||
// metadata-driven options (so_mark, interface, netns, proxy protocol), sets up
|
||||
// bypass rules, node filters, HTTP settings, and TLS node settings. The hop
|
||||
// parameter is used only for logging context.
|
||||
|
||||
func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []chain.HTTPBodyRewriteSettings {
|
||||
var out []chain.HTTPBodyRewriteSettings
|
||||
for _, v := range vs {
|
||||
pattern, _ := regexp.Compile(v.Match)
|
||||
rw := chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
MaxChunkSize: v.MaxChunkSize,
|
||||
}
|
||||
if v.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(v.Rewriter) {
|
||||
log.Warnf("rewriter %q not found in registry for rewrite rule", v.Rewriter)
|
||||
}
|
||||
rw.Rewriter = registry.RewriterRegistry().Get(v.Rewriter)
|
||||
}
|
||||
if pattern != nil || rw.Rewriter != nil {
|
||||
out = append(out, rw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
@@ -176,6 +200,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
|
||||
if matcher, err := routing.NewMatcher(rule); err == nil {
|
||||
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule)
|
||||
// Priority 0 means "use default": automatically set to the
|
||||
// rule length so longer (more specific) rules outrank shorter
|
||||
// ones. Use a negative priority to opt out of this behavior
|
||||
// and always go through the selector.
|
||||
if priority == 0 {
|
||||
priority = len(cfg.Matcher.Rule)
|
||||
}
|
||||
@@ -222,33 +250,9 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteBody {
|
||||
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.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),
|
||||
})
|
||||
}
|
||||
}
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
|
||||
settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
|
||||
opts = append(opts, chain.HTTPNodeOption(settings))
|
||||
}
|
||||
|
||||
|
||||
@@ -328,6 +328,7 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
|
||||
SDs: append(cfg1.SDs, cfg2.SDs...),
|
||||
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
|
||||
Rewriters: append(cfg1.Rewriters, cfg2.Rewriters...),
|
||||
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
|
||||
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
|
||||
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
@@ -34,11 +35,27 @@ import (
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xstats "github.com/go-gost/x/observer/stats"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
"github.com/go-gost/x/registry"
|
||||
xservice "github.com/go-gost/x/service"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
// plaintextListeners are listener types that never terminate TLS on their
|
||||
// accepted connections, so any certFile/keyFile/caFile configured for them is
|
||||
// silently ignored. This catches the common footgun where a user writes e.g.
|
||||
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
|
||||
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
|
||||
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
|
||||
// new plaintext listeners are added.
|
||||
var plaintextListeners = map[string]bool{
|
||||
"tcp": true, "udp": true,
|
||||
"ws": true, "mws": true,
|
||||
"redirect": true, "tproxy": true,
|
||||
"rtcp": true, "rudp": true,
|
||||
"unix": true, "runix": true, "serial": true, "stdio": true,
|
||||
}
|
||||
|
||||
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
|
||||
// It defaults the listener to "tcp" and the handler to "auto", resolves named
|
||||
// components from the registry (authers, admissions, bypasses, resolvers,
|
||||
@@ -88,6 +105,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
|
||||
}
|
||||
|
||||
if (tlsCfg.CertFile != "" || tlsCfg.KeyFile != "" || tlsCfg.CAFile != "") && plaintextListeners[cfg.Listener.Type] {
|
||||
serviceLogger.Warnf("TLS certificate options are configured but the %q listener does not use TLS and will ignore them; the connection will be plaintext. Use a TLS-capable listener (e.g. mwss, wss, tls, quic, grpc, http2, http3) to enable TLS.",
|
||||
cfg.Listener.Type)
|
||||
}
|
||||
|
||||
authers := auth_parser.List(cfg.Listener.Auther, cfg.Listener.Authers...)
|
||||
if len(authers) == 0 {
|
||||
if auther := auth_parser.ParseAutherFromAuth(cfg.Listener.Auth); auther != nil {
|
||||
@@ -282,11 +304,44 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
auther = xauth.AuthenticatorGroup(authers...)
|
||||
}
|
||||
|
||||
// Parse skipauth from handler metadata to whitelist client IPs that
|
||||
// should bypass authentication. Accepts both YAML arrays
|
||||
// (skipauth: ["10.0.0.0/8"]) and comma-separated URL query values
|
||||
// (?skipauth=10.0.0.0/8,192.168.1.1).
|
||||
if cfg.Handler.Metadata != nil {
|
||||
hmd := metadata.NewMetadata(cfg.Handler.Metadata)
|
||||
skipauth := mdutil.GetStrings(hmd, "skipauth")
|
||||
if len(skipauth) == 0 {
|
||||
if s := mdutil.GetString(hmd, "skipauth"); s != "" {
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
skipauth = append(skipauth, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(skipauth) > 0 {
|
||||
if auther != nil {
|
||||
auther = xauth.WhitelistedAuthenticator(auther, skipauth)
|
||||
handlerLogger.Debugf("skipauth whitelist applied: %v", skipauth)
|
||||
} else {
|
||||
handlerLogger.Warnf("skipauth configured but no auther set — authentication is already disabled for all clients")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var recorders []recorder.RecorderObject
|
||||
for _, r := range cfg.Recorders {
|
||||
md := metadata.NewMetadata(r.Metadata)
|
||||
rec := registry.RecorderRegistry().Get(r.Name)
|
||||
if r.Metadata != nil {
|
||||
rec = &xrecorder.MetadataRecorder{
|
||||
Recorder: rec,
|
||||
Metadata: r.Metadata,
|
||||
}
|
||||
}
|
||||
recorders = append(recorders, recorder.RecorderObject{
|
||||
Recorder: registry.RecorderRegistry().Get(r.Name),
|
||||
Recorder: rec,
|
||||
Record: r.Record,
|
||||
Options: &recorder.Options{
|
||||
Direction: mdutil.GetBool(md, parsing.MDKeyRecorderDirection),
|
||||
@@ -295,9 +350,18 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
HTTPBody: mdutil.GetBool(md, parsing.MDKeyRecorderHTTPBody),
|
||||
MaxBodySize: mdutil.GetInt(md, parsing.MDKeyRecorderHTTPMaxBodySize),
|
||||
},
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
var rew rewriter.Rewriter
|
||||
if cfg.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(cfg.Rewriter) {
|
||||
serviceLogger.Warnf("rewriter %q not found in registry", cfg.Rewriter)
|
||||
}
|
||||
rew = registry.RewriterRegistry().Get(cfg.Rewriter)
|
||||
}
|
||||
|
||||
routerOpts = []chain.RouterOption{
|
||||
chain.RetriesRouterOption(cfg.Handler.Retries),
|
||||
chain.TimeoutRouterOption(dialTimeout),
|
||||
@@ -327,6 +391,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
|
||||
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
|
||||
handler.RecordersOption(recorders...),
|
||||
handler.RewriterOption(rew),
|
||||
handler.LoggerOption(handlerLogger),
|
||||
handler.ServiceOption(cfg.Name),
|
||||
handler.NetnsOption(netnsIn),
|
||||
|
||||
+98
-22
@@ -12,6 +12,8 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +26,24 @@ var (
|
||||
defaultTLSConfig atomic.Value
|
||||
)
|
||||
|
||||
// testDefaultCertDir overrides the default certificate directory in tests.
|
||||
// It is set only from same-package test code and left empty in production.
|
||||
var testDefaultCertDir string
|
||||
|
||||
// defaultCertDir returns the directory used for persisting auto-generated
|
||||
// certificates. It uses testDefaultCertDir when set (for testing), otherwise
|
||||
// returns $HOME/.gost/.
|
||||
func defaultCertDir() string {
|
||||
if testDefaultCertDir != "" {
|
||||
return testDefaultCertDir
|
||||
}
|
||||
dir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".gost"
|
||||
}
|
||||
return filepath.Join(dir, ".gost")
|
||||
}
|
||||
|
||||
// DefaultTLSConfig returns the global default TLS configuration used as a
|
||||
// fallback when a listener or handler does not specify its own TLS settings.
|
||||
// The returned config is a shared instance; callers that need to mutate it
|
||||
@@ -40,44 +60,99 @@ func SetDefaultTLSConfig(cfg *tls.Config) {
|
||||
}
|
||||
|
||||
// BuildDefaultTLSConfig loads or generates the default TLS certificate and key
|
||||
// from the given config. If cfg is nil it attempts to load well-known files
|
||||
// ("cert.pem", "key.pem", "ca.pem"). On failure it generates a self-signed
|
||||
// ECDSA P-256 certificate valid for one year.
|
||||
// from the given config.
|
||||
//
|
||||
// When explicit certificate files are configured (CertFile, KeyFile, or CAFile
|
||||
// is non-empty) it loads them via tls_util.LoadDefaultConfig — the original
|
||||
// behaviour preserved for backward compatibility.
|
||||
//
|
||||
// When no certificate files are configured (all three empty), it tries sources
|
||||
// in this order:
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/ (persisted)
|
||||
// 3. If none found, generates a new ECDSA P-256 CA certificate, persists it
|
||||
// to $HOME/.gost/, and uses it.
|
||||
//
|
||||
// If cfg is nil an empty TLSConfig is used (equivalent to no explicit files),
|
||||
// so the CWD → persisted → generate path is taken.
|
||||
func BuildDefaultTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
if cfg == nil {
|
||||
cfg = &config.TLSConfig{
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
CAFile: "ca.pem",
|
||||
}
|
||||
cfg = &config.TLSConfig{}
|
||||
}
|
||||
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
// generate random self-signed certificate.
|
||||
cert, err := genCertificate(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if cfg.CertFile != "" || cfg.KeyFile != "" || cfg.CAFile != "" {
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
log.Debug("load global TLS certificate files failed, use random generated certificate")
|
||||
} else {
|
||||
log.Debug("load global TLS certificate files OK")
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
tlsConfig, err := loadOrGeneratePersistentTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func genCertificate(validity time.Duration, org string, cn string) (cert tls.Certificate, err error) {
|
||||
rawCert, rawKey, err := generateKeyPair(validity, org, cn)
|
||||
if err != nil {
|
||||
return
|
||||
// loadOrGeneratePersistentTLSConfig tries loading an auto-generated CA
|
||||
// certificate from these locations (in order):
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under defaultCertDir() (persisted)
|
||||
//
|
||||
// If none is found it generates a new one, persists it to defaultCertDir()
|
||||
// (best-effort), and returns it.
|
||||
func loadOrGeneratePersistentTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
// 1. Try CWD cert.pem / key.pem first (backward compatible).
|
||||
cwdCert, cwdErr := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if cwdErr == nil {
|
||||
log.Debug("loaded default certificate from current working directory (cert.pem / key.pem)")
|
||||
return &tls.Config{Certificates: []tls.Certificate{cwdCert}}, nil
|
||||
}
|
||||
return tls.X509KeyPair(rawCert, rawKey)
|
||||
|
||||
// 2. Try persisted certificate in defaultCertDir().
|
||||
dir := defaultCertDir()
|
||||
certFile := filepath.Join(dir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(dir, "auto-ca-key.pem")
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err == nil {
|
||||
log.Debugf("loaded persisted default certificate from %s", dir)
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
log.Debug("generating new default certificate (no persisted certificate found)")
|
||||
|
||||
// 3. Generate a new certificate.
|
||||
rawCert, rawKey, err := generateKeyPair(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Best-effort persist to disk. If it fails the in-memory certificate is
|
||||
// still usable.
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Warnf("failed to create certificate directory %s: %v", dir, err)
|
||||
} else {
|
||||
if err := os.WriteFile(certFile, rawCert, 0644); err != nil {
|
||||
log.Warnf("failed to persist certificate: %v", err)
|
||||
} else if err := os.WriteFile(keyFile, rawKey, 0600); err != nil {
|
||||
log.Warnf("failed to persist private key: %v", err)
|
||||
} else {
|
||||
log.Debugf("persisted default certificate to %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
cert, err = tls.X509KeyPair(rawCert, rawKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, rawKey []byte, err error) {
|
||||
@@ -124,6 +199,7 @@ func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, ra
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
if _, isRSA := priv.(*rsa.PrivateKey); isRSA {
|
||||
template.KeyUsage |= x509.KeyUsageKeyEncipherment
|
||||
|
||||
+181
-9
@@ -2,15 +2,58 @@ package parsing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// writeTestCertKey writes a self-signed cert/key pair to the given file paths.
|
||||
// The cert is NOT persisted via the defaultCertDir mechanism — it simulates
|
||||
// user-provided cert.pem / key.pem in a working directory.
|
||||
func writeTestCertKey(t *testing.T, certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "cwd-test.local"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(certFile, certPEM, 0644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
@@ -47,6 +90,10 @@ func TestDefaultTLSConfig_InitiallyNil(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
|
||||
// Use a temp dir so the test is isolated and doesn't touch $HOME/.gost
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
// BuildDefaultTLSConfig with nil should generate a self-signed cert
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
@@ -58,9 +105,22 @@ func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Verify that the certificate and key files were persisted.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg := &config.TLSConfig{
|
||||
Validity: 0, // uses default
|
||||
Organization: "TestOrg",
|
||||
@@ -78,26 +138,138 @@ func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_TwoCallsDifferentCerts(t *testing.T) {
|
||||
func TestBuildDefaultTLSConfig_TwoCallsSameCerts(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
if len(cfg1.Certificates) == 0 || len(cfg2.Certificates) == 0 {
|
||||
t.Fatal("expected certificates in both configs")
|
||||
}
|
||||
|
||||
// Each call generates a random cert; they should have different raw bytes.
|
||||
if len(cfg1.Certificates[0].Certificate) == 0 || len(cfg2.Certificates[0].Certificate) == 0 {
|
||||
t.Fatal("expected non-empty certificate raw bytes")
|
||||
}
|
||||
// Both calls should return the SAME persisted certificate.
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected different certificates from two calls")
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate from two calls (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_PersistsAndReloads(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// First call generates and persists.
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
|
||||
// Verify files exist on disk.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted after first call")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted after first call")
|
||||
}
|
||||
|
||||
// Second call loads the persisted cert.
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate on reload (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_FallsBackWhenWriteBlocked(t *testing.T) {
|
||||
// Point to a directory that can't be created (e.g. a file path).
|
||||
// We use a path under /proc on Linux which is a read-only filesystem.
|
||||
testDefaultCertDir = "/proc/doesnotexist/certdir"
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (should fall back to in-memory): %v", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("expected non-nil config even when write fails")
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate from fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDefaultTLSConfig_CWDTakesPriority verifies that when cert.pem and
|
||||
// key.pem exist in the current working directory, they take precedence over
|
||||
// both the persisted certificate and auto-generation.
|
||||
func TestBuildDefaultTLSConfig_CWDTakesPriority(t *testing.T) {
|
||||
// Create a temp dir with cert.pem / key.pem.
|
||||
cwd := t.TempDir()
|
||||
writeTestCertKey(t, filepath.Join(cwd, "cert.pem"), filepath.Join(cwd, "key.pem"))
|
||||
|
||||
// Also set up a persisted cert directory.
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// Pre-populate persisted cert so we can prove CWD wins.
|
||||
_, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pre-populate persisted cert: %v", err)
|
||||
}
|
||||
|
||||
// Change to the CWD that has cert.pem / key.pem.
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(cwd); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
// BuildDefaultTLSConfig should load CWD cert, not the persisted one.
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Load CWD cert directly and compare.
|
||||
cwdCert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if err != nil {
|
||||
t.Fatalf("load CWD cert: %v", err)
|
||||
}
|
||||
got := tlsCfg.Certificates[0].Certificate[0]
|
||||
want := cwdCert.Certificate[0]
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("CWD certificate was not used (persisted or auto-generated cert took priority)")
|
||||
}
|
||||
|
||||
// Load persisted cert to prove it's different from CWD cert.
|
||||
persistedCert, err := tls.LoadX509KeyPair(
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-cert.pem"),
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-key.pem"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted cert: %v", err)
|
||||
}
|
||||
if bytes.Equal(got, persistedCert.Certificate[0]) {
|
||||
t.Fatal("CWD cert should differ from persisted cert; test setup issue")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,11 @@ func (c *relayConnector) bind(conn net.Conn, cmd relay.CmdType, network, address
|
||||
})
|
||||
fa := &relay.AddrFeature{}
|
||||
if network != "unix" {
|
||||
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
}
|
||||
if address == "" {
|
||||
address = "0.0.0.0:0"
|
||||
} else if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
}
|
||||
}
|
||||
fa.ParseFrom(address)
|
||||
req.Features = append(req.Features, fa)
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -12,7 +13,9 @@ type quicSession struct {
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
|
||||
d.sessionMutex.Lock()
|
||||
defer d.sessionMutex.Unlock()
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
@@ -95,6 +94,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
pc, err = icmp_pkg.ListenPacket("ip4:icmp", "")
|
||||
}
|
||||
if err != nil {
|
||||
d.sessionMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,16 +109,22 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
if err != nil {
|
||||
d.logger.Error(err)
|
||||
pc.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.sessions[addr] = session
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
delete(d.sessions, addr)
|
||||
}
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -14,14 +14,21 @@ type metadata struct {
|
||||
}
|
||||
|
||||
func (d *icmpDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
if mdutil.GetBool(md, "keepalive") {
|
||||
d.md.keepAlivePeriod = mdutil.GetDuration(md, "ttl")
|
||||
const (
|
||||
keepAlive = "keepAlive"
|
||||
keepAlivePeriod = "ttl"
|
||||
handshakeTimeout = "handshakeTimeout"
|
||||
maxIdleTimeout = "maxIdleTimeout"
|
||||
)
|
||||
|
||||
if md == nil || !md.IsExists(keepAlive) || mdutil.GetBool(md, keepAlive) {
|
||||
d.md.keepAlivePeriod = mdutil.GetDuration(md, keepAlivePeriod)
|
||||
if d.md.keepAlivePeriod <= 0 {
|
||||
d.md.keepAlivePeriod = 10 * time.Second
|
||||
}
|
||||
}
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, "handshakeTimeout")
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, "maxIdleTimeout")
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -12,7 +13,9 @@ type quicSession struct {
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
|
||||
d.sessionMutex.Lock()
|
||||
defer d.sessionMutex.Unlock()
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
@@ -73,11 +72,13 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
|
||||
c, err := options.Dialer.Dial(ctx, "udp", "")
|
||||
if err != nil {
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
pc, ok := c.(net.PacketConn)
|
||||
if !ok {
|
||||
c.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, errors.New("quic: wrong connection type")
|
||||
}
|
||||
|
||||
@@ -89,16 +90,22 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
if err != nil {
|
||||
d.logger.Error(err)
|
||||
pc.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.sessions[addr] = session
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
delete(d.sessions, addr)
|
||||
}
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,18 @@ func (d *sshdDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
delete(d.sessions, addr) // session is dead
|
||||
ok = false
|
||||
}
|
||||
if ok {
|
||||
// Health check: verify the cached session is still alive before
|
||||
// returning it. A dead-but-not-yet-detected session (e.g. idle
|
||||
// connection silently dropped by NAT) would cause "ssh: unexpected
|
||||
// packet in response to channel open" later in the connector.
|
||||
if err := session.Ping(d.md.keepaliveTimeout); err != nil {
|
||||
d.options.Logger.Debugf("sshd session ping failed: %v, recreating", err)
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
var options dialer.DialOptions
|
||||
for _, opt := range opts {
|
||||
|
||||
@@ -4,15 +4,16 @@ go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
|
||||
github.com/andybalholm/brotli v1.0.6
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-gost/core v0.4.1
|
||||
github.com/go-gost/core v0.5.2
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.3
|
||||
github.com/go-gost/gosocks4 v0.1.0
|
||||
github.com/go-gost/gosocks5 v0.5.0
|
||||
github.com/go-gost/plugin v0.3.0
|
||||
github.com/go-gost/relay v0.6.0
|
||||
github.com/go-gost/plugin v0.4.0
|
||||
github.com/go-gost/relay v0.6.1
|
||||
github.com/go-gost/tls-dissector v0.2.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/gobwas/glob v0.2.3
|
||||
@@ -20,6 +21,7 @@ require (
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/klauspost/compress v1.17.4
|
||||
github.com/miekg/dns v1.1.61
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
@@ -31,6 +33,7 @@ require (
|
||||
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/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -58,9 +61,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -51,24 +51,28 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-gost/core v0.4.1 h1:/MZYv0tSkw9yoK2nl0GLl3GjT1/3qKl5u3E/DADyeZE=
|
||||
github.com/go-gost/core v0.4.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
|
||||
github.com/go-gost/core v0.5.1 h1:HbIn3naEOC661Z4SwzaSYUzffWSRbU0P6gkxiHmuG0I=
|
||||
github.com/go-gost/core v0.5.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
|
||||
github.com/go-gost/core v0.5.2 h1:TofTjnvOkga3IO625qTcUWukhR80cwBXI1RSkoOU0p4=
|
||||
github.com/go-gost/core v0.5.2/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.3 h1:6CUZLp+mTWXnKP2aK8/Z9ZP+ERMX9gSbywmPu4kGX/A=
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.3/go.mod h1:866zFNNI3He6Wef1M/IvAjTal74WhcfKfBgRpTlkKys=
|
||||
github.com/go-gost/gosocks4 v0.1.0 h1:eAzev6qw4fzkFQKC9uCHLVNnnPdHyqCggbnfNN80Pmk=
|
||||
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/go.mod h1:1G6I7HP7VFVxveGkoK8mnprnJqSqJjdcASKsdUn4Pp4=
|
||||
github.com/go-gost/plugin v0.3.0 h1:pmll8nNd9PX92BWMB5+b2y2SEkBAWJLxD2ANfX+WHuw=
|
||||
github.com/go-gost/plugin v0.3.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
|
||||
github.com/go-gost/relay v0.6.0 h1:o8r0B3eTw8Rwwent+5x5Cw1g4AyNUl4hNqiyDJXkFvA=
|
||||
github.com/go-gost/relay v0.6.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
|
||||
github.com/go-gost/plugin v0.4.0 h1:M7MR5PL7QAFCrdWfcXVX+5iLNTVIZlhl8FfdV/K8dZY=
|
||||
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/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
|
||||
github.com/go-gost/tls-dissector v0.2.0 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc1SicVg=
|
||||
github.com/go-gost/tls-dissector v0.2.0/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -106,6 +110,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -139,6 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -177,6 +184,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@@ -206,6 +215,12 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28=
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
@@ -246,6 +261,10 @@ github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbH
|
||||
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -269,6 +288,8 @@ github.com/xtaci/tcpraw v1.2.25 h1:VDlqo0op17JeXBM6e2G9ocCNLOJcw9mZbobMbJjo0vk=
|
||||
github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1GgJQbLk=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@@ -333,13 +354,17 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
|
||||
+62
-7
@@ -2,6 +2,7 @@ package auto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
@@ -12,7 +13,10 @@ import (
|
||||
"github.com/go-gost/gosocks4"
|
||||
"github.com/go-gost/gosocks5"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -94,6 +98,13 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
|
||||
// Peek 1 byte first to detect small protocols (SOCKS4/SOCKS5)
|
||||
// that send fewer bytes than the 5-byte TLS record header that
|
||||
// Sniff() needs. If we called Sniff() first, its Peek(5) would
|
||||
// block indefinitely on a SOCKS5 greeting (3 bytes: VER, NMETHODS,
|
||||
// METHODS), deadlocking with the client that is itself waiting for
|
||||
// the server's method-selection reply.
|
||||
b, err := br.Peek(1)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
@@ -101,20 +112,64 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
return err
|
||||
}
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
switch b[0] {
|
||||
case gosocks4.Ver4: // socks4
|
||||
case gosocks4.Ver4:
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
if h.socks4Handler != nil {
|
||||
return h.socks4Handler.Handle(ctx, conn)
|
||||
}
|
||||
case gosocks5.Ver5: // socks5
|
||||
return nil
|
||||
case gosocks5.Ver5:
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
if h.socks5Handler != nil {
|
||||
return h.socks5Handler.Handle(ctx, conn)
|
||||
}
|
||||
default: // http
|
||||
if h.httpHandler != nil {
|
||||
return h.httpHandler.Handle(ctx, conn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Not SOCKS — sniff for TLS, HTTP, or SSH.
|
||||
proto, _ := sniffing.Sniff(ctx, br)
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
|
||||
if proto == sniffing.ProtoTLS {
|
||||
return h.handleTLS(ctx, conn, log)
|
||||
}
|
||||
|
||||
// Default to HTTP.
|
||||
if h.httpHandler != nil {
|
||||
return h.httpHandler.Handle(ctx, conn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTLS forwards a TLS connection that arrived at the auto handler.
|
||||
// It parses the ClientHello for SNI, dials upstream through the router,
|
||||
// forwards the ClientHello, records ServerHello metadata, and pipes data
|
||||
// bidirectionally.
|
||||
func (h *autoHandler) handleTLS(ctx context.Context, conn net.Conn, log logger.Logger) error {
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
Network: "tcp",
|
||||
Service: h.options.Service,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
LocalAddr: conn.LocalAddr().String(),
|
||||
SID: ctxvalue.SidFromContext(ctx).String(),
|
||||
Time: time.Now(),
|
||||
}
|
||||
sniffer := &sniffing.Sniffer{}
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ro.Route = buf.String()
|
||||
return cc, nil
|
||||
}
|
||||
return sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/limiter"
|
||||
"github.com/go-gost/core/recorder"
|
||||
stats "github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/logger"
|
||||
stats "github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
@@ -132,6 +132,7 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -140,6 +141,7 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -233,7 +235,7 @@ type SnifferBuilder struct {
|
||||
// ReadTimeout is the timeout for reading upstream HTTP response headers
|
||||
// and TLS ServerHello during sniffing. Passed through to sniffing.Sniffer.
|
||||
// See sniffing.Sniffer.ReadTimeout for details.
|
||||
ReadTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
}
|
||||
|
||||
// Build creates a new sniffing.Sniffer from the builder's configuration.
|
||||
|
||||
+17
-14
@@ -445,20 +445,9 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt
|
||||
ro.HTTP.Response.Header = resp.Header
|
||||
}()
|
||||
|
||||
// HTTP/2 connection preface "PRI * HTTP/2.0" is rejected, as are
|
||||
// non-CONNECT requests without an http:// scheme.
|
||||
if req.Method == "PRI" ||
|
||||
(req.Method != http.MethodConnect && req.URL.Scheme != "http") {
|
||||
resp.StatusCode = http.StatusBadRequest
|
||||
|
||||
if log.IsLevelEnabled(logger.TraceLevel) {
|
||||
dump, _ := httputil.DumpResponse(resp, false)
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
return resp.Write(conn)
|
||||
}
|
||||
|
||||
// Authenticate before request validation so that probe resistance
|
||||
// can intercept non-proxy-form requests (e.g., browser/scanner probes
|
||||
// that send "GET / HTTP/1.1" instead of proxy-form URLs).
|
||||
result := h.auth.Authenticate(ctx, req)
|
||||
if !result.OK {
|
||||
if result.PipeTo != "" {
|
||||
@@ -477,6 +466,20 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
|
||||
// HTTP/2 connection preface "PRI * HTTP/2.0" is rejected, as are
|
||||
// non-CONNECT requests without an http:// scheme.
|
||||
if req.Method == "PRI" ||
|
||||
(req.Method != http.MethodConnect && req.URL.Scheme != "http") {
|
||||
resp.StatusCode = http.StatusBadRequest
|
||||
|
||||
if log.IsLevelEnabled(logger.TraceLevel) {
|
||||
dump, _ := httputil.DumpResponse(resp, false)
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
return resp.Write(conn)
|
||||
}
|
||||
|
||||
log = log.WithFields(map[string]any{"clientID": result.ClientID})
|
||||
ro.ClientID = result.ClientID
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/logger"
|
||||
stats "github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/resolver"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
"github.com/go-gost/x/internal/net/udp"
|
||||
"github.com/go-gost/x/internal/util/socks"
|
||||
@@ -93,6 +97,16 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, clientID str
|
||||
return err
|
||||
}
|
||||
|
||||
// Wrap raw *net.UDPConn so domainAddr from socks.UDPTunServerConn.ReadFrom
|
||||
// are resolved through the configured resolver instead of failing WriteTo.
|
||||
if _, ok := pc.(*net.UDPConn); ok {
|
||||
pc = &resolvePacketConn{
|
||||
PacketConn: pc,
|
||||
resolver: h.options.Router.Options().Resolver,
|
||||
hostMapper: h.options.Router.Options().HostMapper,
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the HTTP connection as a SOCKS5 UDP tunnel server conn so
|
||||
// that the relay can read/write SOCKS5-encapsulated UDP datagrams.
|
||||
relay := udp.NewRelay(socks.UDPTunServerConn(conn), pc).
|
||||
@@ -110,3 +124,45 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, clientID str
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePacketConn wraps a net.PacketConn and resolves domain addresses
|
||||
// in WriteTo calls through hostMapper → configured resolver → system DNS.
|
||||
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("http 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})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *metricsHandler) Init(md md.Metadata) (err error) {
|
||||
|
||||
xmetrics.Enable(true)
|
||||
|
||||
h.handler = promhttp.Handler()
|
||||
h.handler = promhttp.HandlerFor(xmetrics.Registry(), promhttp.HandlerOpts{})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(h.md.path, http.HandlerFunc(h.handleFunc))
|
||||
|
||||
+67
-1
@@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/limiter"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/relay"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
@@ -261,8 +264,25 @@ func (h *relayHandler) bindUDP(ctx context.Context, conn net.Conn, network, addr
|
||||
})
|
||||
ro.SrcAddr = pc.LocalAddr().String()
|
||||
|
||||
// lc.ListenPacket returns a raw *net.UDPConn which can't resolve
|
||||
// domainAddr returned by UDPTunServerConn.ReadFrom for domain targets.
|
||||
// Wrap it so domains resolve through hostMapper → configured resolver
|
||||
// → system DNS instead of failing WriteTo with an unknown addr type.
|
||||
if _, ok := pc.(*net.UDPConn); ok {
|
||||
var r resolver.Resolver
|
||||
var hm hosts.HostMapper
|
||||
if h.options.Router != nil {
|
||||
r = h.options.Router.Options().Resolver
|
||||
hm = h.options.Router.Options().HostMapper
|
||||
}
|
||||
pc = &resolvePacketConn{
|
||||
PacketConn: pc,
|
||||
resolver: r,
|
||||
hostMapper: hm,
|
||||
}
|
||||
}
|
||||
|
||||
pc = metrics.WrapPacketConn(serviceName, pc)
|
||||
// pc = admission.WrapPacketConn(l.options.Admission, pc)
|
||||
// pc = limiter.WrapPacketConn(l.options.TrafficLimiter, pc)
|
||||
|
||||
defer pc.Close()
|
||||
@@ -294,3 +314,49 @@ func (h *relayHandler) bindUDP(ctx context.Context, conn net.Conn, network, addr
|
||||
}).Debugf("%s >-< %s", conn.RemoteAddr(), pc.LocalAddr())
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePacketConn wraps a net.PacketConn and resolves domain addresses
|
||||
// in WriteTo calls through hostMapper → configured resolver → system DNS.
|
||||
//
|
||||
// After udpTunConn.ReadFrom returns a domainAddr for ATYP=DOMAINNAME
|
||||
// datagrams, a raw *net.UDPConn cannot consume it (WriteTo needs *net.UDPAddr).
|
||||
// This wrapper resolves the domain before forwarding.
|
||||
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("relay 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})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/relay"
|
||||
)
|
||||
|
||||
@@ -107,4 +111,86 @@ func TestHandleBind_UDP(t *testing.T) {
|
||||
if resp.Status != relay.StatusOK {
|
||||
t.Errorf("Status = %d, want %d", resp.Status, relay.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvePacketConn tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type testPacketConn struct {
|
||||
net.PacketConn
|
||||
lastAddr net.Addr
|
||||
lastData []byte
|
||||
}
|
||||
|
||||
func (c *testPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
c.lastAddr = addr
|
||||
c.lastData = make([]byte, len(b))
|
||||
copy(c.lastData, b)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
type stubResolver struct {
|
||||
ips []net.IP
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubResolver) Resolve(ctx context.Context, network, host string, opts ...resolver.Option) ([]net.IP, error) {
|
||||
return r.ips, r.err
|
||||
}
|
||||
|
||||
type stubHostMapper struct{}
|
||||
|
||||
func (m *stubHostMapper) Lookup(ctx context.Context, network, host string, opts ...hosts.Option) ([]net.IP, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type testDomainAddr struct{}
|
||||
|
||||
func (a *testDomainAddr) Network() string { return "udp" }
|
||||
func (a *testDomainAddr) String() string { return "dns.google:53" }
|
||||
|
||||
func Test_resolvePacketConn_domain(t *testing.T) {
|
||||
r := &resolvePacketConn{
|
||||
PacketConn: &testPacketConn{},
|
||||
resolver: &stubResolver{ips: []net.IP{net.ParseIP("1.2.3.4")}},
|
||||
hostMapper: &stubHostMapper{},
|
||||
}
|
||||
|
||||
_, err := r.WriteTo([]byte("data"), &testDomainAddr{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tpc := r.PacketConn.(*testPacketConn)
|
||||
udpAddr, ok := tpc.lastAddr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("expected *net.UDPAddr, got %T", tpc.lastAddr)
|
||||
}
|
||||
if !udpAddr.IP.Equal(net.ParseIP("1.2.3.4")) || udpAddr.Port != 53 {
|
||||
t.Fatalf("expected 1.2.3.4:53, got %v", udpAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_resolvePacketConn_ipPassthrough(t *testing.T) {
|
||||
r := &resolvePacketConn{
|
||||
PacketConn: &testPacketConn{},
|
||||
resolver: &stubResolver{err: errors.New("should not be called")},
|
||||
hostMapper: &stubHostMapper{},
|
||||
}
|
||||
|
||||
ipAddr := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 53}
|
||||
_, err := r.WriteTo([]byte("data"), ipAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tpc := r.PacketConn.(*testPacketConn)
|
||||
udpAddr, ok := tpc.lastAddr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("expected *net.UDPAddr, got %T", tpc.lastAddr)
|
||||
}
|
||||
if !udpAddr.IP.Equal(net.ParseIP("8.8.8.8")) || udpAddr.Port != 53 {
|
||||
t.Fatalf("expected 8.8.8.8:53, got %v", udpAddr)
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@ func (h *relayHandler) handleConnect(ctx context.Context, conn net.Conn, network
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -245,6 +246,7 @@ func (h *relayHandler) handleConnect(ctx context.Context, conn net.Conn, network
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -306,4 +308,4 @@ func (a *serialAddr) Network() string {
|
||||
|
||||
func (a *serialAddr) String() string {
|
||||
return a.port
|
||||
}
|
||||
}
|
||||
|
||||
+79
-89
@@ -1,11 +1,47 @@
|
||||
// Package sni implements a TLS SNI-based forwarding handler for protocol-aware
|
||||
// routing. It sniffs every inbound TCP connection, parses the TLS ClientHello
|
||||
// (or HTTP request header) to extract the server name, and routes traffic
|
||||
// through the proxy chain.
|
||||
//
|
||||
// # How it differs from the "tcp" / "forward" handler
|
||||
//
|
||||
// The SNI handler is purpose-built for TLS SNI routing — it always sniffs
|
||||
// and only handles HTTP and TLS traffic:
|
||||
//
|
||||
// - Non-HTTP/non-TLS connections are silently dropped (no raw forwarding
|
||||
// fallback). If you need raw TCP fallback, use the "tcp" or "forward"
|
||||
// handler instead.
|
||||
// - It does not implement [handler.Forwarder], so it has no hop-based node
|
||||
// selection, load balancing, or per-node authentication/rewrite settings.
|
||||
// All routing uses the SNI hostname as the destination address through
|
||||
// the configured chain.
|
||||
//
|
||||
// # Connection processing flow
|
||||
//
|
||||
// Each inbound net.Conn is processed by Handle:
|
||||
//
|
||||
// Handle()
|
||||
// ├─ newRecorderObject (session metadata: service, SID, addresses)
|
||||
// ├─ checkRateLimit (connection rate limiter, if configured)
|
||||
// ├─ Router nil check → errRouterNotAvailable
|
||||
// ├─ sniffing.Sniff (peek buffer, detect protocol)
|
||||
// │ └─ handleSniffedProtocol()
|
||||
// │ ├─ sniffing.ProtoHTTP → SnifferBuilder.Build().HandleHTTP()
|
||||
// │ ├─ sniffing.ProtoTLS → SnifferBuilder.Build().HandleTLS()
|
||||
// │ └─ default → silent drop
|
||||
// └─ unrecognised protocol → silently returns nil
|
||||
//
|
||||
// # SnifferBuilder pattern
|
||||
//
|
||||
// The sniffer configuration (MITM certs, WebSocket recording, bypass rules)
|
||||
// is immutable after Init. A [SnifferBuilder] is populated once during Init
|
||||
// and reused via [SnifferBuilder.Build] to create per-connection
|
||||
// [sniffing.Sniffer] instances.
|
||||
package sni
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
@@ -13,8 +49,6 @@ import (
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
@@ -29,26 +63,32 @@ func init() {
|
||||
registry.HandlerRegistry().Register("sni", NewHandler)
|
||||
}
|
||||
|
||||
// sniHandler is a protocol-aware forwarding handler that routes by TLS SNI
|
||||
// or HTTP Host header. It always sniffs the initial bytes to determine the
|
||||
// protocol and delegates to [sniffing.Sniffer] for HTTP/TLS handling.
|
||||
type sniHandler struct {
|
||||
md metadata
|
||||
options handler.Options
|
||||
recorder recorder.RecorderObject
|
||||
certPool tls_util.CertPool
|
||||
sniffer *SnifferBuilder
|
||||
}
|
||||
|
||||
// NewHandler creates an SNI handler with the given options.
|
||||
// The handler registers for the "sni" protocol.
|
||||
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||
options := handler.Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
h := &sniHandler{
|
||||
return &sniHandler{
|
||||
options: options,
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// Init parses metadata and initialises the sniffer builder and TLS certificate
|
||||
// pool. It implements [handler.Handler].
|
||||
func (h *sniHandler) Init(md md.Metadata) (err error) {
|
||||
if err = h.parseMetadata(md); err != nil {
|
||||
return
|
||||
@@ -65,26 +105,32 @@ func (h *sniHandler) Init(md md.Metadata) (err error) {
|
||||
h.certPool = tls_util.NewMemoryCertPool()
|
||||
}
|
||||
|
||||
h.sniffer = &SnifferBuilder{
|
||||
Websocket: h.md.sniffingWebsocket,
|
||||
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
|
||||
Recorder: h.recorder.Recorder,
|
||||
RecorderOptions: h.recorder.Options,
|
||||
Certificate: h.md.certificate,
|
||||
PrivateKey: h.md.privateKey,
|
||||
ALPN: h.md.alpn,
|
||||
CertPool: h.certPool,
|
||||
MitmBypass: h.md.mitmBypass,
|
||||
ReadTimeout: h.md.readTimeout,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle processes an inbound TCP connection. It always sniffs the initial
|
||||
// bytes to detect HTTP or TLS, then delegates to the protocol-specific
|
||||
// sniffer. Non-HTTP/non-TLS traffic is silently dropped.
|
||||
//
|
||||
// Handle implements [handler.Handler].
|
||||
func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
|
||||
defer conn.Close()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
Network: "tcp",
|
||||
Service: h.options.Service,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
LocalAddr: conn.LocalAddr().String(),
|
||||
SID: xctx.SidFromContext(ctx).String(),
|
||||
Time: start,
|
||||
}
|
||||
|
||||
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
||||
ro.ClientAddr = srcAddr.String()
|
||||
}
|
||||
ro := h.newRecorderObject(ctx, conn, start)
|
||||
|
||||
log := h.options.Logger.WithFields(map[string]any{
|
||||
"network": ro.Network,
|
||||
@@ -120,10 +166,17 @@ func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
|
||||
return rate_limiter.ErrRateLimit
|
||||
}
|
||||
|
||||
if h.options.Router == nil {
|
||||
err := errRouterNotAvailable
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if h.md.readTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Now().Add(h.md.readTimeout))
|
||||
defer conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
proto, sniffErr := sniffing.Sniff(ctx, br)
|
||||
if sniffErr != nil {
|
||||
@@ -131,75 +184,12 @@ func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
|
||||
}
|
||||
ro.Proto = proto
|
||||
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
if h.options.Router == nil {
|
||||
return nil, errors.New("sni: router not available")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
|
||||
ro.Route = buf.String()
|
||||
|
||||
if cc != nil {
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
}
|
||||
return cc, err
|
||||
}
|
||||
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
cc, err := dial(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cc = tls.Client(cc, cfg)
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
sniffer := &sniffing.Sniffer{
|
||||
Websocket: h.md.sniffingWebsocket,
|
||||
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
|
||||
Recorder: h.recorder.Recorder,
|
||||
RecorderOptions: h.recorder.Options,
|
||||
Certificate: h.md.certificate,
|
||||
PrivateKey: h.md.privateKey,
|
||||
NegotiatedProtocol: h.md.alpn,
|
||||
CertPool: h.certPool,
|
||||
MitmBypass: h.md.mitmBypass,
|
||||
ReadTimeout: h.md.readTimeout,
|
||||
}
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP:
|
||||
return sniffer.HandleHTTP(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
case sniffing.ProtoTLS:
|
||||
return sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
default:
|
||||
log.Debugf("unrecognized traffic from %s", conn.RemoteAddr())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *sniHandler) checkRateLimit(addr net.Addr) bool {
|
||||
if h.options.RateLimiter == nil {
|
||||
return true
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr.String())
|
||||
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
|
||||
return limiter.Allow(1)
|
||||
handled, sniffErr := h.handleSniffedProtocol(ctx, conn, ro, log, proto)
|
||||
if handled {
|
||||
return sniffErr
|
||||
}
|
||||
|
||||
return true
|
||||
log.Debugf("unrecognized traffic from %s", conn.RemoteAddr())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/limiter/rate"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewHandler(t *testing.T) {
|
||||
h := NewHandler(func(o *handler.Options) {
|
||||
o.Service = "test-svc"
|
||||
})
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil handler")
|
||||
}
|
||||
sh, ok := h.(*sniHandler)
|
||||
if !ok {
|
||||
t.Fatal("expected *sniHandler")
|
||||
}
|
||||
if sh.options.Service != "test-svc" {
|
||||
t.Errorf("expected Service 'test-svc', got %s", sh.options.Service)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestInit_Defaults(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
err := h.Init(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.readTimeout <= 0 {
|
||||
t.Error("expected positive default readTimeout")
|
||||
}
|
||||
if h.sniffer == nil {
|
||||
t.Error("expected sniffer to be initialised")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_SelectsRecorder(t *testing.T) {
|
||||
h := newTestHandler(withRecorder(recorder.RecorderObject{
|
||||
Record: xrecorder.RecorderServiceHandler,
|
||||
Recorder: &mockRecorder{},
|
||||
}))
|
||||
err := h.Init(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.recorder.Recorder == nil {
|
||||
t.Error("expected recorder to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_CertPool(t *testing.T) {
|
||||
cert, key := generateTestCertKey(t)
|
||||
h := newTestHandler()
|
||||
h.md.certificate = cert
|
||||
h.md.privateKey = key
|
||||
err := h.Init(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.certPool == nil {
|
||||
t.Error("expected certPool to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ParseMetadataError(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": "/nonexistent/cert.pem",
|
||||
"mitm.keyFile": "/nonexistent/key.pem",
|
||||
})
|
||||
err := h.Init(md)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Init with bad cert files")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handle — error paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandle_NoRouter(t *testing.T) {
|
||||
h := newTestHandler(withRouter(nil))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when router is nil")
|
||||
}
|
||||
if !errors.Is(err, errRouterNotAvailable) {
|
||||
t.Errorf("expected errRouterNotAvailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_RateLimited(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return false }}
|
||||
},
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handle — sniffing (protocol detection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandle_SniffError(t *testing.T) {
|
||||
// An empty connection causes sniffing.Sniff to return an error because
|
||||
// there are no bytes to peek. The handler should log the error and fall
|
||||
// through to the unrecognised path (silent drop).
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for unrecognised traffic, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_UnrecognizedProto(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
// Non-HTTP/non-TLS bytes won't match any sniffer protocol.
|
||||
conn := newStringConn([]byte("ssh-2.0-OpenSSH"))
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for unrecognised traffic, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_HTTP(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
n, err := client.Read(buf)
|
||||
if err == nil && n > 0 {
|
||||
client.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"))
|
||||
}
|
||||
}()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Logf("sniffed HTTP returned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_TLS(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte{
|
||||
0x16, 0x03, 0x01, 0x00, 0x06, 0x01, 0x00, 0x00, 0x02, 0x03, 0x01,
|
||||
})
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Log("sniffed TLS ClientHello without error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_RecorderError(t *testing.T) {
|
||||
// The deferred recorder error should not affect the return value.
|
||||
h := newTestHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}))
|
||||
h.recorder = recorder.RecorderObject{Recorder: &errorRecorder{}}
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil from unrecognised traffic, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_WithReadTimeout(t *testing.T) {
|
||||
h := newTestHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
}))
|
||||
h.md.readTimeout = 0 // zero disables the deadline
|
||||
h.recorder = recorder.RecorderObject{Recorder: &mockRecorder{}}
|
||||
conn := newStringConn([]byte("ssh"))
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_ConnClosed(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if !conn.closed {
|
||||
t.Error("expected connection to be closed after Handle returns")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/limiter/rate"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mockRouter implements chain.Router for unit tests.
|
||||
type mockRouter struct {
|
||||
opts *chain.RouterOptions
|
||||
dialFn func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
func (m *mockRouter) Options() *chain.RouterOptions { return m.opts }
|
||||
func (m *mockRouter) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (net.Conn, error) {
|
||||
if m.dialFn != nil {
|
||||
return m.dialFn(ctx, network, address)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRouter) Bind(ctx context.Context, network, address string, opts ...chain.BindOption) (net.Listener, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// stubRateLimiter implements rate.RateLimiter for tests.
|
||||
type stubRateLimiter struct {
|
||||
limiterFn func(key string) rate.Limiter
|
||||
}
|
||||
|
||||
func (m *stubRateLimiter) Limiter(key string) rate.Limiter {
|
||||
if m.limiterFn != nil {
|
||||
return m.limiterFn(key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubLimiter implements rate.Limiter for tests.
|
||||
type stubLimiter struct {
|
||||
allowFn func(n int) bool
|
||||
}
|
||||
|
||||
func (m *stubLimiter) Allow(n int) bool {
|
||||
if m.allowFn != nil {
|
||||
return m.allowFn(n)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *stubLimiter) Limit() float64 { return 0 }
|
||||
|
||||
// mockRecorder implements recorder.Recorder for tests.
|
||||
type mockRecorder struct{}
|
||||
|
||||
func (m *mockRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRecorder) Close() error { return nil }
|
||||
|
||||
// errorRecorder always returns an error on Record for testing error paths.
|
||||
type errorRecorder struct{}
|
||||
|
||||
func (e *errorRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
|
||||
return errors.New("record error")
|
||||
}
|
||||
func (e *errorRecorder) Close() error { return nil }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stringConn — a net.Conn backed by bytes.Buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// stringConn is a net.Conn backed by bytes.Buffer, useful for testing
|
||||
// handlers without real network I/O.
|
||||
type stringConn struct {
|
||||
readBuf *bytes.Buffer
|
||||
writeBuf *bytes.Buffer
|
||||
local net.Addr
|
||||
remote net.Addr
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newStringConn(data []byte) *stringConn {
|
||||
return &stringConn{
|
||||
readBuf: bytes.NewBuffer(data),
|
||||
writeBuf: new(bytes.Buffer),
|
||||
local: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080},
|
||||
remote: &net.TCPAddr{IP: net.IPv4(10, 0, 0, 1), Port: 12345},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stringConn) Read(b []byte) (int, error) { return c.readBuf.Read(b) }
|
||||
func (c *stringConn) Write(b []byte) (int, error) { return c.writeBuf.Write(b) }
|
||||
func (c *stringConn) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
func (c *stringConn) LocalAddr() net.Addr { return c.local }
|
||||
func (c *stringConn) RemoteAddr() net.Addr { return c.remote }
|
||||
func (c *stringConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (c *stringConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (c *stringConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func nopLog() logger.Logger { return xlogger.Nop() }
|
||||
|
||||
// newTestHandler creates an uninitialised sniHandler with defaults suitable
|
||||
// for testing. The caller is responsible for calling Init if metadata parsing
|
||||
// is needed.
|
||||
func newTestHandler(opts ...handler.Option) *sniHandler {
|
||||
options := handler.Options{
|
||||
Logger: nopLog(),
|
||||
Router: &mockRouter{opts: &chain.RouterOptions{}},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
return &sniHandler{
|
||||
options: options,
|
||||
sniffer: &SnifferBuilder{},
|
||||
}
|
||||
}
|
||||
|
||||
// newInitdHandler creates a fully initialised sniHandler with default
|
||||
// metadata. Use when the test does not depend on specific metadata values.
|
||||
func newInitdHandler(opts ...handler.Option) *sniHandler {
|
||||
h := newTestHandler(opts...)
|
||||
_ = h.Init(xmd.NewMetadata(nil))
|
||||
return h
|
||||
}
|
||||
|
||||
// withRateLimiter is a helper to set the RateLimiter option on a handler.
|
||||
func withRateLimiter(rl rate.RateLimiter) handler.Option {
|
||||
return func(o *handler.Options) {
|
||||
o.RateLimiter = rl
|
||||
}
|
||||
}
|
||||
|
||||
// withRouter is a helper to set the Router option on a handler.
|
||||
func withRouter(r chain.Router) handler.Option {
|
||||
return func(o *handler.Options) {
|
||||
o.Router = r
|
||||
}
|
||||
}
|
||||
|
||||
// withRecorder is a helper to append a RecorderObject to the Recorders slice.
|
||||
func withRecorder(ro recorder.RecorderObject) handler.Option {
|
||||
return func(o *handler.Options) {
|
||||
o.Recorders = append(o.Recorders, ro)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Certificate helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// generateTestCertKey creates a self-signed test certificate and private key.
|
||||
func generateTestCertKey(t *testing.T) (*x509.Certificate, crypto.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
return cert, key
|
||||
}
|
||||
|
||||
// generateTestCertPEM creates PEM-encoded test certificate and private key.
|
||||
func generateTestCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
cert, key := generateTestCertKey(t)
|
||||
certPEM = pemEncode("CERTIFICATE", cert.Raw)
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key.(*rsa.PrivateKey))
|
||||
keyPEM = pemEncode("RSA PRIVATE KEY", keyBytes)
|
||||
return
|
||||
}
|
||||
|
||||
func pemEncode(blockType string, der []byte) []byte {
|
||||
block := &pem.Block{Type: blockType, Bytes: der}
|
||||
return pem.EncodeToMemory(block)
|
||||
}
|
||||
|
||||
func writeTempFile(t *testing.T, pattern string, data []byte) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
@@ -16,7 +16,7 @@ type metadata struct {
|
||||
// readTimeout controls two behaviors:
|
||||
// 1. A read deadline set on the client connection before sniffing the
|
||||
// TLS ClientHello / HTTP request (see handler.go Handle).
|
||||
// 2. Passed to SnifferBuilder as the timeout for reading upstream
|
||||
// 2. Passed to [sniffing.Sniffer] as the timeout for reading upstream
|
||||
// response headers during HTTP/TLS sniffing.
|
||||
// 0 or negative defaults to 15s.
|
||||
readTimeout time.Duration
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMetadata — defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseMetadata_Defaults(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
|
||||
err := h.parseMetadata(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout <= 0 {
|
||||
t.Error("expected positive default readTimeout")
|
||||
}
|
||||
if h.md.sniffingWebsocket {
|
||||
t.Error("expected sniffingWebsocket disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMetadata — custom values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseMetadata_CustomValues(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"readTimeout": "60s",
|
||||
"hash": "host",
|
||||
"sniffing.websocket": true,
|
||||
"sniffing.websocket.sampleRate": 0.75,
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout != 60*time.Second {
|
||||
t.Errorf("expected 60s, got %v", h.md.readTimeout)
|
||||
}
|
||||
if h.md.hash != "host" {
|
||||
t.Errorf("expected hash 'host', got '%s'", h.md.hash)
|
||||
}
|
||||
if !h.md.sniffingWebsocket {
|
||||
t.Error("expected sniffingWebsocket true")
|
||||
}
|
||||
if h.md.sniffingWebsocketSampleRate != 0.75 {
|
||||
t.Errorf("expected 0.75, got %f", h.md.sniffingWebsocketSampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_ReadTimeoutDefault(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
// A non-positive value should fall back to the 15s default.
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"readTimeout": "0s",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout != 15*time.Second {
|
||||
t.Errorf("expected 15s default, got %v", h.md.readTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_ReadTimeoutNegative(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"readTimeout": "-5s",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout != 15*time.Second {
|
||||
t.Errorf("expected 15s default for negative value, got %v", h.md.readTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMetadata — MITM cert/key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseMetadata_MITMCertError(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": "/nonexistent/cert.pem",
|
||||
"mitm.keyFile": "/nonexistent/key.pem",
|
||||
})
|
||||
err := h.parseMetadata(md)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent cert file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMCertKeyMismatch(t *testing.T) {
|
||||
// Only certFile set, not keyFile — keys should not be loaded.
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": "/some/cert.pem",
|
||||
})
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.certificate != nil {
|
||||
t.Error("expected certificate to remain nil when keyFile not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMCert(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCertPEM(t)
|
||||
certFile := writeTempFile(t, "cert-*.pem", certPEM)
|
||||
defer os.Remove(certFile)
|
||||
keyFile := writeTempFile(t, "key-*.pem", keyPEM)
|
||||
defer os.Remove(keyFile)
|
||||
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": certFile,
|
||||
"mitm.keyFile": keyFile,
|
||||
"mitm.alpn": "h2",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.certificate == nil {
|
||||
t.Error("expected certificate to be loaded")
|
||||
}
|
||||
if h.md.privateKey == nil {
|
||||
t.Error("expected privateKey to be loaded")
|
||||
}
|
||||
if h.md.alpn != "h2" {
|
||||
t.Errorf("expected alpn 'h2', got %s", h.md.alpn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMCertCACertFile(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCertPEM(t)
|
||||
certFile := writeTempFile(t, "cert-*.pem", certPEM)
|
||||
defer os.Remove(certFile)
|
||||
keyFile := writeTempFile(t, "key-*.pem", keyPEM)
|
||||
defer os.Remove(keyFile)
|
||||
|
||||
// Use the alternate "caCertFile"/"caKeyFile" key names.
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.caCertFile": certFile,
|
||||
"mitm.caKeyFile": keyFile,
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.certificate == nil {
|
||||
t.Error("expected certificate to be loaded via caCertFile")
|
||||
}
|
||||
if h.md.privateKey == nil {
|
||||
t.Error("expected privateKey to be loaded via caKeyFile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMBypass(t *testing.T) {
|
||||
// Verify that mitm.bypass is parsed without error even when the named
|
||||
// bypass does not exist. The exact value depends on the global registry
|
||||
// state (other packages may have registered bypasses via init()).
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.bypass": "nonexistent-1769845",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// SnifferBuilder holds configuration for creating per-connection protocol
|
||||
// sniffers. It is populated once during Init and reused via [SnifferBuilder.Build]
|
||||
// for each connection to avoid repeated allocation.
|
||||
type SnifferBuilder struct {
|
||||
Websocket bool
|
||||
WebsocketSampleRate float64
|
||||
Recorder recorder.Recorder
|
||||
RecorderOptions *recorder.Options
|
||||
Certificate *x509.Certificate
|
||||
PrivateKey crypto.PrivateKey
|
||||
ALPN string
|
||||
CertPool tls_util.CertPool
|
||||
MitmBypass bypass.Bypass
|
||||
// ReadTimeout is the timeout for reading upstream HTTP response headers
|
||||
// and TLS ServerHello during sniffing. Passed through to [sniffing.Sniffer].
|
||||
// See sniffing.Sniffer.ReadTimeout for details.
|
||||
ReadTimeout time.Duration
|
||||
}
|
||||
|
||||
// Build creates a new [sniffing.Sniffer] from the builder's configuration.
|
||||
// Each call produces a fresh instance, safe for per-connection use.
|
||||
func (b *SnifferBuilder) Build() *sniffing.Sniffer {
|
||||
return &sniffing.Sniffer{
|
||||
Websocket: b.Websocket,
|
||||
WebsocketSampleRate: b.WebsocketSampleRate,
|
||||
Recorder: b.Recorder,
|
||||
RecorderOptions: b.RecorderOptions,
|
||||
Certificate: b.Certificate,
|
||||
PrivateKey: b.PrivateKey,
|
||||
NegotiatedProtocol: b.ALPN,
|
||||
CertPool: b.CertPool,
|
||||
MitmBypass: b.MitmBypass,
|
||||
ReadTimeout: b.ReadTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// sniffingDial creates a dial function for the sniffing branch that wraps
|
||||
// [chain.Router.Dial] with context-based hash selection, route recording,
|
||||
// and source/destination address population for the recorder.
|
||||
func (h *sniHandler) sniffingDial(ctx context.Context, network, address string, ro *xrecorder.HandlerRecorderObject) (net.Conn, error) {
|
||||
switch h.md.hash {
|
||||
case "host":
|
||||
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: address})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
|
||||
ro.Route = buf.String()
|
||||
|
||||
if cc != nil {
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
}
|
||||
return cc, err
|
||||
}
|
||||
|
||||
// sniffingDialTLS creates a TLS-aware dial function for the sniffing branch.
|
||||
// It dials via [sniffingDial] and wraps the connection with [tls.Client].
|
||||
func (h *sniHandler) sniffingDialTLS(ctx context.Context, network, address string, ro *xrecorder.HandlerRecorderObject, cfg *tls.Config) (net.Conn, error) {
|
||||
cc, err := h.sniffingDial(ctx, network, address, ro)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tls.Client(cc, cfg), nil
|
||||
}
|
||||
|
||||
// handleSniffedProtocol dispatches a sniffed connection to the
|
||||
// protocol-specific sniffer ([sniffing.Sniffer.HandleHTTP] or
|
||||
// [sniffing.Sniffer.HandleTLS]).
|
||||
//
|
||||
// It returns (true, err) when the protocol was handled and (false, nil)
|
||||
// when the protocol is unrecognised and the caller should handle the
|
||||
// connection directly (which for the SNI handler means silently dropping it).
|
||||
func (h *sniHandler) handleSniffedProtocol(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, proto string) (handled bool, err error) {
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS:
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return h.sniffingDial(ctx, network, address, ro)
|
||||
}
|
||||
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
return h.sniffingDialTLS(ctx, network, address, ro, cfg)
|
||||
}
|
||||
sniffer := h.sniffer.Build()
|
||||
if proto == sniffing.ProtoHTTP {
|
||||
return true, sniffer.HandleHTTP(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sniffingDial
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSniffingDial_Success(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
cc, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
if ro.Route == "" {
|
||||
t.Log("route is empty (mock does not populate context buffer)")
|
||||
}
|
||||
if ro.SrcAddr != "" {
|
||||
t.Log("src addr is set")
|
||||
}
|
||||
if ro.DstAddr != "" {
|
||||
t.Log("dst addr is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffingDial_RouterError(t *testing.T) {
|
||||
expectedErr := errors.New("dial failed")
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, expectedErr
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
_, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, expectedErr) {
|
||||
t.Errorf("expected %v, got %v", expectedErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffingDial_HashHost(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newTestHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
h.md.hash = "host"
|
||||
h.recorder = recorder.RecorderObject{Recorder: &mockRecorder{}}
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
cc, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sniffingDialTLS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSniffingDialTLS_Success(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
// sniffingDialTLS wraps the raw connection in tls.Client,
|
||||
// so the TLS handshake will likely fail against a raw net.Pipe,
|
||||
// but that's expected — we're testing the plumbing, not the TLS handshake.
|
||||
cc, err := h.sniffingDialTLS(context.Background(), "tcp", "example.com:443", ro, nil)
|
||||
if err == nil {
|
||||
defer cc.Close()
|
||||
}
|
||||
// Even with a nil tls.Config, tls.Client still attempts a handshake
|
||||
// which will fail against the pipe. We accept any outcome here
|
||||
// as long as sniffingDial was invoked correctly.
|
||||
}
|
||||
|
||||
func TestSniffingDialTLS_DialError(t *testing.T) {
|
||||
expectedErr := errors.New("dial failed")
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, expectedErr
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
_, err := h.sniffingDialTLS(context.Background(), "tcp", "example.com:443", ro, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failed dial")
|
||||
}
|
||||
if !errors.Is(err, expectedErr) {
|
||||
t.Errorf("expected %v, got %v", expectedErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SnifferBuilder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSnifferBuilder(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.sniffer.Websocket = true
|
||||
h.sniffer.WebsocketSampleRate = 0.5
|
||||
h.sniffer.ReadTimeout = 30 * time.Second
|
||||
|
||||
sniffer := h.sniffer.Build()
|
||||
|
||||
if !sniffer.Websocket {
|
||||
t.Error("expected Websocket to be true")
|
||||
}
|
||||
if sniffer.WebsocketSampleRate != 0.5 {
|
||||
t.Errorf("expected 0.5, got %f", sniffer.WebsocketSampleRate)
|
||||
}
|
||||
if sniffer.ReadTimeout != 30*time.Second {
|
||||
t.Errorf("expected 30s, got %v", sniffer.ReadTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnifferBuilder_ZeroValue(t *testing.T) {
|
||||
h := &sniHandler{sniffer: &SnifferBuilder{}}
|
||||
sniffer := h.sniffer.Build()
|
||||
|
||||
if sniffer == nil {
|
||||
t.Fatal("expected non-nil sniffer")
|
||||
}
|
||||
if sniffer.Websocket {
|
||||
t.Error("expected Websocket false by default")
|
||||
}
|
||||
if sniffer.WebsocketSampleRate != 0 {
|
||||
t.Errorf("expected 0, got %f", sniffer.WebsocketSampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleSniffedProtocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleSniffedProtocol_UnknownProto(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), "ssh")
|
||||
if handled {
|
||||
t.Error("expected handled=false for unknown proto")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSniffedProtocol_EmptyProto(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), "")
|
||||
if handled {
|
||||
t.Error("expected handled=false for empty proto")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSniffedProtocol_HTTP(t *testing.T) {
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("dial not configured in test")
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), sniffing.ProtoHTTP)
|
||||
|
||||
if !handled {
|
||||
t.Error("expected handled=true for HTTP proto")
|
||||
}
|
||||
if err == nil {
|
||||
t.Log("sniffer processed HTTP request without error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSniffedProtocol_TLS(t *testing.T) {
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("dial not configured in test")
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte{
|
||||
0x16, 0x03, 0x01, 0x00, 0x06, 0x01, 0x00, 0x00, 0x02, 0x03, 0x01,
|
||||
})
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), sniffing.ProtoTLS)
|
||||
|
||||
if !handled {
|
||||
t.Error("expected handled=true for TLS proto")
|
||||
}
|
||||
if err == nil {
|
||||
t.Log("sniffer processed TLS ClientHello without error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
var (
|
||||
errRouterNotAvailable = errors.New("sni: router not available")
|
||||
)
|
||||
|
||||
// newRecorderObject creates a HandlerRecorderObject populated with connection
|
||||
// metadata (service, addresses, network type, session ID, client address).
|
||||
func (h *sniHandler) newRecorderObject(ctx context.Context, conn net.Conn, start time.Time) *xrecorder.HandlerRecorderObject {
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
Service: h.options.Service,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
LocalAddr: conn.LocalAddr().String(),
|
||||
Network: "tcp",
|
||||
Time: start,
|
||||
SID: xctx.SidFromContext(ctx).String(),
|
||||
}
|
||||
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
||||
ro.ClientAddr = srcAddr.String()
|
||||
}
|
||||
return ro
|
||||
}
|
||||
|
||||
// checkRateLimit verifies that the remote address has not exceeded the
|
||||
// configured connection rate limit. Returns true if allowed, or if no rate
|
||||
// limiter is configured.
|
||||
func (h *sniHandler) checkRateLimit(addr net.Addr) bool {
|
||||
if h.options.RateLimiter == nil {
|
||||
return true
|
||||
}
|
||||
if addr == nil {
|
||||
return true
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr.String())
|
||||
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
|
||||
return limiter.Allow(1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/limiter/rate"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// newRecorderObject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewRecorderObject_TCP(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
start := time.Now()
|
||||
|
||||
ro := h.newRecorderObject(context.Background(), conn, start)
|
||||
|
||||
if ro.Network != "tcp" {
|
||||
t.Errorf("expected network tcp, got %s", ro.Network)
|
||||
}
|
||||
if ro.RemoteAddr != "10.0.0.1:12345" {
|
||||
t.Errorf("expected remote 10.0.0.1:12345, got %s", ro.RemoteAddr)
|
||||
}
|
||||
if ro.LocalAddr != "127.0.0.1:8080" {
|
||||
t.Errorf("expected local 127.0.0.1:8080, got %s", ro.LocalAddr)
|
||||
}
|
||||
if !ro.Time.Equal(start) {
|
||||
t.Errorf("expected time %v, got %v", start, ro.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_SID(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
ctx := xctx.ContextWithSid(context.Background(), xctx.Sid("test-sid"))
|
||||
|
||||
ro := h.newRecorderObject(ctx, conn, time.Now())
|
||||
|
||||
if ro.SID != "test-sid" {
|
||||
t.Errorf("expected SID 'test-sid', got '%s'", ro.SID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_NilSrcAddr(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
ro := h.newRecorderObject(context.Background(), conn, time.Now())
|
||||
|
||||
if ro.ClientAddr != "" {
|
||||
t.Errorf("expected empty ClientAddr, got %s", ro.ClientAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_SrcAddr(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
srcAddr := &net.TCPAddr{IP: net.IPv4(192, 168, 1, 1), Port: 54321}
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), srcAddr)
|
||||
|
||||
ro := h.newRecorderObject(ctx, conn, time.Now())
|
||||
|
||||
if ro.ClientAddr != "192.168.1.1:54321" {
|
||||
t.Errorf("expected ClientAddr '192.168.1.1:54321', got '%s'", ro.ClientAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_Service(t *testing.T) {
|
||||
h := newTestHandler(func(o *handler.Options) {
|
||||
o.Service = "my-service"
|
||||
})
|
||||
conn := newStringConn(nil)
|
||||
|
||||
ro := h.newRecorderObject(context.Background(), conn, time.Now())
|
||||
|
||||
if ro.Service != "my-service" {
|
||||
t.Errorf("expected Service 'my-service', got '%s'", ro.Service)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkRateLimit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckRateLimit_NilLimiter(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if !h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected true when RateLimiter is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_NilAddr(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return false }}
|
||||
},
|
||||
}))
|
||||
|
||||
if !h.checkRateLimit(nil) {
|
||||
t.Error("expected true when addr is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_Allowed(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return true }}
|
||||
},
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if !h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected true when limiter allows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_Blocked(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return false }}
|
||||
},
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected false when limiter blocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_NoLimiterForKey(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter { return nil },
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if !h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected true when no limiter found for key")
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,7 @@ func (h *socks4Handler) handleConnect(ctx context.Context, conn net.Conn, req *g
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -318,6 +319,7 @@ func (h *socks4Handler) handleConnect(ctx context.Context, conn net.Conn, req *g
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -82,7 +82,10 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
|
||||
resp := gosocks5.NewReply(gosocks5.Succeeded, nil)
|
||||
// Report the actual bound address (BND.ADDR) per RFC 1928 §6.
|
||||
bndAddr := gosocks5.Addr{}
|
||||
bndAddr.ParseFrom(cc.LocalAddr().String())
|
||||
resp := gosocks5.NewReply(gosocks5.Succeeded, &bndAddr)
|
||||
log.Trace(resp)
|
||||
if err := resp.Write(conn); err != nil {
|
||||
log.Error(err)
|
||||
@@ -128,6 +131,7 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -136,6 +140,7 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -100,6 +100,26 @@ func (h *socks5Handler) Init(md md.Metadata) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// networkAddr derives the network string from the SOCKS5 address type.
|
||||
// For IPv4 addresses it returns "tcp4"/"udp4" to prevent dual-stack
|
||||
// (IPv4-mapped-IPv6) socket creation on Linux, which would cause the
|
||||
// listener to bind on [::]:port instead of 0.0.0.0:port.
|
||||
// For IPv6 addresses it returns "tcp6"/"udp6".
|
||||
// For domain addresses it returns the unmodified network.
|
||||
func networkAddr(network string, addr *gosocks5.Addr) string {
|
||||
if addr == nil {
|
||||
return network
|
||||
}
|
||||
switch addr.Type {
|
||||
case gosocks5.AddrIPv4:
|
||||
return network + "4"
|
||||
case gosocks5.AddrIPv6:
|
||||
return network + "6"
|
||||
default:
|
||||
return network
|
||||
}
|
||||
}
|
||||
|
||||
func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -179,15 +199,15 @@ func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handl
|
||||
case gosocks5.CmdConnect:
|
||||
return h.handleConnect(ctx, conn, "tcp", address, ro, log)
|
||||
case gosocks5.CmdBind:
|
||||
return h.handleBind(ctx, conn, "tcp", address, ro, log)
|
||||
return h.handleBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
|
||||
case socks.CmdMuxBind:
|
||||
return h.handleMuxBind(ctx, conn, "tcp", address, ro, log)
|
||||
return h.handleMuxBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
|
||||
case gosocks5.CmdUdp:
|
||||
ro.Network = "udp"
|
||||
return h.handleUDP(ctx, conn, "udp", ro, log)
|
||||
case socks.CmdUDPTun:
|
||||
ro.Network = "udp"
|
||||
return h.handleUDPTun(ctx, conn, "udp", address, ro, log)
|
||||
return h.handleUDPTun(ctx, conn, networkAddr("udp", req.Addr), address, ro, log)
|
||||
default:
|
||||
err = ErrUnknownCmd
|
||||
log.Error(err)
|
||||
|
||||
@@ -26,6 +26,7 @@ type metadata struct {
|
||||
enableBind bool
|
||||
enableUDP bool
|
||||
udpBufferSize int
|
||||
udpResolveDomain bool
|
||||
compatibilityMode bool
|
||||
hash string
|
||||
muxCfg *mux.Config
|
||||
@@ -58,6 +59,7 @@ func (h *socks5Handler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
h.md.enableBind = mdutil.GetBool(md, "bind")
|
||||
h.md.enableUDP = mdutil.GetBool(md, "udp")
|
||||
h.md.udpBufferSize = mdutil.GetInt(md, "udp.bufferSize", "udpBufferSize")
|
||||
h.md.udpResolveDomain = mdutil.GetBool(md, "udp.resolveDomain", "udpResolveDomain")
|
||||
|
||||
h.md.compatibilityMode = mdutil.GetBool(md, "comp")
|
||||
h.md.hash = mdutil.GetString(md, "hash")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package v5
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/gosocks5"
|
||||
)
|
||||
|
||||
func TestNetworkAddr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
network string
|
||||
addr *gosocks5.Addr
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil addr returns unmodified",
|
||||
network: "tcp",
|
||||
addr: nil,
|
||||
want: "tcp",
|
||||
},
|
||||
{
|
||||
name: "IPv4 tcp returns tcp4",
|
||||
network: "tcp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
|
||||
want: "tcp4",
|
||||
},
|
||||
{
|
||||
name: "IPv6 tcp returns tcp6",
|
||||
network: "tcp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
|
||||
want: "tcp6",
|
||||
},
|
||||
{
|
||||
name: "IPv4 udp returns udp4",
|
||||
network: "udp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
|
||||
want: "udp4",
|
||||
},
|
||||
{
|
||||
name: "IPv6 udp returns udp6",
|
||||
network: "udp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
|
||||
want: "udp6",
|
||||
},
|
||||
{
|
||||
name: "domain addr returns unmodified",
|
||||
network: "tcp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrDomain},
|
||||
want: "tcp",
|
||||
},
|
||||
{
|
||||
name: "unknown addr type returns unmodified",
|
||||
network: "udp",
|
||||
addr: &gosocks5.Addr{Type: 99},
|
||||
want: "udp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := networkAddr(tt.network, tt.addr)
|
||||
if got != tt.want {
|
||||
t.Errorf("networkAddr(%q, %v) = %q, want %q", tt.network, tt.addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,25 +27,42 @@ func (selector *serverSelector) Methods() []uint8 {
|
||||
|
||||
func (s *serverSelector) Select(methods ...uint8) (method uint8) {
|
||||
s.logger.Debugf("%d %d %v", gosocks5.Ver5, len(methods), methods)
|
||||
method = gosocks5.MethodNoAuth
|
||||
|
||||
// Determine which methods the client offered.
|
||||
var hasNoAuth, hasUserPass, hasTLS, hasTLSAuth bool
|
||||
for _, m := range methods {
|
||||
if m == socks.MethodTLS && !s.noTLS && s.TLSConfig != nil {
|
||||
method = m
|
||||
break
|
||||
switch m {
|
||||
case gosocks5.MethodNoAuth:
|
||||
hasNoAuth = true
|
||||
case gosocks5.MethodUserPass:
|
||||
hasUserPass = true
|
||||
case socks.MethodTLS:
|
||||
hasTLS = true
|
||||
case socks.MethodTLSAuth:
|
||||
hasTLSAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
// when Authenticator is set, auth is mandatory
|
||||
// When Authenticator is set, authentication is mandatory.
|
||||
if s.Authenticator != nil {
|
||||
if method == gosocks5.MethodNoAuth {
|
||||
method = gosocks5.MethodUserPass
|
||||
if hasTLSAuth && !s.noTLS && s.TLSConfig != nil {
|
||||
return socks.MethodTLSAuth
|
||||
}
|
||||
if method == socks.MethodTLS && !s.noTLS {
|
||||
method = socks.MethodTLSAuth
|
||||
if hasUserPass {
|
||||
return gosocks5.MethodUserPass
|
||||
}
|
||||
return gosocks5.MethodNoAcceptable
|
||||
}
|
||||
|
||||
return
|
||||
// No authenticator — select the best unauthenticated method.
|
||||
if hasTLS && !s.noTLS && s.TLSConfig != nil {
|
||||
return socks.MethodTLS
|
||||
}
|
||||
if hasNoAuth {
|
||||
return gosocks5.MethodNoAuth
|
||||
}
|
||||
|
||||
return gosocks5.MethodNoAcceptable
|
||||
}
|
||||
|
||||
func (s *serverSelector) OnSelected(method uint8, conn net.Conn) (string, net.Conn, error) {
|
||||
|
||||
+179
-21
@@ -4,13 +4,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/limiter"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/gosocks5"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
@@ -65,6 +69,45 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
})
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
|
||||
// Verify the upstream chain is reachable before replying Success,
|
||||
// per RFC 1928 §7.
|
||||
var buf bytes.Buffer
|
||||
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), network, "") // UDP association
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
reply := gosocks5.NewReply(gosocks5.Failure, nil)
|
||||
log.Trace(reply)
|
||||
reply.Write(conn)
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
pc, ok := c.(net.PacketConn)
|
||||
if !ok {
|
||||
err := errors.New("socks5: wrong connection type")
|
||||
log.Error(err)
|
||||
reply := gosocks5.NewReply(gosocks5.Failure, nil)
|
||||
log.Trace(reply)
|
||||
reply.Write(conn)
|
||||
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.ParseFrom(cc.LocalAddr().String())
|
||||
|
||||
@@ -81,23 +124,6 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
}
|
||||
|
||||
log.Debugf("bind on %s OK", cc.LocalAddr())
|
||||
|
||||
// obtain a udp connection
|
||||
var buf bytes.Buffer
|
||||
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), network, "") // UDP association
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
pc, ok := c.(net.PacketConn)
|
||||
if !ok {
|
||||
err := errors.New("socks5: wrong connection type")
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
pc = metrics.WrapPacketConn(ro.Service, pc)
|
||||
|
||||
{
|
||||
@@ -129,12 +155,29 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
}
|
||||
}
|
||||
|
||||
r := udp.NewRelay(socks.UDPConn(
|
||||
tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
err := fmt.Errorf("socks5 udp: unexpected remote address type %T", conn.RemoteAddr())
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
pc1 := socks.UDPConn(
|
||||
&filteredPacketConn{
|
||||
PacketConn: cc,
|
||||
filterIP: conn.RemoteAddr().(*net.TCPAddr).IP,
|
||||
filterIP: tcpAddr.IP,
|
||||
},
|
||||
h.md.udpBufferSize), pc).
|
||||
h.md.udpBufferSize)
|
||||
|
||||
if h.md.udpResolveDomain {
|
||||
pc1 = &domainResolvePacketConn{
|
||||
PacketConn: pc1,
|
||||
resolver: h.options.Router.Options().Resolver,
|
||||
hostMapper: h.options.Router.Options().HostMapper,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
r := udp.NewRelay(pc1, pc).
|
||||
WithService(h.options.Service).
|
||||
WithBypass(h.options.Bypass).
|
||||
WithBufferSize(h.md.udpBufferSize).
|
||||
@@ -165,8 +208,123 @@ func (f *filteredPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error
|
||||
return
|
||||
}
|
||||
|
||||
if udpAddr := addr.(*net.UDPAddr); udpAddr.IP.Equal(f.filterIP) {
|
||||
udpAddr, ok := addr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if udpAddr.IP.Equal(f.filterIP) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// addresses to IPs in WriteTo calls. This guarantees that SOCKS5 UDP
|
||||
// response datagrams never carry ATYP=Domain (0x03), which some clients
|
||||
// (e.g., tun2proxy, Surge) cannot parse.
|
||||
type domainResolvePacketConn struct {
|
||||
net.PacketConn
|
||||
resolver resolver.Resolver
|
||||
hostMapper hosts.HostMapper
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func (c *domainResolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
// Already an IP address — pass through unchanged.
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
// Resolve the domain using the router's infrastructure
|
||||
// (host mapper → resolver → system fallback) so the SOCKS5 encoder
|
||||
// will use AddrIPv4 or AddrIPv6 instead of AddrDomain.
|
||||
var ips []net.IP
|
||||
if c.hostMapper != nil {
|
||||
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 && c.resolver != nil {
|
||||
ips, err = c.resolver.Resolve(context.Background(), "ip", host)
|
||||
if err != nil && c.log != nil {
|
||||
c.log.Warnf("socks5 udp: resolve %s: %v", host, err)
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
ips, err = net.LookupIP(host)
|
||||
}
|
||||
if err != nil || len(ips) == 0 {
|
||||
// Cannot resolve the domain — drop the datagram rather than
|
||||
// forwarding an ATYP=Domain response the client cannot parse.
|
||||
if c.log != nil {
|
||||
c.log.Warnf("socks5 udp: dropping datagram to %s (DNS resolution failed)", host)
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("socks5 udp: DNS resolution failed for %s", host)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Pick the first IPv4 address from the result set, falling back to
|
||||
// the first entry when no IPv4 is available.
|
||||
ip := ips[0]
|
||||
for _, candidate := range ips {
|
||||
if candidate.To4() != nil {
|
||||
ip = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
|
||||
}
|
||||
|
||||
@@ -109,6 +109,17 @@ func (h *socks5Handler) handleUDPTun(ctx context.Context, conn net.Conn, network
|
||||
}
|
||||
defer pc.Close()
|
||||
|
||||
// If the upstream is a raw *net.UDPConn (direct dial or BIND mode),
|
||||
// wrap it so domainAddr from socks.UDPTunServerConn.ReadFrom are
|
||||
// resolved through the configured resolver instead of failing WriteTo.
|
||||
if _, ok := pc.(*net.UDPConn); ok {
|
||||
pc = &resolvePacketConn{
|
||||
PacketConn: pc,
|
||||
resolver: h.options.Router.Options().Resolver,
|
||||
hostMapper: h.options.Router.Options().HostMapper,
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
pStats := xstats.Stats{}
|
||||
pc = stats_wrapper.WrapPacketConn(pc, &pStats)
|
||||
|
||||
@@ -237,6 +237,7 @@ func (h *ssHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.H
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -245,6 +246,7 @@ func (h *ssHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.H
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -209,6 +209,7 @@ func (h *forwardHandler) handleDirectForward(ctx context.Context, targetAddr str
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -217,6 +218,7 @@ func (h *forwardHandler) handleDirectForward(ctx context.Context, targetAddr str
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -180,6 +180,7 @@ func (h *unixHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -188,6 +189,7 @@ func (h *unixHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
+8
-3
@@ -176,6 +176,7 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
if matcher := node.Options().Matcher; matcher != nil {
|
||||
req := routing.Request{
|
||||
ClientIP: options.ClientIP,
|
||||
Network: options.Network,
|
||||
Host: options.Host,
|
||||
Protocol: options.Protocol,
|
||||
Method: options.Method,
|
||||
@@ -207,13 +208,17 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
})
|
||||
|
||||
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.
|
||||
// Conditions: (1) top priority > 0 means a matcher indicated routing
|
||||
// specificity, so the top node is authoritative for this request;
|
||||
// (2) no backup node is present, otherwise BackupFilter would be
|
||||
// silently bypassed. When both hold the selector (FailFilter,
|
||||
// BackupFilter, strategy) is skipped and the best node wins directly.
|
||||
// silently bypassed; (3) the top priority is strictly higher than
|
||||
// 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)
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
@@ -422,6 +422,43 @@ func TestSelect_MatcherDoesNotMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelect_MatcherReceivesNetwork(t *testing.T) {
|
||||
var capturedReq *routing.Request
|
||||
n1 := chain.NewNode("n1", "127.0.0.1:8080",
|
||||
chain.MatcherNodeOption(&requestCapturingMatcher{capture: &capturedReq, match: true}),
|
||||
)
|
||||
h := newTestHop(NodeOption(n1))
|
||||
defer h.Close()
|
||||
|
||||
h.Select(context.Background(), hop.NetworkSelectOption("tcp"))
|
||||
|
||||
if capturedReq == nil {
|
||||
t.Fatal("request was not passed to matcher")
|
||||
}
|
||||
if capturedReq.Network != "tcp" {
|
||||
t.Errorf("matcher saw Network=%q, want %q", capturedReq.Network, "tcp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelect_MatcherMatchNetwork(t *testing.T) {
|
||||
n1 := chain.NewNode("n1", "127.0.0.1:8080",
|
||||
chain.MatcherNodeOption(&testMatcher{match: false}),
|
||||
)
|
||||
n2 := chain.NewNode("n2", "127.0.0.1:9090",
|
||||
chain.MatcherNodeOption(&testMatcher{match: true}),
|
||||
)
|
||||
h := newTestHop(NodeOption(n1, n2))
|
||||
defer h.Close()
|
||||
|
||||
node := h.Select(context.Background(), hop.NetworkSelectOption("udp"))
|
||||
if node == nil {
|
||||
t.Fatal("expected selected node, got nil")
|
||||
}
|
||||
if node.Name != "n2" {
|
||||
t.Errorf("expected 'n2' (match=true), got %q", node.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelect_Priority_HighestWins(t *testing.T) {
|
||||
n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(5))
|
||||
n2 := chain.NewNode("n2", "127.0.0.1:9090", chain.PriorityNodeOption(10))
|
||||
@@ -485,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) {
|
||||
h := newTestHop()
|
||||
defer h.Close()
|
||||
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
// addresses assigned to that interface. The network parameter determines the address
|
||||
// type (TCPAddr for "tcp"/"tcp4"/"tcp6", UDPAddr for "udp"/"udp4"/"udp6", IPAddr
|
||||
// otherwise).
|
||||
func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr, err error) {
|
||||
//
|
||||
// isIP reports whether ifceName was parsed as an IP address (true) rather than an
|
||||
// interface name (false). When isIP is true, callers should skip SO_BINDTODEVICE —
|
||||
// the user's intent is source IP binding for policy routing, not device binding.
|
||||
func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr, isIP bool, err error) {
|
||||
if ifceName == "" {
|
||||
addr = append(addr, nil)
|
||||
return
|
||||
@@ -43,6 +47,8 @@ func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ifceName is an IP address — skip SO_BINDTODEVICE, use LocalAddr only
|
||||
isIP = true
|
||||
ifce, err = findInterfaceByIP(ip)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -202,39 +202,48 @@ func Test_findInterfaceByIP(t *testing.T) {
|
||||
|
||||
func TestParseInterfaceAddr(t *testing.T) {
|
||||
// Empty interface name
|
||||
ifce, addrs, err := ParseInterfaceAddr("", "tcp")
|
||||
ifce, addrs, isIP, err := ParseInterfaceAddr("", "tcp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if ifce != "" {
|
||||
t.Errorf("expected empty ifce, got %s", ifce)
|
||||
}
|
||||
if isIP {
|
||||
t.Errorf("expected isIP=false for empty string, got true")
|
||||
}
|
||||
if len(addrs) != 1 || addrs[0] != nil {
|
||||
t.Errorf("expected [nil], got %v", addrs)
|
||||
}
|
||||
|
||||
// Non-existent interface
|
||||
_, _, err = ParseInterfaceAddr("nonexistent_interface_xyz", "tcp")
|
||||
_, _, _, err = ParseInterfaceAddr("nonexistent_interface_xyz", "tcp")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent interface")
|
||||
}
|
||||
|
||||
// IP string as interface name
|
||||
ifce, addrs, err = ParseInterfaceAddr("127.0.0.1", "tcp")
|
||||
// IP string as interface name — isIP should be true
|
||||
ifce, addrs, isIP, err = ParseInterfaceAddr("127.0.0.1", "tcp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if !isIP {
|
||||
t.Errorf("expected isIP=true for IP input, got false")
|
||||
}
|
||||
if ifce == "" {
|
||||
t.Log("127.0.0.1 not found on any interface (may happen in containers)")
|
||||
} else if len(addrs) != 1 {
|
||||
t.Errorf("expected 1 addr, got %d", len(addrs))
|
||||
}
|
||||
|
||||
// IP that doesn't exist on any interface
|
||||
ifce, addrs, err = ParseInterfaceAddr("203.0.113.1", "udp")
|
||||
// IP that doesn't exist on any interface — should still have isIP=true
|
||||
ifce, addrs, isIP, err = ParseInterfaceAddr("203.0.113.1", "udp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if !isIP {
|
||||
t.Errorf("expected isIP=true for IP input, got false")
|
||||
}
|
||||
if ifce != "" {
|
||||
t.Errorf("expected empty ifce for unassigned IP, got %s", ifce)
|
||||
}
|
||||
@@ -243,10 +252,13 @@ func TestParseInterfaceAddr(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test UDP network specific returns
|
||||
ifce, addrs, err = ParseInterfaceAddr("127.0.0.1", "udp")
|
||||
ifce, addrs, isIP, err = ParseInterfaceAddr("127.0.0.1", "udp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if !isIP {
|
||||
t.Errorf("expected isIP=true for IP input, got false")
|
||||
}
|
||||
if ifce != "" {
|
||||
if len(addrs) != 1 {
|
||||
t.Errorf("expected 1 addr, got %d", len(addrs))
|
||||
|
||||
@@ -76,13 +76,14 @@ func (d *Dialer) Dial(ctx context.Context, network, addr string) (conn net.Conn,
|
||||
ifce = strings.TrimSuffix(ifce, "!")
|
||||
var ifceName string
|
||||
var ifAddrs []net.Addr
|
||||
ifceName, ifAddrs, err = xnet.ParseInterfaceAddr(ifce, network)
|
||||
var isIP bool
|
||||
ifceName, ifAddrs, isIP, err = xnet.ParseInterfaceAddr(ifce, network)
|
||||
if err != nil && strict {
|
||||
return
|
||||
}
|
||||
|
||||
for _, ifAddr := range ifAddrs {
|
||||
conn, err = d.dialOnce(ctx, network, addr, ifceName, ifAddr, log)
|
||||
conn, err = d.dialOnce(ctx, network, addr, ifceName, ifAddr, !isIP, log)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
@@ -100,7 +101,7 @@ func (d *Dialer) Dial(ctx context.Context, network, addr string) (conn net.Conn,
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, ifAddr net.Addr, log logger.Logger) (net.Conn, error) {
|
||||
func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, ifAddr net.Addr, bindToDevice bool, log logger.Logger) (net.Conn, error) {
|
||||
if ifceName != "" {
|
||||
log.Debugf("dial %s/%s via interface %s@%s", addr, network, ifceName, ifAddr)
|
||||
}
|
||||
@@ -145,10 +146,11 @@ func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, i
|
||||
return nil, fmt.Errorf("dial: unsupported network %s", network)
|
||||
}
|
||||
netd := net.Dialer{
|
||||
Resolver: &net.Resolver{PreferGo: true},
|
||||
LocalAddr: ifAddr,
|
||||
Control: func(network, address string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) {
|
||||
if ifceName != "" {
|
||||
if ifceName != "" && bindToDevice {
|
||||
if err := bindDevice(network, address, fd, ifceName); err != nil {
|
||||
log.Warnf("%s/%s bind device: %v", address, network, err)
|
||||
}
|
||||
|
||||
@@ -11,14 +11,6 @@ func bindDevice(network, address string, fd uintptr, ifceName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip SO_BINDTODEVICE for loopback interface — it prevents
|
||||
// outbound traffic from reaching non-local destinations.
|
||||
// Source IP binding via LocalAddr is sufficient for policy routing
|
||||
// when using IP aliases on loopback (e.g. ip addr add 10.1.1.1/32 dev lo).
|
||||
if ifce, err := net.InterfaceByName(ifceName); err == nil && ifce.Flags&net.FlagLoopback != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(address)
|
||||
if ip := net.ParseIP(host); ip != nil && !ip.IsGlobalUnicast() {
|
||||
return nil
|
||||
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
|
||||
// Resolve resolves addr to a concrete IP address. If hosts is non-nil, it is
|
||||
// consulted first. If r is non-nil and no host mapping matched, the resolver
|
||||
// is used. If neither is available, addr is returned unchanged.
|
||||
// is used. If neither is available, the address is returned unchanged so that
|
||||
// the original hostname is preserved for downstream connectors (e.g., SOCKS5
|
||||
// ATYP=domain). DNS resolution is deferred to the final TCP dialer, which uses
|
||||
// a pure-Go resolver to avoid cgo thread exhaustion.
|
||||
func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hosts hosts.HostMapper, log logger.Logger) (string, error) {
|
||||
if addr == "" {
|
||||
return addr, nil
|
||||
@@ -51,5 +54,10 @@ func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hos
|
||||
}
|
||||
return net.JoinHostPort(ips[0].String(), port), nil
|
||||
}
|
||||
|
||||
// Return the address unchanged so that the original hostname is
|
||||
// preserved through the proxy chain. DNS resolution happens in the
|
||||
// final TCP dialer (dialer.go) which uses net.Resolver{PreferGo: true}
|
||||
// to avoid cgo-based lookups that block OS threads.
|
||||
return addr, nil
|
||||
}
|
||||
@@ -115,8 +115,19 @@ func TestResolve_ResolverEmptyResult(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolve_NoResolverNoHosts(t *testing.T) {
|
||||
// When no resolver and no host mapper are configured, the address is
|
||||
// returned unchanged so that the original hostname is preserved
|
||||
// through the proxy chain (e.g., SOCKS5 ATYP=domain).
|
||||
// DNS resolution is deferred to the final TCP dialer.
|
||||
ctx := context.Background()
|
||||
addr, err := Resolve(ctx, "tcp", "example.com:8080", nil, nil, nil)
|
||||
addr, err := Resolve(ctx, "tcp", "1.2.3.4:8080", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if addr != "1.2.3.4:8080" {
|
||||
t.Errorf("expected 1.2.3.4:8080, got %s", addr)
|
||||
}
|
||||
addr, err = Resolve(ctx, "tcp", "example.com:8080", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -81,7 +81,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
defer func() { cc.Close() }()
|
||||
|
||||
upstreamHost := normalizeHost(ro.HTTP.Host, "80")
|
||||
|
||||
ho.log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
|
||||
log = ho.log
|
||||
@@ -112,6 +114,41 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
// When DNS override directs multiple domains to the same proxy IP,
|
||||
// the browser may reuse a keep-alive connection for a different host.
|
||||
// Re-dial to ensure requests reach the correct upstream.
|
||||
if reqHost := normalizeHost(req.Host, "80"); reqHost != "" && reqHost != upstreamHost {
|
||||
cc.Close()
|
||||
newNode, res, resolveErr := resolveHTTPNode(ctx, reqHost, req, &ho)
|
||||
if resolveErr != nil {
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return resolveErr
|
||||
}
|
||||
dial := ho.dial
|
||||
if dial == nil {
|
||||
dial = (&net.Dialer{}).DialContext
|
||||
}
|
||||
newCC, dialErr := dial(ctx, "tcp", newNode.Addr)
|
||||
if dialErr != nil {
|
||||
return dialErr
|
||||
}
|
||||
newCC = tlsWrapConn(newCC, newNode.Options().TLS)
|
||||
upstreamHost = reqHost
|
||||
node = newNode
|
||||
cc = newCC
|
||||
ro.Host = reqHost
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
log = log.WithFields(map[string]any{
|
||||
"host": reqHost,
|
||||
"node": node.Name,
|
||||
"dst": node.Addr,
|
||||
"src": cc.LocalAddr().String(),
|
||||
})
|
||||
ho.log = log
|
||||
}
|
||||
|
||||
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, node, req, &pStats, &ho); err != nil || shouldClose {
|
||||
return err
|
||||
}
|
||||
@@ -319,6 +356,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
if re.Pattern.MatchString(req.URL.Path) {
|
||||
if s := re.Pattern.ReplaceAllString(req.URL.Path, re.Replacement); s != "" {
|
||||
req.URL.Path = s
|
||||
ro.HTTP.URI = req.URL.RequestURI()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -331,7 +369,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
|
||||
// Rewrite request body before wrapping for recording,
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
@@ -408,7 +446,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -434,3 +472,4 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -41,38 +41,30 @@ func (h *Sniffer) handleUpgradeResponse(ctx context.Context, rw, cc io.ReadWrite
|
||||
return xnet.Pipe(ctx, rw, cc)
|
||||
}
|
||||
|
||||
func rewriteRespBody(resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if resp == nil || len(rewrites) == 0 || resp.ContentLength <= 0 {
|
||||
func rewriteRespBody(ctx context.Context, resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
|
||||
return nil
|
||||
uri := ""
|
||||
if resp.Request != nil {
|
||||
uri = resp.Request.RequestURI
|
||||
}
|
||||
|
||||
body, err := drainBody(resp.Body)
|
||||
if err != nil || body == nil {
|
||||
rb, err := newRewriteBody(ctx, resp.Body, rewrites,
|
||||
resp.Header.Get("Content-Type"),
|
||||
resp.Header.Get("Content-Encoding"),
|
||||
resp.ContentLength, "response", uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentType, _, _ := strings.Cut(resp.Header.Get("Content-Type"), ";")
|
||||
for _, rewrite := range rewrites {
|
||||
rewriteType := rewrite.Type
|
||||
if rewriteType == "" {
|
||||
rewriteType = "text/html"
|
||||
}
|
||||
if rewriteType != "*" && !strings.Contains(rewriteType, contentType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if rewrite.Pattern != nil {
|
||||
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
|
||||
}
|
||||
if rb == nil {
|
||||
return nil
|
||||
}
|
||||
resp.Body = rb
|
||||
if !rb.streaming && rb.contentLength >= 0 {
|
||||
resp.ContentLength = rb.contentLength
|
||||
resp.TransferEncoding = nil
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
}
|
||||
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,38 +82,26 @@ func drainBody(b io.ReadCloser) (body []byte, err error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func rewriteReqBody(req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if req == nil || len(rewrites) == 0 || req.Body == nil || req.ContentLength <= 0 {
|
||||
func rewriteReqBody(ctx context.Context, req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if encoding := req.Header.Get("Content-Encoding"); encoding != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := drainBody(req.Body)
|
||||
if err != nil || body == nil {
|
||||
rb, err := newRewriteBody(ctx, req.Body, rewrites,
|
||||
req.Header.Get("Content-Type"),
|
||||
req.Header.Get("Content-Encoding"),
|
||||
req.ContentLength, "request", req.RequestURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentType, _, _ := strings.Cut(req.Header.Get("Content-Type"), ";")
|
||||
for _, rewrite := range rewrites {
|
||||
rewriteType := rewrite.Type
|
||||
if rewriteType == "" {
|
||||
rewriteType = "text/html"
|
||||
}
|
||||
if rewriteType != "*" && !strings.Contains(rewriteType, contentType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if rewrite.Pattern != nil {
|
||||
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
|
||||
}
|
||||
if rb == nil {
|
||||
return nil
|
||||
}
|
||||
req.Body = rb
|
||||
if !rb.streaming && rb.contentLength >= 0 {
|
||||
req.ContentLength = rb.contentLength
|
||||
req.TransferEncoding = nil
|
||||
req.Header.Del("Transfer-Encoding")
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// scanSSEEvents is a bufio.SplitFunc that splits SSE events on \n\n (or \r\n\r\n).
|
||||
// The delimiter is consumed but not included in the token.
|
||||
func scanSSEEvents(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
for i := 0; i < len(data)-1; i++ {
|
||||
if data[i] == '\n' && data[i+1] == '\n' {
|
||||
return i + 2, data[:i], nil
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(data)-3; i++ {
|
||||
if data[i] == '\r' && data[i+1] == '\n' && data[i+2] == '\r' && data[i+3] == '\n' {
|
||||
return i + 4, data[:i], nil
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(data)-1; i++ {
|
||||
if data[i] == '\r' && data[i+1] == '\r' {
|
||||
return i + 2, data[:i], nil
|
||||
}
|
||||
}
|
||||
if atEOF && len(data) > 0 {
|
||||
return len(data), data, bufio.ErrFinalToken
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
// rewriteBody wraps an io.ReadCloser and applies the rewrite chain.
|
||||
//
|
||||
// Three processing modes:
|
||||
//
|
||||
// 1. Non-streaming (plain): eagerly buffer the entire body, rewrite once,
|
||||
// expose the final ContentLength.
|
||||
//
|
||||
// 2. Non-streaming (compressed): decompress, rewrite, recompress, expose
|
||||
// the final ContentLength.
|
||||
//
|
||||
// 3. SSE streaming: split on \n\n via bufio.Scanner, rewrite each event
|
||||
// independently. The first event carries sse_phase:"start", subsequent
|
||||
// events carry sse_phase:"event", and when the scanner hits EOF an
|
||||
// empty-body apply(nil, sse_phase:"end") is emitted so Rewriter plugins
|
||||
// can append a trailing event (e.g. message_stop for LLM API conversion).
|
||||
type rewriteBody struct {
|
||||
ctx context.Context
|
||||
src io.ReadCloser
|
||||
rewrites []chain.HTTPBodyRewriteSettings
|
||||
contentType string
|
||||
streaming bool
|
||||
|
||||
scanner *bufio.Scanner
|
||||
buf bytes.Buffer
|
||||
contentLength int64 // >= 0 for non-streaming bodies; -1 for streaming
|
||||
|
||||
// SSE stream lifecycle state.
|
||||
eventIndex int // incremented per SSE event for the "event" phase
|
||||
ended bool // true after the stream-end phase has been emitted
|
||||
scannerErr error // non-nil when scanner terminated with an error
|
||||
|
||||
sid string // session ID, cached from context at construction time
|
||||
|
||||
// Protocol detection metadata for Rewriter plugins.
|
||||
direction string // "request" or "response"
|
||||
uri string // HTTP request path (e.g. "/v1/messages")
|
||||
}
|
||||
|
||||
// multiReadCloser combines an io.Reader with an io.Closer, used when
|
||||
// replaying buffered prefix data followed by the original source.
|
||||
type multiReadCloser struct {
|
||||
r io.Reader
|
||||
c io.Closer
|
||||
}
|
||||
|
||||
func (m *multiReadCloser) Read(p []byte) (int, error) { return m.r.Read(p) }
|
||||
func (m *multiReadCloser) Close() error { return m.c.Close() }
|
||||
|
||||
// defaultMaxChunkSize is the default MaxChunkSize for chunked non-streaming
|
||||
// bodies when no rewrite rule explicitly sets one. 1MB covers typical LLM
|
||||
// responses without risking OOM on unbounded bodies.
|
||||
const defaultMaxChunkSize = 1 << 20 // 1MB
|
||||
|
||||
// newRewriteBody returns an io.ReadCloser that rewrites data read from src
|
||||
// through the given rewrite chain. Returns nil, nil when no rewrites are
|
||||
// configured, or for chunked/unknown-length non-streaming bodies.
|
||||
// For compressed bodies (Content-Encoding set), the body is decompressed,
|
||||
// rewritten, and recompressed transparently. Returns nil, err when the
|
||||
// upstream body cannot be read or the rewrite chain fails.
|
||||
func newRewriteBody(ctx context.Context, src io.ReadCloser, rewrites []chain.HTTPBodyRewriteSettings, contentType, contentEncoding string, contentLength int64, direction, uri string) (*rewriteBody, error) {
|
||||
if len(rewrites) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ct, _, _ := strings.Cut(contentType, ";")
|
||||
ct = strings.TrimSpace(ct)
|
||||
streaming := strings.HasPrefix(ct, "text/event-stream")
|
||||
|
||||
// Bounded buffering for chunked/unknown-length non-streaming bodies.
|
||||
if !streaming && contentLength < 0 {
|
||||
var maxChunkSize int
|
||||
for _, rw := range rewrites {
|
||||
if rw.MaxChunkSize > maxChunkSize {
|
||||
maxChunkSize = rw.MaxChunkSize
|
||||
}
|
||||
}
|
||||
if maxChunkSize == 0 {
|
||||
maxChunkSize = defaultMaxChunkSize
|
||||
}
|
||||
limited := io.LimitReader(src, int64(maxChunkSize+1))
|
||||
buf, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(buf) > maxChunkSize {
|
||||
// Overflow: passthrough without rewriting.
|
||||
// ContentLength stays -1 so Go re-chunk-encodes on Write.
|
||||
rb := &rewriteBody{
|
||||
ctx: ctx,
|
||||
src: &multiReadCloser{r: io.MultiReader(bytes.NewReader(buf), src), c: src},
|
||||
contentType: ct,
|
||||
streaming: false,
|
||||
contentLength: -1,
|
||||
}
|
||||
rb.sid = xctx.SidFromContext(ctx).String()
|
||||
rb.direction = direction
|
||||
rb.uri = uri
|
||||
return rb, nil
|
||||
}
|
||||
// Fits: rewrite and set ContentLength.
|
||||
rb := &rewriteBody{
|
||||
ctx: ctx,
|
||||
rewrites: rewrites,
|
||||
contentType: ct,
|
||||
streaming: false,
|
||||
contentLength: -1,
|
||||
}
|
||||
rb.sid = xctx.SidFromContext(ctx).String()
|
||||
rb.direction = direction
|
||||
rb.uri = uri
|
||||
if contentEncoding != "" {
|
||||
// Compressed: decompress → rewrite → recompress.
|
||||
var hasTypeMatch bool
|
||||
for _, rw := range rewrites {
|
||||
rt := rw.Type
|
||||
if rt == "" {
|
||||
if rw.Rewriter != nil {
|
||||
rt = "*"
|
||||
} else {
|
||||
rt = "text/html"
|
||||
}
|
||||
}
|
||||
if rt == "*" || strings.Contains(rt, ct) {
|
||||
hasTypeMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTypeMatch {
|
||||
rb.src = io.NopCloser(bytes.NewReader(buf))
|
||||
rb.contentLength = int64(len(buf))
|
||||
return rb, nil
|
||||
}
|
||||
decoded, err := decompressBody(buf, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewritten, err := rb.apply(decoded, rb.baseMetadataOpt())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recompressed, err := compressBody(rewritten, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(recompressed))
|
||||
rb.contentLength = int64(len(recompressed))
|
||||
} else {
|
||||
rewritten, err := rb.apply(buf, rb.baseMetadataOpt())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(rewritten))
|
||||
rb.contentLength = int64(len(rewritten))
|
||||
}
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
rb := &rewriteBody{
|
||||
ctx: ctx,
|
||||
src: src,
|
||||
rewrites: rewrites,
|
||||
contentType: ct,
|
||||
streaming: streaming,
|
||||
contentLength: -1,
|
||||
}
|
||||
rb.sid = xctx.SidFromContext(ctx).String()
|
||||
rb.direction = direction
|
||||
rb.uri = uri
|
||||
mdOpts := []rewriter.RewriteOption{rb.baseMetadataOpt()}
|
||||
|
||||
if contentEncoding != "" {
|
||||
if streaming {
|
||||
return nil, nil
|
||||
}
|
||||
// Early exit if no rewrite rule matches this content type
|
||||
// (avoids unnecessary decompress-recompress cycle).
|
||||
var hasTypeMatch bool
|
||||
for _, rw := range rewrites {
|
||||
rt := rw.Type
|
||||
if rt == "" {
|
||||
if rw.Rewriter != nil {
|
||||
rt = "*"
|
||||
} else {
|
||||
rt = "text/html"
|
||||
}
|
||||
}
|
||||
if rt == "*" || strings.Contains(rt, ct) {
|
||||
hasTypeMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTypeMatch {
|
||||
return nil, nil
|
||||
}
|
||||
// Compressed non-streaming: decompress, rewrite, recompress.
|
||||
body, err := drainBody(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := decompressBody(body, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewritten, err := rb.apply(decoded, mdOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recompressed, err := compressBody(rewritten, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(recompressed))
|
||||
rb.contentLength = int64(len(recompressed))
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
if !streaming {
|
||||
// Non-streaming: eagerly read and rewrite so the caller knows the
|
||||
// final content length.
|
||||
body, err := drainBody(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewritten, err := rb.apply(body, mdOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(rewritten))
|
||||
rb.contentLength = int64(len(rewritten))
|
||||
} else {
|
||||
// SSE streaming: scan events split on \n\n and rewrite each one
|
||||
// independently on Read. Lifecycle phases (first event = start,
|
||||
// subsequent = event, EOF = end) are assigned in Read().
|
||||
scanner := bufio.NewScanner(src)
|
||||
scanner.Split(scanSSEEvents)
|
||||
scanner.Buffer(make([]byte, 64*1024), 64*1024*1024)
|
||||
rb.scanner = scanner
|
||||
}
|
||||
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
func (b *rewriteBody) Read(p []byte) (n int, err error) {
|
||||
for {
|
||||
if !b.streaming {
|
||||
return b.src.Read(p)
|
||||
}
|
||||
|
||||
// SSE streaming: flush buffered rewritten event first.
|
||||
if b.buf.Len() > 0 {
|
||||
return b.buf.Read(p)
|
||||
}
|
||||
|
||||
// Scan the next SSE event.
|
||||
if !b.scanner.Scan() {
|
||||
err = b.scanner.Err()
|
||||
if err == nil {
|
||||
if b.ended {
|
||||
if b.scannerErr != nil {
|
||||
return 0, b.scannerErr
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
// End of upstream SSE events — emit stream end phase.
|
||||
// This empty-body call lets Rewriter plugins append a
|
||||
// trailing event (e.g. message_stop for LLM conversion).
|
||||
b.ended = true
|
||||
md := map[string]any{
|
||||
"sid": b.sid,
|
||||
"direction": b.direction,
|
||||
"uri": b.uri,
|
||||
"sse_phase": "end",
|
||||
"event_index": b.eventIndex,
|
||||
}
|
||||
rewritten, endErr := b.apply(nil, rewriter.MetadataRewriteOption(md))
|
||||
if endErr != nil {
|
||||
return 0, endErr
|
||||
}
|
||||
if len(rewritten) > 0 {
|
||||
b.buf.Write(rewritten)
|
||||
writeSSEStreamDelim(&b.buf, rewritten)
|
||||
}
|
||||
return b.buf.Read(p)
|
||||
}
|
||||
// Scanner error (e.g. upstream connection reset): emit the end
|
||||
// phase so Rewriter plugins can send a terminal event
|
||||
// (response.failed / message_stop), then return the error
|
||||
// after the buffer drains.
|
||||
if !b.ended {
|
||||
b.ended = true
|
||||
b.scannerErr = err
|
||||
md := map[string]any{
|
||||
"sid": b.sid,
|
||||
"direction": b.direction,
|
||||
"uri": b.uri,
|
||||
"sse_phase": "end",
|
||||
"event_index": b.eventIndex,
|
||||
"stream_error": err.Error(),
|
||||
}
|
||||
rewritten, _ := b.apply(nil, rewriter.MetadataRewriteOption(md))
|
||||
if len(rewritten) > 0 {
|
||||
b.buf.Write(rewritten)
|
||||
writeSSEStreamDelim(&b.buf, rewritten)
|
||||
return b.buf.Read(p)
|
||||
}
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Copy token (scanner.Bytes() is only valid until next Scan()).
|
||||
event := make([]byte, len(b.scanner.Bytes()))
|
||||
copy(event, b.scanner.Bytes())
|
||||
|
||||
// SSE lifecycle phases — assigned to real events so the Rewriter
|
||||
// can distinguish first (start), middle (event), and last event
|
||||
// without synthetic signals. On EOF, apply(nil, end) produces any
|
||||
// trailing event (e.g. message_stop for LLM API conversion).
|
||||
phase := "event"
|
||||
if b.eventIndex == 0 {
|
||||
phase = "start"
|
||||
}
|
||||
md := map[string]any{
|
||||
"sid": b.sid,
|
||||
"direction": b.direction,
|
||||
"uri": b.uri,
|
||||
"sse_phase": phase,
|
||||
"event_index": b.eventIndex,
|
||||
}
|
||||
b.eventIndex++
|
||||
|
||||
rewritten, err := b.apply(event, rewriter.MetadataRewriteOption(md))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rewritten) > 0 {
|
||||
b.buf.Write(rewritten)
|
||||
writeSSEStreamDelim(&b.buf, rewritten)
|
||||
}
|
||||
// Loop to flush the buffer or scan the next event.
|
||||
}
|
||||
}
|
||||
|
||||
func (b *rewriteBody) Close() error {
|
||||
return b.src.Close()
|
||||
}
|
||||
|
||||
// baseMetadata returns rewriter metadata with all available fields.
|
||||
func (b *rewriteBody) baseMetadata() map[string]any {
|
||||
md := map[string]any{}
|
||||
if b.sid != "" {
|
||||
md["sid"] = b.sid
|
||||
}
|
||||
if b.direction != "" {
|
||||
md["direction"] = b.direction
|
||||
}
|
||||
if b.uri != "" {
|
||||
md["uri"] = b.uri
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
// baseMetadataOpt returns a RewriteOption wrapping baseMetadata.
|
||||
func (b *rewriteBody) baseMetadataOpt() rewriter.RewriteOption {
|
||||
return rewriter.MetadataRewriteOption(b.baseMetadata())
|
||||
}
|
||||
|
||||
// ponytail: guard against plugin already having added \n\n delimiter
|
||||
func writeSSEStreamDelim(buf *bytes.Buffer, data []byte) {
|
||||
if len(data) < 2 || data[len(data)-1] != '\n' || data[len(data)-2] != '\n' {
|
||||
buf.Write([]byte("\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func decompressBody(data []byte, encoding string) ([]byte, error) {
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
r, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
case "deflate":
|
||||
r, err := zlib.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
case "br":
|
||||
return io.ReadAll(brotli.NewReader(bytes.NewReader(data)))
|
||||
case "zstd":
|
||||
r, err := zstd.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
default:
|
||||
// identity or unknown — pass through.
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
func compressBody(data []byte, encoding string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
w := gzip.NewWriter(&buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "deflate":
|
||||
w := zlib.NewWriter(&buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "br":
|
||||
w := brotli.NewWriter(&buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "zstd":
|
||||
w, err := zstd.NewWriter(&buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return data, nil
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// apply runs the rewrite chain on body bytes.
|
||||
// Returns the (possibly unchanged) body and any error from a plugin Rewriter.
|
||||
func (b *rewriteBody) apply(body []byte, opts ...rewriter.RewriteOption) ([]byte, error) {
|
||||
if len(body) == 0 && len(opts) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
for _, rw := range b.rewrites {
|
||||
rewriteType := rw.Type
|
||||
if rewriteType == "" {
|
||||
if rw.Rewriter != nil {
|
||||
rewriteType = "*"
|
||||
} else {
|
||||
rewriteType = "text/html"
|
||||
}
|
||||
}
|
||||
if rewriteType != "*" && !strings.Contains(rewriteType, b.contentType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if rw.Rewriter != nil {
|
||||
if body == nil || rw.Pattern == nil || rw.Pattern.Match(body) {
|
||||
rewritten, err := rw.Rewriter.Rewrite(b.ctx, body, opts...)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
body = rewritten
|
||||
}
|
||||
} else if rw.Pattern != nil {
|
||||
body = rw.Pattern.ReplaceAll(body, rw.Replacement)
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package forwarder
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
@@ -13,10 +15,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
@@ -43,6 +48,32 @@ func (m *mockBypass) Contains(_ context.Context, _, _ string, _ ...bypass.Option
|
||||
|
||||
func (m *mockBypass) IsWhitelist() bool { return m.whitelist }
|
||||
|
||||
// compressTestData compresses data with the given encoding for test purposes.
|
||||
func compressTestData(data []byte, encoding string) []byte {
|
||||
var buf bytes.Buffer
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
w := gzip.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
case "deflate":
|
||||
w := zlib.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
case "br":
|
||||
w := brotli.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
case "zstd":
|
||||
w, _ := zstd.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
default:
|
||||
return data
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pure Function Tests
|
||||
// =============================================================================
|
||||
@@ -70,7 +101,7 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
wantCL: 11,
|
||||
},
|
||||
{
|
||||
name: "nil body skipped",
|
||||
name: "nil body",
|
||||
req: &http.Request{
|
||||
Body: nil,
|
||||
ContentLength: 11,
|
||||
@@ -79,10 +110,10 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
},
|
||||
wantBody: "",
|
||||
wantCL: 11,
|
||||
wantCL: 0,
|
||||
},
|
||||
{
|
||||
name: "zero content length skipped",
|
||||
name: "zero content length",
|
||||
req: &http.Request{
|
||||
Body: io.NopCloser(bytes.NewReader(hello)),
|
||||
ContentLength: 0,
|
||||
@@ -90,21 +121,8 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
rewrites: []chain.HTTPBodyRewriteSettings{
|
||||
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
},
|
||||
wantBody: "hello world",
|
||||
wantCL: 0,
|
||||
},
|
||||
{
|
||||
name: "content-encoding skips rewrite",
|
||||
req: &http.Request{
|
||||
Body: io.NopCloser(bytes.NewReader(hello)),
|
||||
ContentLength: int64(len(hello)),
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}},
|
||||
},
|
||||
rewrites: []chain.HTTPBodyRewriteSettings{
|
||||
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
},
|
||||
wantBody: "hello world",
|
||||
wantCL: 11,
|
||||
wantBody: "hi world",
|
||||
wantCL: 8,
|
||||
},
|
||||
{
|
||||
name: "content type does not match default text/html",
|
||||
@@ -164,7 +182,7 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req != nil {
|
||||
_ = rewriteReqBody(tt.req, tt.rewrites...)
|
||||
_ = rewriteReqBody(context.Background(), tt.req, tt.rewrites...)
|
||||
if tt.req.Body != nil {
|
||||
body, _ := io.ReadAll(tt.req.Body)
|
||||
tt.req.Body.Close()
|
||||
@@ -178,11 +196,40 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
t.Errorf("ContentLength = %d, want %d", tt.req.ContentLength, tt.wantCL)
|
||||
}
|
||||
} else {
|
||||
_ = rewriteReqBody(nil) // should not panic
|
||||
_ = rewriteReqBody(context.Background(), nil) // should not panic
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestRewriteReqBody_Gzip(t *testing.T) {
|
||||
t.Run("gzip content-encoding rewrite", func(t *testing.T) {
|
||||
hello := []byte("hello world")
|
||||
gzipHello := compressTestData(hello, "gzip")
|
||||
req := &http.Request{
|
||||
Body: io.NopCloser(bytes.NewReader(gzipHello)),
|
||||
ContentLength: int64(len(gzipHello)),
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}, "Content-Type": {"text/html"}},
|
||||
}
|
||||
_ = rewriteReqBody(context.Background(), req, []chain.HTTPBodyRewriteSettings{
|
||||
{Type: "*", Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
}...)
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
r, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid gzip output: %v", err)
|
||||
}
|
||||
decoded, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
if string(decoded) != "hi world" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
|
||||
}
|
||||
if req.ContentLength != int64(len(body)) {
|
||||
t.Errorf("ContentLength = %d, want %d", req.ContentLength, len(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func TestClampBodySize(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -344,8 +391,8 @@ func TestDrainBody(t *testing.T) {
|
||||
|
||||
func TestRewriteRespBody(t *testing.T) {
|
||||
t.Run("nil response", func(t *testing.T) {
|
||||
if err := rewriteRespBody(nil); err != nil {
|
||||
t.Errorf("rewriteRespBody(nil) = %v, want nil", err)
|
||||
if err := rewriteRespBody(context.Background(), nil); err != nil {
|
||||
t.Errorf("rewriteRespBody(context.Background(), nil) = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -354,7 +401,7 @@ func TestRewriteRespBody(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
_ = rewriteRespBody(resp)
|
||||
_ = rewriteRespBody(context.Background(), resp)
|
||||
// Body unchanged
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "original" {
|
||||
@@ -365,34 +412,43 @@ func TestRewriteRespBody(t *testing.T) {
|
||||
t.Run("zero content length", func(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
ContentLength: 0,
|
||||
Header: http.Header{"Content-Type": {"text/plain"}},
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("."),
|
||||
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "*",
|
||||
Replacement: []byte("replaced"),
|
||||
})
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
// Content-Length was 0 (unknown), so no rewrite applied
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
if string(got) != "replaced" {
|
||||
t.Errorf("body = %q, want %q", string(got), "replaced")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with content encoding", func(t *testing.T) {
|
||||
original := []byte("original")
|
||||
compressed := compressTestData(original, "gzip")
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}},
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
ContentLength: int64(len(compressed)),
|
||||
Body: io.NopCloser(bytes.NewReader(compressed)),
|
||||
}
|
||||
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Type: "*",
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Replacement: []byte("replaced"),
|
||||
})
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
// Content-Encoding present, rewrite skipped
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
// Output is gzip-compressed, decompress to verify.
|
||||
r, err := gzip.NewReader(bytes.NewReader(got))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid gzip output: %v", err)
|
||||
}
|
||||
decoded, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
if string(decoded) != "replaced" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "replaced")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -413,7 +469,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
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)
|
||||
if string(got) != "replaced" {
|
||||
t.Errorf("body = %q, want %q", string(got), "replaced")
|
||||
@@ -426,7 +482,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello")),
|
||||
}
|
||||
_ = rewriteRespBody(resp, makeRewrite("*", "world"))
|
||||
_ = rewriteRespBody(context.Background(), resp, makeRewrite("*", "world"))
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "world" {
|
||||
t.Errorf("body = %q, want %q", string(got), "world")
|
||||
@@ -439,7 +495,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
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)
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
@@ -454,7 +510,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
}
|
||||
// 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.
|
||||
_ = rewriteRespBody(resp, makeRewrite("", "replaced"))
|
||||
_ = rewriteRespBody(context.Background(), resp, makeRewrite("", "replaced"))
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "replaced" {
|
||||
t.Errorf("body = %q, want %q (should be rewritten)", string(got), "replaced")
|
||||
@@ -1146,7 +1202,7 @@ func TestRewriteRespBody_PatternReplacement(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
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"),
|
||||
Type: "text/html",
|
||||
Replacement: []byte("New"),
|
||||
@@ -1169,7 +1225,7 @@ func TestRewriteRespBody_MultipleRewrites(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello")),
|
||||
}
|
||||
err := rewriteRespBody(resp,
|
||||
err := rewriteRespBody(context.Background(), resp,
|
||||
chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("h.*o"),
|
||||
Type: "text/html",
|
||||
@@ -1196,7 +1252,7 @@ func TestRewriteRespBody_NilPattern(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: nil,
|
||||
Type: "*",
|
||||
Replacement: []byte("replaced"),
|
||||
@@ -1215,7 +1271,7 @@ func TestRewriteRespBody_EmptyContentTypeUsesDefault(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "",
|
||||
Replacement: []byte("rewritten"),
|
||||
@@ -1229,30 +1285,320 @@ func TestRewriteRespBody_EmptyContentTypeUsesDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_NegativeContentLength(t *testing.T) {
|
||||
func TestRewriteRespBody_DefaultMaxChunkSize(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("original"),
|
||||
Type: "*",
|
||||
Replacement: []byte("replaced"),
|
||||
Replacement: []byte("rewritten"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "rewritten" {
|
||||
t.Errorf("body = %q, want %q (default 1MB maxChunkSize should buffer and rewrite)", string(got), "rewritten")
|
||||
}
|
||||
if resp.ContentLength != int64(len("rewritten")) {
|
||||
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_MaxChunkSize_Fits(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "*",
|
||||
Replacement: []byte("rewritten"),
|
||||
MaxChunkSize: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "rewritten" {
|
||||
t.Errorf("body = %q, want %q", string(got), "rewritten")
|
||||
}
|
||||
if resp.ContentLength != int64(len("rewritten")) {
|
||||
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
|
||||
}
|
||||
if resp.TransferEncoding != nil {
|
||||
t.Errorf("TransferEncoding = %v, want nil", resp.TransferEncoding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_MaxChunkSize_Overflow(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "*",
|
||||
Replacement: []byte("rewritten"),
|
||||
MaxChunkSize: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
t.Errorf("body = %q, want %q (unchanged, overflow)", string(got), "original")
|
||||
}
|
||||
if resp.ContentLength != -1 {
|
||||
t.Errorf("ContentLength = %d, want -1", resp.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_MaxChunkSize_Compressed_Fits(t *testing.T) {
|
||||
original := []byte("hello world")
|
||||
compressed := compressTestData(original, "gzip")
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}, "Content-Type": {"text/plain"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(bytes.NewReader(compressed)),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("hello"),
|
||||
Type: "*",
|
||||
Replacement: []byte("hi"),
|
||||
MaxChunkSize: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Body should be decompressed → rewritten → recompressed.
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
gr, err := gzip.NewReader(bytes.NewReader(got))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid gzip output: %v", err)
|
||||
}
|
||||
decoded, _ := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if string(decoded) != "hi world" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
|
||||
}
|
||||
if resp.ContentLength != int64(len(got)) {
|
||||
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len(got))
|
||||
}
|
||||
if resp.TransferEncoding != nil {
|
||||
t.Errorf("TransferEncoding = %v, want nil", resp.TransferEncoding)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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) {
|
||||
h := &Sniffer{
|
||||
Recorder: &noopRecorder{},
|
||||
@@ -1393,3 +1739,268 @@ func TestServeH2_InvalidPreface(t *testing.T) {
|
||||
t.Error("expected error from invalid h2 preface, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSE (Server-Sent Events) Tests
|
||||
// =============================================================================
|
||||
|
||||
|
||||
func TestRewriteRespBody_SSE_ContentType(t *testing.T) {
|
||||
// rewriteRespBody now rewrites SSE per-event via the universal wrapper.
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/event-stream"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("data: hello\n\n")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("data: hello"),
|
||||
Type: "*",
|
||||
Replacement: []byte("data: world"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "data: world\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRewriteBody(t *testing.T) {
|
||||
makeRewrite := func(rewriteType, match, replace string) chain.HTTPBodyRewriteSettings {
|
||||
return chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(match),
|
||||
Type: rewriteType,
|
||||
Replacement: []byte(replace),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("regex replacement streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: world\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple events streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: a\n\ndata: b\n\ndata: c\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "data: [ab]$", "data: x"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
expected := "data: x\n\ndata: x\n\ndata: c\n\n"
|
||||
if string(got) != expected {
|
||||
t.Errorf("body = %q, want %q", string(got), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple rewrite rules streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
makeRewrite("*", "world", "there"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: there\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: there\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content type filter streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
// text/html type rule — won't match text/event-stream.
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
{Type: "text/html", Pattern: regexp.MustCompile("hello"), Replacement: []byte("world")},
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: hello\n\n" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "data: hello\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no rewrites returns nil", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, nil, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
t.Error("expected nil for no rewrites")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content encoding returns nil", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
}, "text/event-stream", "gzip", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
t.Error("expected nil for Content-Encoding")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plugin rewriter streaming", func(t *testing.T) {
|
||||
rw := &mockRewriter{
|
||||
cb: func(b []byte) []byte { return []byte("data: rewritten") },
|
||||
}
|
||||
src := io.NopCloser(strings.NewReader("data: original\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
{Type: "*", Rewriter: rw},
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: rewritten\n\ndata: rewritten\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: rewritten\n\ndata: rewritten\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incomplete final event flushed", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: world\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-streaming body rewritten", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("hello world"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "hi"),
|
||||
}, "text/plain", "", 11, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "hi world" {
|
||||
t.Errorf("body = %q, want %q", string(got), "hi world")
|
||||
}
|
||||
if body.contentLength != int64(len("hi world")) {
|
||||
t.Errorf("contentLength = %d, want %d", body.contentLength, len("hi world"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chunked non-streaming uses default maxChunkSize", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("hello world"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "hi"),
|
||||
}, "text/plain", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body (default 1MB maxChunkSize)")
|
||||
}
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "hi world" {
|
||||
t.Errorf("body = %q, want %q", string(got), "hi world")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_CompressedEncodings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
encoding string
|
||||
}{
|
||||
{"gzip", "gzip"},
|
||||
{"deflate", "deflate"},
|
||||
{"br", "br"},
|
||||
{"zstd", "zstd"},
|
||||
}
|
||||
|
||||
input := []byte("hello world")
|
||||
rewrites := []chain.HTTPBodyRewriteSettings{
|
||||
{
|
||||
Type: "*",
|
||||
Pattern: regexp.MustCompile("hello"),
|
||||
Replacement: []byte("hi"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compressed := compressTestData(input, tt.encoding)
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": {tt.encoding}, "Content-Type": {"text/plain"}},
|
||||
ContentLength: int64(len(compressed)),
|
||||
Body: io.NopCloser(bytes.NewReader(compressed)),
|
||||
}
|
||||
if err := rewriteRespBody(context.Background(), resp, rewrites...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
// Output is recompressed; decompress to verify rewrite.
|
||||
decoded, err := decompressBody(got, tt.encoding)
|
||||
if err != nil {
|
||||
t.Fatalf("decompress: %v", err)
|
||||
}
|
||||
if string(decoded) != "hi world" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, conn net.Conn, opts ...HandleOp
|
||||
if ho.log != nil {
|
||||
ho.log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
|
||||
}
|
||||
return nil
|
||||
return errors.New("tls: sni is empty, closing connection")
|
||||
}
|
||||
ro.Host = host
|
||||
|
||||
@@ -321,6 +321,7 @@ func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHel
|
||||
}),
|
||||
WithHTTPKeepalive(true),
|
||||
WithNode(ho.node),
|
||||
WithBypass(ho.bypass),
|
||||
WithRecorderObject(ro),
|
||||
WithLog(log),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package relay
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-gost/gosocks5"
|
||||
"github.com/go-gost/relay"
|
||||
@@ -54,6 +55,19 @@ func UDPTunServerConn(c net.Conn) net.PacketConn {
|
||||
}
|
||||
}
|
||||
|
||||
// domainAddr is a net.Addr that preserves a domain name without resolving it.
|
||||
// Returned by udpTunConn.ReadFrom when a relay UDP datagram carries
|
||||
// ATYP=DOMAINNAME, so the relay handler can resolve it through the configured
|
||||
// resolver instead of leaking the query through the system 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)) }
|
||||
|
||||
func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
socksAddr := gosocks5.Addr{}
|
||||
header := gosocks5.UDPHeader{
|
||||
@@ -72,7 +86,11 @@ func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
if n > len(b) {
|
||||
n = copy(b, dgram.Data)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pipeConn is a net.Conn backed by bytes.Buffer — Write writes to buf, Read reads from buf.
|
||||
type pipeConn struct {
|
||||
net.Conn
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func newPipeConn() *pipeConn {
|
||||
return &pipeConn{}
|
||||
}
|
||||
func (c *pipeConn) Read(b []byte) (int, error) {
|
||||
n := copy(b, c.buf)
|
||||
c.buf = c.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
func (c *pipeConn) Write(b []byte) (int, error) {
|
||||
c.buf = append(c.buf, b...)
|
||||
return len(b), nil
|
||||
}
|
||||
func (c *pipeConn) Close() error { return nil }
|
||||
|
||||
func Test_udpTunConn_domainRoundTrip(t *testing.T) {
|
||||
pc1 := newPipeConn()
|
||||
server := UDPTunServerConn(pc1)
|
||||
|
||||
domain := &domainAddr{network: "udp", host: "dns.google", port: 53}
|
||||
_, err := server.WriteTo([]byte("hello"), domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pc2 := newPipeConn()
|
||||
pc2.Write(pc1.buf)
|
||||
|
||||
_, addr, err := UDPTunServerConn(pc2).ReadFrom(make([]byte, 1500))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
da, ok := addr.(*domainAddr)
|
||||
if !ok {
|
||||
t.Fatalf("expected *domainAddr, got %T: %s", addr, addr)
|
||||
}
|
||||
if da.host != "dns.google" || da.port != 53 {
|
||||
t.Fatalf("expected dns.google:53, got %s:%d", da.host, da.port)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_udpTunConn_ipRoundTrip(t *testing.T) {
|
||||
pc1 := newPipeConn()
|
||||
server := UDPTunServerConn(pc1)
|
||||
|
||||
ipAddr := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}
|
||||
_, err := server.WriteTo([]byte("hello"), ipAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pc2 := newPipeConn()
|
||||
pc2.Write(pc1.buf)
|
||||
|
||||
_, addr, err := UDPTunServerConn(pc2).ReadFrom(make([]byte, 1500))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := addr.(*net.UDPAddr); !ok {
|
||||
t.Fatalf("expected *net.UDPAddr, got %T: %s", addr, addr)
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
defer func() { cc.Close() }()
|
||||
|
||||
upstreamHost := host
|
||||
|
||||
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
|
||||
|
||||
@@ -122,6 +124,26 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
// When DNS override directs multiple domains to the same proxy IP,
|
||||
// the browser may reuse a keep-alive connection for a different host.
|
||||
// Re-dial to ensure requests reach the correct upstream.
|
||||
if reqHost := normalizeHost(req.Host, "80"); reqHost != upstreamHost {
|
||||
cc.Close()
|
||||
cc, err = dial(ctx, network, reqHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upstreamHost = reqHost
|
||||
ro.Host = reqHost
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
log = log.WithFields(map[string]any{
|
||||
"host": reqHost,
|
||||
"src": cc.LocalAddr().String(),
|
||||
"dst": cc.RemoteAddr().String(),
|
||||
})
|
||||
}
|
||||
|
||||
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, req, readTimeout, ro, &pStats, log); err != nil || shouldClose {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
@@ -52,7 +53,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, network string, conn net.Conn,
|
||||
if log != nil {
|
||||
log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
|
||||
}
|
||||
return nil
|
||||
return errors.New("tls: sni is empty, closing connection")
|
||||
}
|
||||
ro.Host = host
|
||||
|
||||
@@ -234,6 +235,7 @@ func (h *Sniffer) terminateTLS(ctx context.Context, network string, conn, cc net
|
||||
WithDialTLS(func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
}),
|
||||
WithBypass(ho.bypass),
|
||||
WithRecorderObject(ro),
|
||||
WithLog(log),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package socks
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-gost/core/common/bufpool"
|
||||
"github.com/go-gost/gosocks5"
|
||||
@@ -50,7 +51,11 @@ func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
if n > len(b) {
|
||||
n = copy(b, dgram.Data)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -106,6 +111,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.
|
||||
// NOTE: for server side,
|
||||
// the returned addr is the target address the client want to relay to.
|
||||
@@ -128,7 +147,11 @@ func (c *udpConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,25 @@ func (s *Session) Keepalive(interval, timeout time.Duration, retries int) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ping checks if the SSH session is alive by sending a keepalive request.
|
||||
// It blocks until a response is received or the timeout expires.
|
||||
// A nil error indicates the session is healthy.
|
||||
func (s *Session) Ping(timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultKeepaliveTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case err := <-s.ping():
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) ping() <-chan error {
|
||||
ch := make(chan error, 1)
|
||||
go func() {
|
||||
|
||||
@@ -59,6 +59,9 @@ func (l *mtcpListener) Init(md md.Metadata) (err error) {
|
||||
lc.SetMultipathTCP(true)
|
||||
l.logger.Debugf("mptcp enabled: %v", lc.MultipathTCP())
|
||||
}
|
||||
if l.md.reuseport {
|
||||
lc.Control = l.setReusePort
|
||||
}
|
||||
ln, err := lc.Listen(context.Background(), network, l.options.Addr)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package mtcp
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (l *mtcpListener) setReusePort(network, address string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
|
||||
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package mtcp
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func (l *mtcpListener) setReusePort(network, address string, c syscall.RawConn) error {
|
||||
return nil
|
||||
}
|
||||
@@ -13,8 +13,9 @@ const (
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
mptcp bool
|
||||
muxCfg *mux.Config
|
||||
mptcp bool
|
||||
reuseport bool
|
||||
muxCfg *mux.Config
|
||||
backlog int
|
||||
keepalive bool
|
||||
keepaliveIdle time.Duration
|
||||
@@ -24,6 +25,7 @@ type metadata struct {
|
||||
|
||||
func (l *mtcpListener) parseMetadata(md md.Metadata) (err error) {
|
||||
l.md.mptcp = mdutil.GetBool(md, "mptcp")
|
||||
l.md.reuseport = mdutil.GetBool(md, "reuseport")
|
||||
|
||||
l.md.muxCfg = &mux.Config{
|
||||
Version: mdutil.GetInt(md, "mux.version"),
|
||||
|
||||
@@ -53,7 +53,7 @@ func (l *redirectListener) Init(md md.Metadata) (err error) {
|
||||
network = "tcp4"
|
||||
}
|
||||
lc := net.ListenConfig{}
|
||||
if l.md.tproxy {
|
||||
if l.md.tproxy || l.md.reuseport {
|
||||
lc.Control = l.control
|
||||
}
|
||||
if l.md.mptcp {
|
||||
|
||||
@@ -2,8 +2,17 @@ package tcp
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
|
||||
if l.md.reuseport {
|
||||
return c.Control(func(fd uintptr) {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
|
||||
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,18 @@ import (
|
||||
|
||||
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1); err != nil {
|
||||
l.logger.Errorf("SetsockoptInt(SOL_IP, IP_TRANSPARENT, 1): %v", err)
|
||||
if l.md.tproxy {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1); err != nil {
|
||||
l.logger.Errorf("failed to set IP_TRANSPARENT: %v", err)
|
||||
}
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_IPV6, unix.IPV6_TRANSPARENT, 1); err != nil {
|
||||
l.logger.Errorf("failed to set IPV6_TRANSPARENT: %v", err)
|
||||
}
|
||||
}
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_IPV6, unix.IPV6_TRANSPARENT, 1); err != nil {
|
||||
l.logger.Errorf("SetsockoptInt(SOL_IPV6, IPV6_TRANSPARENT, 1): %v", err)
|
||||
if l.md.reuseport {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
|
||||
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
//go:build !linux && !darwin
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
|
||||
return errors.New("TProxy is not available on non-linux platform")
|
||||
if l.md.tproxy {
|
||||
return errors.New("TProxy is not available on non-linux platform")
|
||||
}
|
||||
if l.md.reuseport {
|
||||
return c.Control(func(fd uintptr) {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
|
||||
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func (l *redirectListener) control(network, address string, c syscall.RawConn) error {
|
||||
if l.md.tproxy {
|
||||
return errors.New("TProxy is not available on non-linux platform")
|
||||
}
|
||||
// SO_REUSEADDR is the default on Windows, so reuseport is a no-op.
|
||||
return nil
|
||||
}
|
||||
@@ -6,13 +6,15 @@ import (
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
tproxy bool
|
||||
mptcp bool
|
||||
tproxy bool
|
||||
mptcp bool
|
||||
reuseport bool
|
||||
}
|
||||
|
||||
func (l *redirectListener) parseMetadata(md mdata.Metadata) (err error) {
|
||||
l.md.tproxy = mdutil.GetBool(md, "tproxy")
|
||||
l.md.mptcp = mdutil.GetBool(md, "mptcp")
|
||||
l.md.reuseport = mdutil.GetBool(md, "reuseport")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package stdio implements a listener that wraps os.Stdin/os.Stdout as a
|
||||
// net.Listener. It accepts exactly one connection, making it suitable for
|
||||
// SSH ProxyCommand where the parent process (SSH) pipes the transport byte
|
||||
// stream through the child process's standard I/O.
|
||||
//
|
||||
// Usage: gost -L stdio://example.com:22 -F http://proxy:8080
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/logger"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.ListenerRegistry().Register("stdio", NewListener)
|
||||
}
|
||||
|
||||
type (
|
||||
stdioListener struct {
|
||||
conn net.Conn
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
accepted bool
|
||||
mu sync.Mutex
|
||||
laddr net.Addr
|
||||
logger logger.Logger
|
||||
md metadata
|
||||
options listener.Options
|
||||
}
|
||||
)
|
||||
|
||||
// NewListener creates a stdio listener. The returned listener wraps
|
||||
// os.Stdin and os.Stdout as a single-use net.Conn.
|
||||
func NewListener(opts ...listener.Option) listener.Listener {
|
||||
options := listener.Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
return &stdioListener{
|
||||
logger: options.Logger,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *stdioListener) Init(md md.Metadata) (err error) {
|
||||
if err = l.parseMetadata(md); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.laddr = &stdioAddr{addr: "stdio"}
|
||||
l.done = make(chan struct{})
|
||||
l.conn = &stdioConn{l: l}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Accept returns the stdio connection on the first call.
|
||||
// Subsequent calls block until the listener is closed.
|
||||
func (l *stdioListener) Accept() (net.Conn, error) {
|
||||
l.mu.Lock()
|
||||
if !l.accepted {
|
||||
l.accepted = true
|
||||
l.mu.Unlock()
|
||||
return l.conn, nil
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
<-l.done
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
|
||||
func (l *stdioListener) Addr() net.Addr {
|
||||
return l.laddr
|
||||
}
|
||||
|
||||
func (l *stdioListener) Close() error {
|
||||
l.close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// close signals listener completion. Idempotent and safe for concurrent use.
|
||||
func (l *stdioListener) close() {
|
||||
l.once.Do(func() { close(l.done) })
|
||||
}
|
||||
|
||||
// stdioConn implements net.Conn over os.Stdin and os.Stdout.
|
||||
type stdioConn struct {
|
||||
l *stdioListener
|
||||
}
|
||||
|
||||
func (c *stdioConn) Read(b []byte) (int, error) { return os.Stdin.Read(b) }
|
||||
func (c *stdioConn) Write(b []byte) (int, error) { return os.Stdout.Write(b) }
|
||||
|
||||
// Close terminates the process.
|
||||
//
|
||||
// A stdio listener serves exactly one connection: the process's own
|
||||
// stdin/stdout, piped from a parent such as SSH's ProxyCommand. When that
|
||||
// connection ends the parent has closed the pipe and there is nothing left
|
||||
// to serve, so the whole process must exit.
|
||||
//
|
||||
// Closing the listener alone is not enough: the service's accept loop runs
|
||||
// in a fire-and-forget goroutine (see x/service.Serve) and the process
|
||||
// blocks on the go-svc signal wait (see gost/cmd/gost/main.go), so it would
|
||||
// linger forever after the parent quits. os.Exit is the only reliable way
|
||||
// to terminate here. The handler's deferred stats/recording/logging already
|
||||
// ran before this Close (LIFO order), so nothing is lost.
|
||||
func (c *stdioConn) Close() error {
|
||||
c.l.close()
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *stdioConn) LocalAddr() net.Addr { return &stdioAddr{addr: "stdio"} }
|
||||
func (c *stdioConn) RemoteAddr() net.Addr { return &stdioAddr{addr: "pipe"} }
|
||||
|
||||
// Deadline methods are no-ops: os.Stdin/os.Stdout do not support
|
||||
// deadline-based I/O.
|
||||
func (c *stdioConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (c *stdioConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (c *stdioConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
// stdioAddr is a trivial net.Addr for stdio connections.
|
||||
type stdioAddr struct {
|
||||
addr string
|
||||
}
|
||||
|
||||
func (a *stdioAddr) Network() string { return "tcp" }
|
||||
func (a *stdioAddr) String() string { return a.addr }
|
||||
@@ -0,0 +1,13 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
md "github.com/go-gost/core/metadata"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
// reserved for future options (e.g., timeouts)
|
||||
}
|
||||
|
||||
func (l *stdioListener) parseMetadata(md md.Metadata) (err error) {
|
||||
return
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package tap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
@@ -68,7 +69,13 @@ func (l *tapListener) Init(md mdata.Metadata) (err error) {
|
||||
func (l *tapListener) listenLoop() {
|
||||
for {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
err := func() error {
|
||||
err := func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic in createTap: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ifce, name, ip, err := l.createTap()
|
||||
if err != nil {
|
||||
if ifce != nil {
|
||||
|
||||
@@ -56,6 +56,9 @@ func (l *tcpListener) Init(md md.Metadata) (err error) {
|
||||
lc.SetMultipathTCP(true)
|
||||
l.logger.Debugf("mptcp enabled: %v", lc.MultipathTCP())
|
||||
}
|
||||
if l.md.reuseport {
|
||||
lc.Control = l.setReusePort
|
||||
}
|
||||
ln, err := lc.Listen(context.Background(), network, l.options.Addr)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (l *tcpListener) setReusePort(network, address string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
|
||||
l.logger.Errorf("failed to set SO_REUSEPORT: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func (l *tcpListener) setReusePort(network, address string, c syscall.RawConn) error {
|
||||
return nil
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
mptcp bool
|
||||
mptcp bool
|
||||
reuseport bool
|
||||
|
||||
keepalive bool
|
||||
keepaliveIdle time.Duration
|
||||
@@ -18,6 +19,7 @@ type metadata struct {
|
||||
|
||||
func (l *tcpListener) parseMetadata(md md.Metadata) (err error) {
|
||||
l.md.mptcp = mdutil.GetBool(md, "mptcp")
|
||||
l.md.reuseport = mdutil.GetBool(md, "reuseport")
|
||||
|
||||
l.md.keepalive = mdutil.GetBool(md, "keepalive")
|
||||
l.md.keepaliveIdle = mdutil.GetDuration(md, "keepalive.idle")
|
||||
|
||||
@@ -2,6 +2,7 @@ package tun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
@@ -75,7 +76,13 @@ func (l *tunListener) Init(md mdata.Metadata) (err error) {
|
||||
func (l *tunListener) listenLoop(ready context.CancelCauseFunc) {
|
||||
for {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
err := func() error {
|
||||
err := func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic in createTun: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ifce, name, ip, err := l.createTun()
|
||||
if err != nil {
|
||||
if ifce != nil {
|
||||
|
||||
@@ -52,8 +52,17 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
|
||||
}
|
||||
}
|
||||
|
||||
if len(l.md.config.Net) > 0 {
|
||||
ipNet := l.md.config.Net[0]
|
||||
// Loop over every configured address so dual-stack (IPv4+IPv6) setups
|
||||
// install both addresses, selecting the netsh context by family: "ip"
|
||||
// for IPv4, "ipv6" for IPv6. A failing address is logged and skipped so
|
||||
// one bad entry doesn't prevent the rest from being applied; the first
|
||||
// successfully installed address is reported as the interface IP.
|
||||
//
|
||||
// Caveat: unlike Linux's netlink.AddrAdd, "netsh interface ip set
|
||||
// address" replaces rather than adds, so only a single IPv4 address is
|
||||
// supported (one IPv4 + one IPv6 works; multiple IPv4 addresses
|
||||
// silently overwrite).
|
||||
for _, ipNet := range l.md.config.Net {
|
||||
cmd := fmt.Sprintf("netsh interface ip set address name=%s "+
|
||||
"source=static addr=%s mask=%s gateway=none",
|
||||
name, ipNet.IP.String(), ipMask(ipNet.Mask))
|
||||
@@ -65,10 +74,19 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
|
||||
|
||||
args := strings.Split(cmd, " ")
|
||||
if er := exec.Command(args[0], args[1:]...).Run(); er != nil {
|
||||
err = fmt.Errorf("%s: %v", cmd, er)
|
||||
return
|
||||
l.log.Errorf("%s: %v", cmd, er)
|
||||
continue
|
||||
}
|
||||
ip = ipNet.IP
|
||||
if ip == nil {
|
||||
ip = ipNet.IP
|
||||
}
|
||||
}
|
||||
|
||||
// If not a single address could be configured, surface an error rather
|
||||
// than returning a nil IP (and thus a nil peer address) to the caller.
|
||||
if ip == nil && len(l.md.config.Net) > 0 {
|
||||
err = fmt.Errorf("failed to configure any address on interface %s", name)
|
||||
return
|
||||
}
|
||||
|
||||
if err = l.addRoutes(name, l.md.config.Gateway); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package tungo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
@@ -68,7 +69,13 @@ func (l *tunListener) Init(md mdata.Metadata) (err error) {
|
||||
func (l *tunListener) listenLoop(ready context.CancelCauseFunc) {
|
||||
for {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
err := func() error {
|
||||
err := func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic in createTun: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ifce, name, ip, err := l.createTun()
|
||||
if err != nil {
|
||||
if ifce != nil {
|
||||
|
||||
@@ -56,8 +56,17 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
|
||||
}
|
||||
}
|
||||
|
||||
if len(l.md.config.Net) > 0 {
|
||||
ipNet := l.md.config.Net[0]
|
||||
// Loop over every configured address so dual-stack (IPv4+IPv6) setups
|
||||
// install both addresses, selecting the netsh context by family: "ip"
|
||||
// for IPv4, "ipv6" for IPv6. A failing address is logged and skipped so
|
||||
// one bad entry doesn't prevent the rest from being applied; the first
|
||||
// successfully installed address is reported as the interface IP.
|
||||
//
|
||||
// Caveat: unlike Linux's netlink.AddrAdd, "netsh interface ip set
|
||||
// address" replaces rather than adds, so only a single IPv4 address is
|
||||
// supported (one IPv4 + one IPv6 works; multiple IPv4 addresses
|
||||
// silently overwrite).
|
||||
for _, ipNet := range l.md.config.Net {
|
||||
cmd := fmt.Sprintf("netsh interface ip set address name=%s "+
|
||||
"source=static addr=%s mask=%s",
|
||||
name, ipNet.IP.String(), ipMask(ipNet.Mask))
|
||||
@@ -73,10 +82,19 @@ func (l *tunListener) createTun() (ifce io.ReadWriteCloser, name string, ip net.
|
||||
l.log.Debugf("%s: %s", cmd, windows.ByteSliceToString(output))
|
||||
}
|
||||
if er != nil {
|
||||
err = fmt.Errorf("%s: %v", cmd, er)
|
||||
return
|
||||
l.log.Errorf("%s: %v", cmd, er)
|
||||
continue
|
||||
}
|
||||
ip = ipNet.IP
|
||||
if ip == nil {
|
||||
ip = ipNet.IP
|
||||
}
|
||||
}
|
||||
|
||||
// If not a single address could be configured, surface an error rather
|
||||
// than returning a nil IP (and thus a nil peer address) to the caller.
|
||||
if ip == nil && len(l.md.config.Net) > 0 {
|
||||
err = fmt.Errorf("failed to configure any address on interface %s", name)
|
||||
return
|
||||
}
|
||||
|
||||
if err = l.addRoutes(name, l.md.config.Gateway); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package unix
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/limiter"
|
||||
@@ -48,6 +49,12 @@ func (l *unixListener) Init(md md.Metadata) (err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if l.md.fileMode > 0 {
|
||||
if err = os.Chmod(l.options.Addr, l.md.fileMode); err != nil {
|
||||
ln.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ln = proxyproto.WrapListener(l.options.ProxyProtocol, ln, 10*time.Second)
|
||||
ln = metrics.WrapListener(l.options.Service, ln)
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
package unix
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
md "github.com/go-gost/core/metadata"
|
||||
)
|
||||
|
||||
type metadata struct{}
|
||||
type metadata struct {
|
||||
fileMode os.FileMode
|
||||
}
|
||||
|
||||
func (l *unixListener) parseMetadata(md md.Metadata) (err error) {
|
||||
if !md.IsExists("mode") {
|
||||
return
|
||||
}
|
||||
|
||||
switch v := md.Get("mode").(type) {
|
||||
case int:
|
||||
l.md.fileMode = os.FileMode(v)
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(v, 0, 32)
|
||||
l.md.fileMode = os.FileMode(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
+25
-1
@@ -2,9 +2,11 @@ package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdlog "log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/limiter"
|
||||
@@ -85,6 +87,12 @@ func (l *wsListener) Init(md md.Metadata) (err error) {
|
||||
Addr: l.options.Addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: l.md.readHeaderTimeout,
|
||||
// Route HTTP server errors (TLS handshake failures, malformed
|
||||
// requests) to the GOST logger instead of the default stderr
|
||||
// output via log.Printf. Without this, network scanners and
|
||||
// protocol filters probing the WSS port produce uncontrolled
|
||||
// noise: "http: TLS handshake error from ...".
|
||||
ErrorLog: stdlog.New(&logWriter{log: l.log}, "", 0),
|
||||
}
|
||||
|
||||
l.cqueue = make(chan net.Conn, l.md.backlog)
|
||||
@@ -186,7 +194,9 @@ func (l *wsListener) upgrade(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
conn, err := l.upgrader.Upgrade(w, r, l.md.header)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
// Upgrade failures (plain HTTP requests, websocket
|
||||
// handshake errors) are operational noise — log at Warn.
|
||||
log.Warn(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,3 +218,17 @@ func (l *wsListener) upgrade(w http.ResponseWriter, r *http.Request) {
|
||||
log.Warnf("connection queue is full, client %s discarded", conn.RemoteAddr())
|
||||
}
|
||||
}
|
||||
|
||||
// logWriter adapts a GOST logger to the io.Writer interface required by
|
||||
// http.Server.ErrorLog. It writes each log entry as a Debug-level message
|
||||
// so that HTTP server errors (TLS handshake failures, malformed requests)
|
||||
// are captured in GOST's logging pipeline instead of escaping to stderr
|
||||
// via log.Printf.
|
||||
type logWriter struct {
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func (w *logWriter) Write(p []byte) (n int, err error) {
|
||||
w.log.Debug(strings.TrimSpace(string(p)))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
+25
-2
@@ -4,6 +4,8 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/go-gost/core/metrics"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -30,10 +32,31 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
defaultMetrics metrics.Metrics = NewMetrics()
|
||||
enabled atomic.Bool
|
||||
defaultMetrics metrics.Metrics
|
||||
defaultRegistry *prometheus.Registry
|
||||
enabled atomic.Bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
defaultRegistry = prometheus.NewRegistry()
|
||||
|
||||
// Register Go and process collectors on our custom registry so the metrics
|
||||
// endpoint is self-contained. The process collector is platform-specific:
|
||||
// Linux/Windows use the standard procfs/Win32-based collector; all other
|
||||
// platforms (FreeBSD, Darwin, etc.) use a gopsutil-based collector.
|
||||
defaultRegistry.MustRegister(collectors.NewGoCollector())
|
||||
registerProcessCollector(defaultRegistry)
|
||||
|
||||
defaultMetrics = NewMetrics(defaultRegistry)
|
||||
}
|
||||
|
||||
// Registry returns the custom Prometheus registry that holds all GOST metrics
|
||||
// plus the Go and process collectors. The metrics HTTP endpoint should serve
|
||||
// from this registry rather than prometheus.DefaultGatherer.
|
||||
func Registry() *prometheus.Registry {
|
||||
return defaultRegistry
|
||||
}
|
||||
|
||||
// Enable enables or disables metrics collection globally. When disabled, all
|
||||
// GetCounter, GetGauge, and GetObserver calls return noop implementations.
|
||||
func Enable(b bool) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
psutil "github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
// gopsutilProcessCollector implements prometheus.Collector using gopsutil/v3
|
||||
// for cross-platform process metrics on non-Linux, non-Windows systems
|
||||
// (FreeBSD, Darwin, etc.) where procfs is unavailable.
|
||||
type gopsutilProcessCollector struct {
|
||||
pidFn func() (int, error)
|
||||
cpuTotal *prometheus.Desc
|
||||
openFDs *prometheus.Desc
|
||||
maxFDs *prometheus.Desc
|
||||
vsize *prometheus.Desc
|
||||
maxVsize *prometheus.Desc
|
||||
rss *prometheus.Desc
|
||||
startTime *prometheus.Desc
|
||||
reportErrors bool
|
||||
}
|
||||
|
||||
func getPID() (int, error) { return os.Getpid(), nil }
|
||||
|
||||
func newGopsutilProcessCollector() prometheus.Collector {
|
||||
return &gopsutilProcessCollector{
|
||||
pidFn: getPID,
|
||||
cpuTotal: prometheus.NewDesc(
|
||||
"process_cpu_seconds_total",
|
||||
"Total user and system CPU time spent in seconds.",
|
||||
nil, nil,
|
||||
),
|
||||
openFDs: prometheus.NewDesc(
|
||||
"process_open_fds",
|
||||
"Number of open file descriptors.",
|
||||
nil, nil,
|
||||
),
|
||||
maxFDs: prometheus.NewDesc(
|
||||
"process_max_fds",
|
||||
"Maximum number of open file descriptors.",
|
||||
nil, nil,
|
||||
),
|
||||
vsize: prometheus.NewDesc(
|
||||
"process_virtual_memory_bytes",
|
||||
"Virtual memory size in bytes.",
|
||||
nil, nil,
|
||||
),
|
||||
maxVsize: prometheus.NewDesc(
|
||||
"process_virtual_memory_max_bytes",
|
||||
"Maximum amount of virtual memory available in bytes.",
|
||||
nil, nil,
|
||||
),
|
||||
rss: prometheus.NewDesc(
|
||||
"process_resident_memory_bytes",
|
||||
"Resident memory size in bytes.",
|
||||
nil, nil,
|
||||
),
|
||||
startTime: prometheus.NewDesc(
|
||||
"process_start_time_seconds",
|
||||
"Start time of the process since unix epoch in seconds.",
|
||||
nil, nil,
|
||||
),
|
||||
reportErrors: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *gopsutilProcessCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.cpuTotal
|
||||
ch <- c.openFDs
|
||||
ch <- c.maxFDs
|
||||
ch <- c.vsize
|
||||
ch <- c.maxVsize
|
||||
ch <- c.rss
|
||||
ch <- c.startTime
|
||||
}
|
||||
|
||||
func (c *gopsutilProcessCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
pid, err := c.pidFn()
|
||||
if err != nil {
|
||||
c.reportError(ch, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := psutil.NewProcess(int32(pid))
|
||||
if err != nil {
|
||||
c.reportError(ch, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Memory metrics: RSS (resident) and VMS (virtual).
|
||||
if mem, err := p.MemoryInfo(); err == nil {
|
||||
ch <- prometheus.MustNewConstMetric(c.rss, prometheus.GaugeValue, float64(mem.RSS))
|
||||
ch <- prometheus.MustNewConstMetric(c.vsize, prometheus.GaugeValue, float64(mem.VMS))
|
||||
} else {
|
||||
c.reportError(ch, c.rss, err)
|
||||
}
|
||||
|
||||
// CPU time.
|
||||
if times, err := p.Times(); err == nil {
|
||||
cpuSeconds := times.User + times.System
|
||||
ch <- prometheus.MustNewConstMetric(c.cpuTotal, prometheus.CounterValue, cpuSeconds)
|
||||
} else {
|
||||
c.reportError(ch, c.cpuTotal, err)
|
||||
}
|
||||
|
||||
// Open file descriptors.
|
||||
if fds, err := p.NumFDs(); err == nil {
|
||||
ch <- prometheus.MustNewConstMetric(c.openFDs, prometheus.GaugeValue, float64(fds))
|
||||
} else {
|
||||
c.reportError(ch, c.openFDs, err)
|
||||
}
|
||||
|
||||
// Max file descriptors — not reliably available via gopsutil on all platforms.
|
||||
// Try RLIMIT_NOFILE via gopsutil's RlimitUsage.
|
||||
if rlimits, err := p.RlimitUsage(false); err == nil && len(rlimits) > 0 {
|
||||
for _, rl := range rlimits {
|
||||
switch rl.Resource {
|
||||
case psutil.RLIMIT_NOFILE:
|
||||
ch <- prometheus.MustNewConstMetric(c.maxFDs, prometheus.GaugeValue, float64(rl.Hard))
|
||||
case psutil.RLIMIT_AS:
|
||||
ch <- prometheus.MustNewConstMetric(c.maxVsize, prometheus.GaugeValue, float64(rl.Hard))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process start time.
|
||||
if ct, err := p.CreateTime(); err == nil {
|
||||
createTime := time.Unix(ct/1000, (ct%1000)*1e6)
|
||||
ch <- prometheus.MustNewConstMetric(c.startTime, prometheus.GaugeValue, float64(createTime.Unix()))
|
||||
} else {
|
||||
c.reportError(ch, c.startTime, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *gopsutilProcessCollector) reportError(ch chan<- prometheus.Metric, desc *prometheus.Desc, err error) {
|
||||
if !c.reportErrors {
|
||||
return
|
||||
}
|
||||
if desc == nil {
|
||||
desc = prometheus.NewInvalidDesc(err)
|
||||
}
|
||||
ch <- prometheus.NewInvalidMetric(desc, err)
|
||||
}
|
||||
|
||||
// registerProcessCollector registers a gopsutil-based process metrics collector
|
||||
// for platforms where procfs is unavailable.
|
||||
func registerProcessCollector(reg *prometheus.Registry) {
|
||||
reg.MustRegister(newGopsutilProcessCollector())
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build linux || windows
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
)
|
||||
|
||||
// registerProcessCollector registers the standard prometheus ProcessCollector
|
||||
// which uses procfs on Linux and Win32 syscalls on Windows.
|
||||
func registerProcessCollector(reg *prometheus.Registry) {
|
||||
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
|
||||
}
|
||||
+10
-5
@@ -16,8 +16,13 @@ type promMetrics struct {
|
||||
}
|
||||
|
||||
// NewMetrics returns a Prometheus-based Metrics implementation. All metrics are
|
||||
// automatically registered with the default Prometheus registry.
|
||||
func NewMetrics() metrics.Metrics {
|
||||
// registered with the given registry. Use nil to register with the default
|
||||
// Prometheus registry.
|
||||
func NewMetrics(reg *prometheus.Registry) metrics.Metrics {
|
||||
if reg == nil {
|
||||
reg = prometheus.DefaultRegisterer.(*prometheus.Registry)
|
||||
}
|
||||
|
||||
host, _ := os.Hostname()
|
||||
m := &promMetrics{
|
||||
host: host,
|
||||
@@ -95,13 +100,13 @@ func NewMetrics() metrics.Metrics {
|
||||
},
|
||||
}
|
||||
for k := range m.gauges {
|
||||
prometheus.MustRegister(m.gauges[k])
|
||||
reg.MustRegister(m.gauges[k])
|
||||
}
|
||||
for k := range m.counters {
|
||||
prometheus.MustRegister(m.counters[k])
|
||||
reg.MustRegister(m.counters[k])
|
||||
}
|
||||
for k := range m.histograms {
|
||||
prometheus.MustRegister(m.histograms[k])
|
||||
reg.MustRegister(m.histograms[k])
|
||||
}
|
||||
|
||||
return m
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/service"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
xmetrics "github.com/go-gost/x/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -46,6 +49,8 @@ type metricService struct {
|
||||
}
|
||||
|
||||
// NewService creates a metrics Service that exposes Prometheus metrics over HTTP.
|
||||
// It serves from the GOST custom registry rather than the default one so that
|
||||
// process metrics are available on all platforms.
|
||||
func NewService(network, addr string, opts ...Option) (service.Service, error) {
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
@@ -82,7 +87,12 @@ func NewService(network, addr string, opts ...Option) (service.Service, error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
promhttp.Handler().ServeHTTP(w, r)
|
||||
|
||||
reg := xmetrics.Registry()
|
||||
if reg == nil {
|
||||
reg = prometheus.DefaultRegisterer.(*prometheus.Registry)
|
||||
}
|
||||
promhttp.HandlerFor(reg, promhttp.HandlerOpts{}).ServeHTTP(w, r)
|
||||
}))
|
||||
return &metricService{
|
||||
s: &http.Server{
|
||||
|
||||
@@ -2,6 +2,8 @@ package wrapper
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
xmetrics "github.com/go-gost/x/metrics"
|
||||
)
|
||||
|
||||
type listener struct {
|
||||
@@ -10,10 +12,11 @@ type listener struct {
|
||||
}
|
||||
|
||||
// WrapListener wraps a net.Listener so that all accepted connections are
|
||||
// automatically wrapped with WrapConn for metrics tracking. If ln is nil,
|
||||
// nil is returned.
|
||||
// automatically wrapped with WrapConn for metrics tracking. If metrics are
|
||||
// not enabled, ln is returned unchanged to allow splice(2) optimization
|
||||
// on bare *net.TCPConn. If ln is nil, nil is returned.
|
||||
func WrapListener(service string, ln net.Listener) net.Listener {
|
||||
if ln == nil {
|
||||
if ln == nil || !xmetrics.IsEnabled() {
|
||||
return ln
|
||||
}
|
||||
return &listener{
|
||||
|
||||
@@ -10,6 +10,23 @@ import (
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
)
|
||||
|
||||
// MetadataRecorder wraps a Recorder and automatically appends metadata
|
||||
// to every Record call.
|
||||
type MetadataRecorder struct {
|
||||
recorder.Recorder
|
||||
Metadata any
|
||||
}
|
||||
|
||||
func (r *MetadataRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
|
||||
if r.Recorder == nil {
|
||||
return nil
|
||||
}
|
||||
if r.Metadata == nil {
|
||||
return r.Recorder.Record(ctx, b, opts...)
|
||||
}
|
||||
return r.Recorder.Record(ctx, b, append(opts, recorder.MetadataRecordOption(r.Metadata))...)
|
||||
}
|
||||
|
||||
const (
|
||||
RecorderServiceHandler = "recorder.service.handler"
|
||||
RecorderServiceHandlerSerial = "recorder.service.handler.serial"
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
reg "github.com/go-gost/core/registry"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/core/router"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/sd"
|
||||
"github.com/go-gost/core/service"
|
||||
"github.com/go-gost/x/limiter/quota"
|
||||
@@ -46,6 +47,7 @@ var (
|
||||
resolverReg reg.Registry[resolver.Resolver] = new(resolverRegistry)
|
||||
hostsReg reg.Registry[hosts.HostMapper] = new(hostsRegistry)
|
||||
recorderReg reg.Registry[recorder.Recorder] = new(recorderRegistry)
|
||||
rewriterReg reg.Registry[rewriter.Rewriter] = new(rewriterRegistry)
|
||||
|
||||
trafficLimiterReg reg.Registry[traffic.TrafficLimiter] = new(trafficLimiterRegistry)
|
||||
connLimiterReg reg.Registry[conn.ConnLimiter] = new(connLimiterRegistry)
|
||||
@@ -187,6 +189,11 @@ func RecorderRegistry() reg.Registry[recorder.Recorder] {
|
||||
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.
|
||||
func TrafficLimiterRegistry() reg.Registry[traffic.TrafficLimiter] {
|
||||
return trafficLimiterReg
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user