Files
ginuerzh 17909c4f95 fix(nodes): explicit reference check, trim lookup, deep-copy global definitions
IsReference no longer uses reflect.DeepEqual; ResolveConfig trims the name before registry lookup; cloneConfig deep-copies all nested fields so hop parsing never mutates shared globals. Also documents the intentional fail-fast vs warn-and-skip error semantics.
2026-10-07 01:36:45 +00:00

676 lines
18 KiB
Go

package node
import (
"io"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/registry"
// Register connector and dialer implementations needed for node parsing.
_ "github.com/go-gost/x/connector/http"
_ "github.com/go-gost/x/dialer/tcp"
)
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
m.Run()
}
func testLogger() logger.Logger {
return xlogger.NewLogger(xlogger.OutputOption(io.Discard))
}
func TestParseNode_Nil(t *testing.T) {
n, err := ParseNode("test-hop", nil, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != nil {
t.Fatal("expected nil for nil config")
}
}
func TestParseNode_Defaults(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "node1",
Addr: "example.com:8080",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_DefaultsEmptyName(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "",
Addr: "example.com:8080",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_DefaultsEmptyAddr(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "no-addr",
Addr: "",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_ExplicitConnector(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "explicit-conn",
Addr: "example.com:8080",
Connector: &config.ConnectorConfig{
Type: "http",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_UnregisteredConnector(t *testing.T) {
_, err := ParseNode("test-hop", &config.NodeConfig{
Name: "bad-conn",
Addr: "example.com:8080",
Connector: &config.ConnectorConfig{
Type: "nonexistent-connector-type",
},
}, testLogger())
if err == nil {
t.Fatal("expected error for unregistered connector type")
}
}
func TestParseNode_UnregisteredDialer(t *testing.T) {
_, err := ParseNode("test-hop", &config.NodeConfig{
Name: "bad-dial",
Addr: "example.com:8080",
Dialer: &config.DialerConfig{
Type: "nonexistent-dialer-type",
},
}, testLogger())
if err == nil {
t.Fatal("expected error for unregistered dialer type")
}
}
func TestParseNode_ExplicitDialer(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "explicit-dial",
Addr: "example.com:8080",
Dialer: &config.DialerConfig{
Type: "tcp",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithBypass(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "bypass-node",
Addr: "example.com:8080",
Bypass: "bp1",
Bypasses: []string{"bp2", "bp3"},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithResolver(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "resolver-node",
Addr: "example.com:8080",
Resolver: "my-resolver",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHosts(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "hosts-node",
Addr: "example.com:8080",
Hosts: "my-hosts",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithNetwork(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "network-node",
Addr: "example.com:8080",
Network: "tcp",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithMetadata(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "meta-node",
Addr: "example.com:8080",
Metadata: map[string]any{
parsing.MDKeySoMark: 1234,
parsing.MDKeyInterface: "eth0",
parsing.MDKeyNetns: "myns",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithSockOpts(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "sockopts-node",
Addr: "example.com:8080",
SockOpts: &config.SockOptsConfig{
Mark: 9999,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithFilter(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "filter-node",
Addr: "example.com:8080",
Filter: &config.NodeFilterConfig{
Protocol: "http",
Host: "*.example.com",
Path: "/api",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithFilterStarBare(t *testing.T) {
// "*example.com" should become ".example.com"
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "star-bare-node",
Addr: "example.com:8080",
Filter: &config.NodeFilterConfig{
Host: "*example.com",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithMatcher(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "matcher-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: "dstport==80",
Priority: 10,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithMatcherAutoPriority(t *testing.T) {
// Priority auto-set to len(rule) when zero
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "auto-prio-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: "dstport==443",
Priority: 0,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithMatcherBodySize(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "body-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
// Escaped predicate form; in YAML users write the backtick
// equivalent: BodyRegexp(`"model":"gpt-4"`).
Rule: `BodyRegexp("\"model\":\"gpt-4\"")`,
BodySize: 65536,
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
if got := n.Options().MatcherBodySize; got != 65536 {
t.Errorf("MatcherBodySize = %d, want 65536", got)
}
}
func TestParseNode_WithMatcherBodySize_Clamped(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "body-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: `BodyRegexp("foo")`,
BodySize: 10 * 1024 * 1024, // 10MB, above the 1MB cap
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
if got := n.Options().MatcherBodySize; got != MaxMatcherBodySize {
t.Errorf("MatcherBodySize = %d, want %d (clamped)", got, MaxMatcherBodySize)
}
}
func TestParseNode_WithHTTPNode(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "http-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
Host: "custom.example.com",
RequestHeader: map[string]string{"X-Custom": "value"},
ResponseHeader: map[string]string{"X-Response": "value"},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHTTPAuth(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "http-auth-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
Auth: &config.AuthConfig{
Username: "user",
Password: "pass",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHTTPDeprecatedHeader(t *testing.T) {
// Deprecated Header field should populate RequestHeader
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "http-deprecated-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
Header: map[string]string{"X-Old": "value"},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_WithHeaderRewrite(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "hdr-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
RewriteRequestHeader: []config.HTTPHeaderRewriteConfig{
{Name: "(?i)^referer$", Match: ".*", Replacement: ""},
},
RewriteResponseHeader: []config.HTTPHeaderRewriteConfig{
{Name: "(?i)^location$", Match: "https://github.com", Replacement: "https://127.0.0.1:8080"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
settings := n.Options().HTTP
if settings == nil {
t.Fatal("expected HTTP settings")
}
if len(settings.RewriteRequestHeader) != 1 || len(settings.RewriteResponseHeader) != 1 {
t.Fatalf("rewrite header counts = %d/%d, want 1/1", len(settings.RewriteRequestHeader), len(settings.RewriteResponseHeader))
}
}
func TestParseHeaderRewrites_EmptyNameNoRewriter(t *testing.T) {
// Empty Name with no Rewriter must not be appended (silent no-op guard).
out := parseHeaderRewrites([]config.HTTPHeaderRewriteConfig{
{Name: "", Match: ".*", Replacement: ""},
}, testLogger())
if len(out) != 0 {
t.Fatalf("out = %d rules, want 0", len(out))
}
}
func TestParseNode_WithTLSNode(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "tls-node",
Addr: "example.com:8080",
TLS: &config.TLSNodeConfig{
ServerName: "secure.example.com",
Secure: true,
Options: &config.TLSOptions{
MinVersion: "1.2",
MaxVersion: "1.3",
CipherSuites: []string{"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"},
ALPN: []string{"h2", "http/1.1"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_TLSServerNameFromAddr(t *testing.T) {
// When connector/dialer TLS ServerName is empty, it defaults from addr hostname.
// The server name is set on the connector's TLS config (internal), so the best
// verification is that ParseNode does not error and returns a node.
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "tls-servername-node",
Addr: "tls.example.com:443",
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_ConnectorAuth(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "conn-auth-node",
Addr: "example.com:8080",
Connector: &config.ConnectorConfig{
Type: "http",
Auth: &config.AuthConfig{
Username: "connuser",
Password: "connpass",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_DialerAuth(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "dial-auth-node",
Addr: "example.com:8080",
Dialer: &config.DialerConfig{
Type: "tcp",
Auth: &config.AuthConfig{
Username: "dialuser",
Password: "dialpass",
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestParseNode_InvalidMatcherRule(t *testing.T) {
// An invalid matcher rule sets priority to -1
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "invalid-matcher-node",
Addr: "example.com:8080",
Matcher: &config.NodeMatcherConfig{
Rule: "",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node even with empty matcher rule")
}
}
func TestParseNode_DeprecatedFilterFields(t *testing.T) {
// Deprecated filter fields: Protocol/Host/Path via Filter
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "deprecated-node",
Addr: "example.com:8080",
Filter: &config.NodeFilterConfig{
Protocol: "socks5",
Host: "*.example.com",
Path: "/proxy",
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
}
func TestFilterToMatcherRule(t *testing.T) {
tests := []struct {
name string
filter *config.NodeFilterConfig
want string
}{
{"nil", nil, ""},
{"all empty", &config.NodeFilterConfig{}, ""},
{"host only", &config.NodeFilterConfig{Host: "example.com"}, "Host(`example.com`)"},
{"dot host", &config.NodeFilterConfig{Host: ".example.com"},
"Host(`example.com`) || Host(`.example.com`)"},
{"star host", &config.NodeFilterConfig{Host: "*.example.com"},
"Host(`example.com`) || Host(`.example.com`)"},
{"protocol only", &config.NodeFilterConfig{Protocol: "ssh"}, "Proto(`ssh`)"},
{"path only", &config.NodeFilterConfig{Path: "/api"}, "PathPrefix(`/api`)"},
{"combined", &config.NodeFilterConfig{
Host: ".example.com",
Protocol: "http",
Path: "/api",
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
{"star combined", &config.NodeFilterConfig{
Host: "*.example.com",
Protocol: "http",
Path: "/api",
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filterToMatcherRule(tt.filter); got != tt.want {
t.Errorf("filterToMatcherRule(%v) = %q, want %q", tt.filter, got, tt.want)
}
})
}
}
func TestIsReference(t *testing.T) {
tests := []struct {
name string
cfg *config.NodeConfig
want bool
}{
{"nil", nil, false},
{"empty name", &config.NodeConfig{}, false},
{"blank name", &config.NodeConfig{Name: " "}, false},
{"name only", &config.NodeConfig{Name: "shared"}, true},
{"name and addr", &config.NodeConfig{Name: "shared", Addr: "example.com:80"}, false},
{"name and network", &config.NodeConfig{Name: "shared", Network: "tcp"}, false},
{"name and empty-but-non-nil connector", &config.NodeConfig{Name: "shared", Connector: &config.ConnectorConfig{}}, false},
{"name and empty bypasses slice", &config.NodeConfig{Name: "shared", Bypasses: []string{}}, true},
{"name and matcher", &config.NodeConfig{Name: "shared", Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsReference(tt.cfg); got != tt.want {
t.Errorf("IsReference(%+v) = %v, want %v", tt.cfg, got, tt.want)
}
})
}
}
func TestResolveConfig_TrimsName(t *testing.T) {
const name = "spaced-global-node"
registry.NodeRegistry().Register(name, &config.NodeConfig{Name: name, Addr: "example.com:8080"})
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
got, err := ResolveConfig(&config.NodeConfig{Name: " " + name + " "})
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if got == nil || got.Addr != "example.com:8080" {
t.Fatalf("resolved = %+v, want addr %q", got, "example.com:8080")
}
}
func TestResolveConfig_DeepCopyIsolation(t *testing.T) {
const name = "rich-global-node"
global := &config.NodeConfig{
Name: name,
Addr: "example.com:8080",
Metadata: map[string]any{"k": "v"},
Connector: &config.ConnectorConfig{
Type: "http",
Auth: &config.AuthConfig{Username: "u"},
Metadata: map[string]any{"ck": "cv"},
TLS: &config.TLSConfig{ServerName: "sni.example", Options: &config.TLSOptions{ALPN: []string{"h2"}}},
},
Dialer: &config.DialerConfig{
Type: "tcp",
Metadata: map[string]any{"dk": "dv"},
},
Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"},
HTTP: &config.HTTPNodeConfig{
Host: "h",
RequestHeader: map[string]string{"A": "b"},
Auth: &config.AuthConfig{Username: "hu"},
},
TLS: &config.TLSNodeConfig{ServerName: "tls.example", Options: &config.TLSOptions{MinVersion: "1.2"}},
Probe: &config.ProbeConfig{Type: "tcp", Addr: "example.com:80", HTTPHeaders: map[string]string{"H": "v"}},
}
registry.NodeRegistry().Register(name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
got, err := ResolveConfig(&config.NodeConfig{Name: name})
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
// Mutate every nested value on the copy; the global must not change.
got.Metadata["k"] = "mut"
got.Connector.Auth.Username = "mut"
got.Connector.Metadata["ck"] = "mut"
got.Connector.TLS.Options.ALPN[0] = "mut"
got.Dialer.Metadata["dk"] = "mut"
got.Matcher.Rule = "mut"
got.HTTP.RequestHeader["A"] = "mut"
got.HTTP.Auth.Username = "mut"
got.TLS.Options.MinVersion = "mut"
got.Probe.HTTPHeaders["H"] = "mut"
if global.Metadata["k"] != "v" ||
global.Connector.Auth.Username != "u" ||
global.Connector.Metadata["ck"] != "cv" ||
global.Connector.TLS.Options.ALPN[0] != "h2" ||
global.Dialer.Metadata["dk"] != "dv" ||
global.Matcher.Rule != "Host(`x`)" ||
global.HTTP.RequestHeader["A"] != "b" ||
global.HTTP.Auth.Username != "hu" ||
global.TLS.Options.MinVersion != "1.2" ||
global.Probe.HTTPHeaders["H"] != "v" {
t.Fatalf("global definition was mutated via the resolved copy: %+v", global)
}
}