Files
x/config/cmd/cmd_test.go
T

1952 lines
51 KiB
Go

package cmd
import (
"encoding/base64"
"net/url"
"testing"
"github.com/go-gost/x/config"
)
func TestDecodeSIP002Auth(t *testing.T) {
tests := []struct {
name string
rawURL string
wantUsername string
wantPassword string
wantNil bool
}{
{
name: "SIP002 URL-safe base64 without padding",
rawURL: "ss://" + base64.RawURLEncoding.EncodeToString([]byte("aes-256-gcm:password")) + "@server:8388",
wantUsername: "aes-256-gcm",
wantPassword: "password",
},
{
name: "SIP002 standard base64 with padding",
rawURL: "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:pass")) + "@server:8388",
wantUsername: "aes-256-gcm",
wantPassword: "pass",
},
{
name: "standard format with password (not SIP002)",
rawURL: "ss://aes-256-gcm:password@server:8388",
wantNil: true,
},
{
name: "no userinfo",
rawURL: "ss://server:8388",
wantNil: true,
},
{
name: "invalid base64",
rawURL: "ss://not-valid!!!@server:8388",
wantNil: true,
},
{
name: "base64 without colon in decoded",
rawURL: "ss://" + base64.RawURLEncoding.EncodeToString([]byte("nocolon")) + "@server:8388",
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.rawURL)
if err != nil {
t.Fatalf("url.Parse(%q): %v", tt.rawURL, err)
}
got := decodeSIP002Auth(u)
if tt.wantNil {
if got != nil {
t.Errorf("decodeSIP002Auth() = %+v, want nil", got)
}
return
}
if got == nil {
t.Fatal("decodeSIP002Auth() = nil, want non-nil")
}
if got.Username != tt.wantUsername {
t.Errorf("Username = %q, want %q", got.Username, tt.wantUsername)
}
if got.Password != tt.wantPassword {
t.Errorf("Password = %q, want %q", got.Password, tt.wantPassword)
}
})
}
}
func TestParseAuthFromCmd(t *testing.T) {
tests := []struct {
name string
input string
wantUsername string
wantPassword string
wantErr bool
}{
{
name: "StdEncoding with padding",
input: base64.StdEncoding.EncodeToString([]byte("user:pass")),
wantUsername: "user",
wantPassword: "pass",
},
{
name: "RawURLEncoding without padding",
input: base64.RawURLEncoding.EncodeToString([]byte("user:pass")),
wantUsername: "user",
wantPassword: "pass",
},
{
name: "invalid base64",
input: "not-valid!!!",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAuthFromCmd(tt.input)
if tt.wantErr {
if err == nil {
t.Error("parseAuthFromCmd() error = nil, want error")
}
return
}
if err != nil {
t.Fatalf("parseAuthFromCmd() error = %v", err)
}
if got.Username != tt.wantUsername {
t.Errorf("Username = %q, want %q", got.Username, tt.wantUsername)
}
if got.Password != tt.wantPassword {
t.Errorf("Password = %q, want %q", got.Password, tt.wantPassword)
}
})
}
}
func TestBuildNodeConfigSIP002(t *testing.T) {
encoded := base64.RawURLEncoding.EncodeToString([]byte("aes-256-gcm:password"))
rawURL := "ss://" + encoded + "@server:8388"
u, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Connector == nil || node.Connector.Auth == nil {
t.Fatal("node.Connector.Auth is nil")
}
if node.Connector.Auth.Username != "aes-256-gcm" {
t.Errorf("Username = %q, want %q", node.Connector.Auth.Username, "aes-256-gcm")
}
if node.Connector.Auth.Password != "password" {
t.Errorf("Password = %q, want %q", node.Connector.Auth.Password, "password")
}
}
func TestBuildNodeConfigStandardAuth(t *testing.T) {
rawURL := "http://user:pass@server:8080"
u, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Connector == nil || node.Connector.Auth == nil {
t.Fatal("node.Connector.Auth is nil")
}
if node.Connector.Auth.Username != "user" {
t.Errorf("Username = %q, want %q", node.Connector.Auth.Username, "user")
}
if node.Connector.Auth.Password != "pass" {
t.Errorf("Password = %q, want %q", node.Connector.Auth.Password, "pass")
}
}
func TestBuildServiceConfigSIP002(t *testing.T) {
encoded := base64.RawURLEncoding.EncodeToString([]byte("aes-256-gcm:password"))
rawURL := "ss://" + encoded + "@:8388"
u, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) == 0 {
t.Fatal("no services returned")
}
auth := svcs[0].Handler.Auth
if auth == nil {
t.Fatal("Handler.Auth is nil")
}
if auth.Username != "aes-256-gcm" {
t.Errorf("Username = %q, want %q", auth.Username, "aes-256-gcm")
}
if auth.Password != "password" {
t.Errorf("Password = %q, want %q", auth.Password, "password")
}
}
func TestNorm(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "whitespace only",
input: " ",
wantErr: true,
},
{
name: "host:port without scheme",
input: "host:8080",
want: "auto://host:8080",
},
{
name: "colon prefix",
input: ":8080",
want: "auto://:8080",
},
{
name: "https scheme rewrites to http+tls",
input: "https://host:443",
want: "http+tls://host:443",
},
{
name: "http scheme unchanged",
input: "http://host:8080",
want: "http://host:8080",
},
{
name: "with query params",
input: "http://host:8080?key=val",
want: "http://host:8080?key=val",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Norm(tt.input)
if tt.wantErr {
if err == nil {
t.Error("Norm() error = nil, want error")
}
return
}
if err != nil {
t.Fatalf("Norm() error = %v", err)
}
if got.String() != tt.want {
t.Errorf("Norm() = %q, want %q", got.String(), tt.want)
}
})
}
}
func TestCutHost(t *testing.T) {
tests := []struct {
name string
input string
wantHost string
wantRemain string
}{
{
name: "empty string",
input: "",
wantHost: "",
wantRemain: "",
},
{
name: "scheme://host:port",
input: "http://host:8080",
wantHost: "host:8080",
wantRemain: "http://",
},
{
name: "scheme://host:port/path",
input: "http://host:8080/path",
wantHost: "host:8080",
wantRemain: "http:///path",
},
{
name: "scheme://host:port?query",
input: "http://host:8080?key=val",
wantHost: "host:8080",
wantRemain: "http://?key=val",
},
{
name: "with user:pass@host",
input: "http://user:pass@host:8080",
wantHost: "host:8080",
wantRemain: "http://user:pass@",
},
{
name: "with user:pass@host/path?query",
input: "http://user:pass@host:8080/path?key=val",
wantHost: "host:8080",
wantRemain: "http://user:pass@/path?key=val",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
host, remain := cutHost(tt.input)
if host != tt.wantHost {
t.Errorf("host = %q, want %q", host, tt.wantHost)
}
if remain != tt.wantRemain {
t.Errorf("remain = %q, want %q", remain, tt.wantRemain)
}
})
}
}
func TestParseSelector(t *testing.T) {
tests := []struct {
name string
input map[string]any
wantNil bool
wantStrat string
wantFails int
wantDurGt bool // failTimeout > 0
}{
{
name: "no params",
input: map[string]any{},
wantNil: true,
},
{
name: "only strategy",
input: map[string]any{
"strategy": "rr",
},
wantStrat: "rr",
wantFails: 1,
wantDurGt: true,
},
{
name: "only maxFails",
input: map[string]any{
"max_fails": 3,
},
wantStrat: "round",
wantFails: 3,
wantDurGt: true,
},
{
name: "all params with failTimeout",
input: map[string]any{
"strategy": "hash",
"maxFails": 5,
"failTimeout": "10s",
},
wantStrat: "hash",
wantFails: 5,
wantDurGt: true,
},
{
name: "fail_timeout with underscore",
input: map[string]any{
"strategy": "fifo",
"max_fails": 2,
"fail_timeout": "15s",
},
wantStrat: "fifo",
wantFails: 2,
wantDurGt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseSelector(tt.input)
if tt.wantNil {
if got != nil {
t.Errorf("parseSelector() = %+v, want nil", got)
}
return
}
if got == nil {
t.Fatal("parseSelector() = nil, want non-nil")
}
if got.Strategy != tt.wantStrat {
t.Errorf("Strategy = %q, want %q", got.Strategy, tt.wantStrat)
}
if got.MaxFails != tt.wantFails {
t.Errorf("MaxFails = %d, want %d", got.MaxFails, tt.wantFails)
}
if tt.wantDurGt && got.FailTimeout <= 0 {
t.Errorf("FailTimeout = %v, want > 0", got.FailTimeout)
}
})
}
}
func TestCopyMap(t *testing.T) {
t.Run("nil map", func(t *testing.T) {
if copyMap(nil) != nil {
t.Error("copyMap(nil) should be nil")
}
})
t.Run("non-nil map", func(t *testing.T) {
orig := map[string]any{"key": "val"}
c := copyMap(orig)
if c["key"] != "val" {
t.Errorf("value = %v, want %v", c["key"], "val")
}
// mutate copy, verify original unchanged
c["key"] = "newval"
if orig["key"] != "val" {
t.Error("original map was mutated")
}
})
}
func TestCopyTLS(t *testing.T) {
t.Run("nil", func(t *testing.T) {
if copyTLS(nil) != nil {
t.Error("copyTLS(nil) should be nil")
}
})
t.Run("non-nil", func(t *testing.T) {
orig := &config.TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}
c := copyTLS(orig)
if c.CertFile != "cert.pem" || c.KeyFile != "key.pem" {
t.Error("copyTLS did not copy fields")
}
c.CertFile = "other.pem"
if orig.CertFile != "cert.pem" {
t.Error("original TLSConfig was mutated")
}
})
}
func TestCopyConnectorConfig(t *testing.T) {
t.Run("nil", func(t *testing.T) {
if copyConnectorConfig(nil) != nil {
t.Error("copyConnectorConfig(nil) should be nil")
}
})
t.Run("non-nil deep copy", func(t *testing.T) {
orig := &config.ConnectorConfig{
Type: "http",
Auth: &config.AuthConfig{Username: "u", Password: "p"},
Metadata: map[string]any{"k": "v"},
}
c := copyConnectorConfig(orig)
if c.Type != "http" {
t.Errorf("Type = %q, want %q", c.Type, "http")
}
if c.Auth.Username != "u" || c.Auth.Password != "p" {
t.Error("Auth not copied")
}
// mutate copy, verify original unchanged
c.Auth.Username = "x"
if orig.Auth.Username != "u" {
t.Error("original auth was mutated")
}
c.Metadata["k"] = "x"
if orig.Metadata["k"] != "v" {
t.Error("original metadata was mutated")
}
})
}
func TestCopyDialerConfig(t *testing.T) {
t.Run("nil", func(t *testing.T) {
if copyDialerConfig(nil) != nil {
t.Error("copyDialerConfig(nil) should be nil")
}
})
t.Run("non-nil deep copy", func(t *testing.T) {
orig := &config.DialerConfig{
Type: "tls",
TLS: &config.TLSConfig{ServerName: "example.com"},
Auth: &config.AuthConfig{Username: "u"},
Metadata: map[string]any{"k": "v"},
}
c := copyDialerConfig(orig)
if c.TLS.ServerName != "example.com" {
t.Error("TLS not copied")
}
c.TLS.ServerName = "other.com"
if orig.TLS.ServerName != "example.com" {
t.Error("original TLS was mutated")
}
c.Auth.Username = "x"
if orig.Auth.Username != "u" {
t.Error("original auth was mutated")
}
})
}
func TestBuildNodeConfig_NilMap(t *testing.T) {
// nil m should not panic
rawURL := "http://server:8080"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, nil)
if err != nil {
t.Fatalf("buildNodeConfig(nil m): %v", err)
}
if node.Addr != "server:8080" {
t.Errorf("Addr = %q, want %q", node.Addr, "server:8080")
}
if node.Connector.Type != "http" {
t.Errorf("Connector.Type = %q, want %q", node.Connector.Type, "http")
}
}
func TestBuildServiceConfigStandardAuth(t *testing.T) {
rawURL := "http://user:pass@:8080"
u, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) == 0 {
t.Fatal("no services returned")
}
auth := svcs[0].Handler.Auth
if auth == nil {
t.Fatal("Handler.Auth is nil")
}
if auth.Username != "user" {
t.Errorf("Username = %q, want %q", auth.Username, "user")
}
if auth.Password != "pass" {
t.Errorf("Password = %q, want %q", auth.Password, "pass")
}
}
func TestBuildNodeConfig_TLS(t *testing.T) {
u, _ := url.Parse("http+tls://server:443")
m := map[string]any{
"secure": true,
"serverName": "example.com",
}
node, err := buildNodeConfig(u, m)
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Dialer.TLS == nil {
t.Fatal("Dialer.TLS is nil")
}
if node.Dialer.TLS.Secure != true {
t.Error("TLS.Secure should be true")
}
if node.Dialer.TLS.ServerName != "example.com" {
t.Errorf("TLS.ServerName = %q, want %q", node.Dialer.TLS.ServerName, "example.com")
}
}
func TestBuildNodeConfig_TLSServerNameDefaultsToHost(t *testing.T) {
rawURL := "http+tls://example.com:443?secure=true"
u, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Dialer.TLS.ServerName != "example.com" {
t.Errorf("TLS.ServerName = %q, want %q", node.Dialer.TLS.ServerName, "example.com")
}
}
func TestBuildNodeConfig_AuthFallbackForUnknownScheme(t *testing.T) {
// When dialer type is unknown, it defaults to tcp, and auth stays on connector
rawURL := "unknown://user:pass@server:8080"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
// Connector defaults to "http", dialer defaults to "tcp"
if node.Connector.Auth == nil {
t.Fatal("Connector.Auth is nil")
}
if node.Connector.Auth.Username != "user" || node.Connector.Auth.Password != "pass" {
t.Errorf("Connector.Auth = %+v, want user:pass", node.Connector.Auth)
}
if node.Dialer.Auth != nil {
t.Error("Dialer.Auth should be nil for non-ssh dialer")
}
}
func TestBuildNodeConfig_AuthFromMetadata(t *testing.T) {
authB64 := base64.RawURLEncoding.EncodeToString([]byte("quser:qpass"))
u, _ := url.Parse("http://server:8080")
m := map[string]any{"auth": authB64}
node, err := buildNodeConfig(u, m)
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Connector.Auth == nil {
t.Fatal("Connector.Auth is nil")
}
if node.Connector.Auth.Username != "quser" || node.Connector.Auth.Password != "qpass" {
t.Errorf("Connector.Auth = %+v, want quser:qpass", node.Connector.Auth)
}
}
func TestBuildNodeConfig_InvalidAuthParam(t *testing.T) {
u, _ := url.Parse("http://server:8080")
m := map[string]any{"auth": "invalid!!!"}
_, err := buildNodeConfig(u, m)
if err == nil {
t.Error("buildNodeConfig should fail with invalid auth param")
}
}
func TestBuildNodeConfig_MetadataRouting(t *testing.T) {
m := map[string]any{
"node.key": "nodeval",
"connector.key": "connval",
"dialer.key": "dialval",
"common.key": "commonval",
}
rawURL := "http://server:8080"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, m)
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Metadata["key"] != "nodeval" {
t.Errorf("node metadata: key = %v, want nodeval", node.Metadata["key"])
}
if node.Connector.Metadata["key"] != "connval" {
t.Errorf("connector metadata: key = %v, want connval", node.Connector.Metadata["key"])
}
if node.Dialer.Metadata["key"] != "dialval" {
t.Errorf("dialer metadata: key = %v, want dialval", node.Dialer.Metadata["key"])
}
// common keys should propagate to all
if node.Metadata["common.key"] != "commonval" {
t.Error("common key not in node metadata")
}
if node.Connector.Metadata["common.key"] != "commonval" {
t.Error("common key not in connector metadata")
}
if node.Dialer.Metadata["common.key"] != "commonval" {
t.Error("common key not in dialer metadata")
}
}
func TestBuildNodeConfig_UnknownConnectorUsesHTTP(t *testing.T) {
rawURL := "unknown://server:8080"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Connector.Type != "http" {
t.Errorf("Connector.Type = %q, want http", node.Connector.Type)
}
if node.Dialer.Type != "tcp" {
t.Errorf("Dialer.Type = %q, want tcp", node.Dialer.Type)
}
}
func TestBuildNodeConfig_SsuConnector(t *testing.T) {
// ssu connector is not in registry, so it falls back to http.
// The dialer falls back to tcp (not udp) because the connector was
// already resolved to http before the ssu check.
rawURL := "ssu://server:8388"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
// Connector falls back to http, dialer falls back to tcp
if node.Connector.Type != "http" {
t.Errorf("Connector.Type = %q, want http", node.Connector.Type)
}
if node.Dialer.Type != "tcp" {
t.Errorf("Dialer.Type = %q, want tcp", node.Dialer.Type)
}
}
func TestBuildServiceConfig_AuthOnHandlerByDefault(t *testing.T) {
// When listener type is unknown (defaults to tcp), auth stays on handler
rawURL := "http://user:pass@:8080"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
svc := svcs[0]
if svc.Handler.Auth == nil {
t.Fatal("Handler.Auth is nil")
}
if svc.Handler.Auth.Username != "user" || svc.Handler.Auth.Password != "pass" {
t.Errorf("Handler.Auth = %+v, want user:pass", svc.Handler.Auth)
}
if svc.Listener.Auth != nil {
t.Error("Listener.Auth should be nil for non-ssh listener")
}
}
func TestBuildServiceConfig_TLS(t *testing.T) {
rawURL := "http://:443?certFile=mycert.pem&keyFile=mykey.pem&caFile=myca.pem"
u, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
tls := svcs[0].Listener.TLS
if tls == nil {
t.Fatal("Listener.TLS is nil")
}
if tls.CertFile != "mycert.pem" {
t.Errorf("CertFile = %q, want mycert.pem", tls.CertFile)
}
if tls.KeyFile != "mykey.pem" {
t.Errorf("KeyFile = %q, want mykey.pem", tls.KeyFile)
}
if tls.CAFile != "myca.pem" {
t.Errorf("CAFile = %q, want myca.pem", tls.CAFile)
}
}
func TestBuildServiceConfig_TLSShortKeys(t *testing.T) {
rawURL := "http://:443?cert=a.pem&key=b.pem&ca=c.pem"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
tls := svcs[0].Listener.TLS
if tls == nil {
t.Fatal("Listener.TLS is nil")
}
if tls.CertFile != "a.pem" || tls.KeyFile != "b.pem" || tls.CAFile != "c.pem" {
t.Errorf("TLS = %+v, want a.pem/b.pem/c.pem", tls)
}
}
func TestBuildServiceConfig_NoTLSWithoutCert(t *testing.T) {
rawURL := "http://:443?keyFile=key.pem&caFile=ca.pem"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if svcs[0].Listener.TLS != nil {
t.Error("Listener.TLS should be nil without certFile")
}
}
func TestBuildServiceConfig_DNSParam(t *testing.T) {
rawURL := "http://:8080?dns=1.1.1.1,8.8.8.8"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
md := svcs[0].Handler.Metadata
val, ok := md["dns"]
if !ok {
t.Fatal("dns key missing from metadata")
}
strs, ok := val.([]string)
if !ok {
t.Fatalf("dns value type = %T, want []string", val)
}
if len(strs) != 2 || strs[0] != "1.1.1.1" || strs[1] != "8.8.8.8" {
t.Errorf("dns = %v, want [1.1.1.1 8.8.8.8]", strs)
}
}
func TestBuildServiceConfig_MetadataRouting(t *testing.T) {
rawURL := "http://:8080?service.key=svcval&handler.key=hval&listener.key=lval&common=val"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
svc := svcs[0]
if svc.Metadata["key"] != "svcval" {
t.Errorf("service metadata: key = %v, want svcval", svc.Metadata["key"])
}
if svc.Handler.Metadata["key"] != "hval" {
t.Errorf("handler metadata: key = %v, want hval", svc.Handler.Metadata["key"])
}
if svc.Listener.Metadata["key"] != "lval" {
t.Errorf("listener metadata: key = %v, want lval", svc.Listener.Metadata["key"])
}
// common keys propagate everywhere
if svc.Metadata["common"] != "val" {
t.Error("common key not in service metadata")
}
if svc.Handler.Metadata["common"] != "val" {
t.Error("common key not in handler metadata")
}
if svc.Listener.Metadata["common"] != "val" {
t.Error("common key not in listener metadata")
}
}
func TestBuildServiceConfig_NetParams(t *testing.T) {
rawURL := "http://:8080?interface=eth0&so_mark=42&proxyProtocol=2&netns=myns&netns.out=outns"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
md := svcs[0].Metadata
if md["interface"] != "eth0" {
t.Errorf("interface = %v, want eth0", md["interface"])
}
if md["so_mark"] != 42 {
t.Errorf("so_mark = %v, want 42", md["so_mark"])
}
if md["proxyProtocol"] != 2 {
t.Errorf("proxyProtocol = %v, want 2", md["proxyProtocol"])
}
if md["netns"] != "myns" {
t.Errorf("netns = %v, want myns", md["netns"])
}
if md["netns.out"] != "outns" {
t.Errorf("netns.out = %v, want outns", md["netns.out"])
}
}
func TestBuildServiceConfig_AuthFromQueryParam(t *testing.T) {
authB64 := base64.RawURLEncoding.EncodeToString([]byte("qu:qp"))
rawURL := "http://:8080?auth=" + authB64
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
auth := svcs[0].Handler.Auth
if auth == nil {
t.Fatal("Handler.Auth is nil")
}
if auth.Username != "qu" || auth.Password != "qp" {
t.Errorf("Auth = %+v, want qu:qp", auth)
}
}
func TestBuildServiceConfig_InvalidAuthParam(t *testing.T) {
rawURL := "http://:8080?auth=bad!!!"
u, _ := url.Parse(rawURL)
_, err := buildServiceConfig(u)
if err == nil {
t.Error("buildServiceConfig should fail with invalid auth param")
}
}
func TestBuildServiceConfig_FullAuto(t *testing.T) {
// ":8080" should prepend "auto://"
rawURL := "auto://:8080"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) != 1 {
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
}
if svcs[0].Handler.Type != "auto" {
t.Errorf("Handler.Type = %q, want auto", svcs[0].Handler.Type)
}
if svcs[0].Listener.Type != "tcp" {
t.Errorf("Listener.Type = %q, want tcp", svcs[0].Listener.Type)
}
}
func TestBuildConfigFromCmd_Empty(t *testing.T) {
cfg, err := BuildConfigFromCmd(nil, nil)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if cfg == nil {
t.Fatal("cfg is nil")
}
if len(cfg.Services) != 0 {
t.Errorf("expected 0 services, got %d", len(cfg.Services))
}
if len(cfg.Chains) != 0 {
t.Errorf("expected 0 chains, got %d", len(cfg.Chains))
}
}
func TestBuildConfigFromCmd_SingleService(t *testing.T) {
cfg, err := BuildConfigFromCmd([]string{":8080"}, nil)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Services) != 1 {
t.Fatalf("len(Services) = %d, want 1", len(cfg.Services))
}
svc := cfg.Services[0]
if svc.Addr != ":8080" {
t.Errorf("Addr = %q, want :8080", svc.Addr)
}
if svc.Handler.Type != "auto" {
t.Errorf("Handler.Type = %q, want auto", svc.Handler.Type)
}
if svc.Listener.Type != "tcp" {
t.Errorf("Listener.Type = %q, want tcp", svc.Listener.Type)
}
}
func TestBuildConfigFromCmd_ServiceWithChain(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Chains) != 1 {
t.Fatalf("len(Chains) = %d, want 1", len(cfg.Chains))
}
chain := cfg.Chains[0]
if len(chain.Hops) != 1 {
t.Fatalf("len(Hops) = %d, want 1", len(chain.Hops))
}
if chain.Hops[0].Nodes[0].Addr != "proxy:3128" {
t.Errorf("Node addr = %q, want proxy:3128", chain.Hops[0].Nodes[0].Addr)
}
svc := cfg.Services[0]
if svc.Handler.Chain != "chain-0" {
t.Errorf("Handler.Chain = %q, want chain-0", svc.Handler.Chain)
}
}
func TestBuildConfigFromCmd_ServiceWithMultipleNodes(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy1:3128", "http://proxy2:3128"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
chain := cfg.Chains[0]
if len(chain.Hops) != 2 {
t.Fatalf("len(Hops) = %d, want 2", len(chain.Hops))
}
if chain.Hops[0].Name != "hop-0" {
t.Errorf("Hops[0].Name = %q, want hop-0", chain.Hops[0].Name)
}
if chain.Hops[1].Name != "hop-1" {
t.Errorf("Hops[1].Name = %q, want hop-1", chain.Hops[1].Name)
}
}
func TestBuildConfigFromCmd_NodeWithBypass(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?bypass=10.0.0.0/8,*.local"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Bypasses) != 1 {
t.Fatalf("len(Bypasses) = %d, want 1", len(cfg.Bypasses))
}
bp := cfg.Bypasses[0]
if len(bp.Matchers) != 2 {
t.Errorf("len(Matchers) = %d, want 2", len(bp.Matchers))
}
if cfg.Chains[0].Hops[0].Bypass != bp.Name {
t.Errorf("Hop.Bypass = %q, want %q", cfg.Chains[0].Hops[0].Bypass, bp.Name)
}
}
func TestBuildConfigFromCmd_NodeWithInvertBypass(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?bypass=~10.0.0.0/8"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
bp := cfg.Bypasses[0]
if !bp.Whitelist {
t.Error("Bypass.Whitelist should be true for ~ prefix")
}
if len(bp.Matchers) != 1 || bp.Matchers[0] != "10.0.0.0/8" {
t.Errorf("Matchers = %v, want [10.0.0.0/8]", bp.Matchers)
}
}
func TestBuildConfigFromCmd_NodeWithResolver(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?resolver=8.8.8.8,1.1.1.1"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Resolvers) != 1 {
t.Fatalf("len(Resolvers) = %d, want 1", len(cfg.Resolvers))
}
rs := cfg.Resolvers[0]
if len(rs.Nameservers) != 2 {
t.Errorf("len(Nameservers) = %d, want 2", len(rs.Nameservers))
}
}
func TestBuildConfigFromCmd_NodeResolverFamilyOptions(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?resolver=auto&prefer=ipv6&only=ipv4"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
rs := cfg.Resolvers[0]
if len(rs.Nameservers) != 1 {
t.Fatalf("len(Nameservers) = %d, want 1", len(rs.Nameservers))
}
ns := rs.Nameservers[0]
if ns.Addr != "auto" {
t.Errorf("Addr = %q, want auto", ns.Addr)
}
if ns.Prefer != "ipv6" {
t.Errorf("Prefer = %q, want ipv6", ns.Prefer)
}
if ns.Only != "ipv4" {
t.Errorf("Only = %q, want ipv4", ns.Only)
}
}
func TestBuildConfigFromCmd_NodeWithHosts(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?hosts=example.com:1.2.3.4,test.local:5.6.7.8"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Hosts) != 1 {
t.Fatalf("len(Hosts) = %d, want 1", len(cfg.Hosts))
}
hs := cfg.Hosts[0]
if len(hs.Mappings) != 2 {
t.Errorf("len(Mappings) = %d, want 2", len(hs.Mappings))
}
}
func TestBuildConfigFromCmd_NodeWithHopMetadata(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?hop.key=hval&interface=eth0&so_mark=1&proxyProtocol=1"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hop := cfg.Chains[0].Hops[0]
if hop.Metadata["key"] != "hval" {
t.Errorf("hop metadata key = %v, want hval", hop.Metadata["key"])
}
if hop.Metadata["so_mark"] != 1 {
t.Errorf("hop.so_mark = %v, want 1", hop.Metadata["so_mark"])
}
}
func TestBuildConfigFromCmd_ServiceWithAdmission(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?admission=10.0.0.0/8,192.168.0.0/16"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Admissions) != 1 {
t.Fatalf("len(Admissions) = %d, want 1", len(cfg.Admissions))
}
adm := cfg.Admissions[0]
if len(adm.Matchers) != 2 {
t.Errorf("len(Matchers) = %d, want 2", len(adm.Matchers))
}
if cfg.Services[0].Admission != adm.Name {
t.Errorf("Service.Admission = %q, want %q", cfg.Services[0].Admission, adm.Name)
}
}
func TestBuildConfigFromCmd_ServiceWithInvertAdmission(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?admission=~10.0.0.0/8"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if !cfg.Admissions[0].Whitelist {
t.Error("Admission.Whitelist should be true for ~ prefix")
}
}
func TestBuildConfigFromCmd_ServiceWithBypass(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?bypass=10.0.0.0/8"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Bypasses) != 1 {
t.Fatalf("len(Bypasses) = %d, want 1", len(cfg.Bypasses))
}
if cfg.Services[0].Bypass != cfg.Bypasses[0].Name {
t.Errorf("Service.Bypass = %q, want %q", cfg.Services[0].Bypass, cfg.Bypasses[0].Name)
}
}
func TestBuildConfigFromCmd_ServiceWithResolver(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?resolver=8.8.8.8&prefer=go"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
rs := cfg.Resolvers[0]
if rs.Nameservers[0].Prefer != "go" {
t.Errorf("Prefer = %q, want go", rs.Nameservers[0].Prefer)
}
}
func TestBuildConfigFromCmd_ServiceSystemResolverOnlyIPv4(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{"http://:8080?resolver=system&only=ipv4"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Resolvers) != 1 {
t.Fatalf("len(Resolvers) = %d, want 1", len(cfg.Resolvers))
}
rs := cfg.Resolvers[0]
if len(rs.Nameservers) != 1 {
t.Fatalf("len(Nameservers) = %d, want 1", len(rs.Nameservers))
}
ns := rs.Nameservers[0]
if ns.Addr != "system" {
t.Errorf("Addr = %q, want system", ns.Addr)
}
if ns.Only != "ipv4" {
t.Errorf("Only = %q, want ipv4", ns.Only)
}
if cfg.Services[0].Resolver != rs.Name {
t.Errorf("Service.Resolver = %q, want %q", cfg.Services[0].Resolver, rs.Name)
}
}
func TestBuildConfigFromCmd_ServiceWithHosts(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?hosts=example.com:1.2.3.4"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hs := cfg.Hosts[0]
if len(hs.Mappings) != 1 || hs.Mappings[0].Hostname != "example.com" || hs.Mappings[0].IP != "1.2.3.4" {
t.Errorf("Mappings = %+v, want example.com:1.2.3.4", hs.Mappings)
}
}
func TestBuildConfigFromCmd_ServiceWithLimiters(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?limiter.in=1MB&limiter.out=2MB&limiter.conn.in=10&limiter.conn.out=20"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Limiters) != 1 {
t.Fatalf("len(Limiters) = %d, want 1", len(cfg.Limiters))
}
lim := cfg.Limiters[0]
if len(lim.Limits) != 2 {
t.Errorf("len(Limits) = %d, want 2", len(lim.Limits))
}
if cfg.Services[0].Limiter != lim.Name {
t.Errorf("Service.Limiter = %q, want %q", cfg.Services[0].Limiter, lim.Name)
}
}
func TestBuildConfigFromCmd_ServiceWithCLimiter(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?climiter=100"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.CLimiters) != 1 {
t.Fatalf("len(CLimiters) = %d, want 1", len(cfg.CLimiters))
}
if cfg.Services[0].CLimiter != cfg.CLimiters[0].Name {
t.Errorf("Service.CLimiter = %q, want %q", cfg.Services[0].CLimiter, cfg.CLimiters[0].Name)
}
}
func TestBuildConfigFromCmd_ServiceWithRLimiter(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?rlimiter=0.5"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.RLimiters) != 1 {
t.Fatalf("len(RLimiters) = %d, want 1", len(cfg.RLimiters))
}
if cfg.Services[0].RLimiter != cfg.RLimiters[0].Name {
t.Errorf("Service.RLimiter = %q, want %q", cfg.Services[0].RLimiter, cfg.RLimiters[0].Name)
}
}
func TestBuildConfigFromCmd_ServiceWithRetries(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?retries=3"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if cfg.Services[0].Handler.Retries != 3 {
t.Errorf("Retries = %d, want 3", cfg.Services[0].Handler.Retries)
}
}
func TestBuildConfigFromCmd_MultipleServices(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080", ":9090"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Services) != 2 {
t.Fatalf("len(Services) = %d, want 2", len(cfg.Services))
}
if cfg.Services[0].Name != "service-0" || cfg.Services[1].Name != "service-1" {
t.Errorf("names = %q, %q, want service-0, service-1",
cfg.Services[0].Name, cfg.Services[1].Name)
}
}
func TestBuildConfigFromCmd_InvalidNode(t *testing.T) {
// Norm returns ErrInvalidCmd for empty strings
_, err := BuildConfigFromCmd(
nil,
[]string{""},
)
if err == nil {
t.Error("BuildConfigFromCmd should return error for empty node string")
}
}
func TestBuildConfigFromCmd_NodeWithCommaSeparatedHosts(t *testing.T) {
// url.Parse accepts comma-separated hostnames without a port on the first entry
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://h1,h2:3128"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hop := cfg.Chains[0].Hops[0]
if len(hop.Nodes) != 2 {
t.Fatalf("len(Nodes) = %d, want 2", len(hop.Nodes))
}
if hop.Nodes[0].Addr != "h1" || hop.Nodes[1].Addr != "h2:3128" {
t.Errorf("Node addrs = %q, %q, want h1, h2:3128",
hop.Nodes[0].Addr, hop.Nodes[1].Addr)
}
}
func TestBuildConfigFromCmd_NodeWithSelector(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?strategy=rr&maxFails=3&failTimeout=10s"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
sel := cfg.Chains[0].Hops[0].Selector
if sel == nil {
t.Fatal("Selector is nil")
}
if sel.Strategy != "rr" {
t.Errorf("Strategy = %q, want rr", sel.Strategy)
}
if sel.MaxFails != 3 {
t.Errorf("MaxFails = %d, want 3", sel.MaxFails)
}
}
func TestBuildConfigFromCmd_HttpsRewritesToHTTPTLS(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{"https://:443"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
// https is rewritten to http+tls by Norm; buildServiceConfig splits into handler=http, listener=tls
svc := cfg.Services[0]
if svc.Handler.Type != "http" && svc.Handler.Type != "auto" {
t.Errorf("Handler.Type = %q, want http or auto", svc.Handler.Type)
}
if svc.Listener.Type != "tls" && svc.Listener.Type != "tcp" {
t.Errorf("Listener.Type = %q, want tls or tcp", svc.Listener.Type)
}
}
func TestBuildConfigFromCmd_EmptyServiceString(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{"", " ", ":8080"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Services) != 1 {
t.Fatalf("len(Services) = %d, want 1 (empty strings skipped)", len(cfg.Services))
}
}
func TestBuildConfigFromCmd_NodeMetadataNotMixedWithServiceMetadata(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?hop.something=svcval"},
[]string{"http://proxy:3128?hop.something=nodeval"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hop := cfg.Chains[0].Hops[0]
if hop.Metadata["something"] != "nodeval" {
t.Errorf("hop metadata = %v, want nodeval", hop.Metadata["something"])
}
// service's hop.something shouldn't interfere with node's hop metadata
svc := cfg.Services[0]
if svc.Metadata["something"] == "svcval" {
t.Error("service metadata should not contain hop.something")
}
}
func TestBuildConfigFromCmd_NodeWithConnectorAndDialerMetadata(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?connector.timeout=5s&dialer.keepAlive=true&node.weight=10"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
nodeCfg := cfg.Chains[0].Hops[0].Nodes[0]
if nodeCfg.Connector.Metadata["timeout"] != "5s" {
t.Errorf("connector timeout = %v, want 5s", nodeCfg.Connector.Metadata["timeout"])
}
if nodeCfg.Dialer.Metadata["keepAlive"] != "true" {
t.Errorf("dialer keepAlive = %v, want true", nodeCfg.Dialer.Metadata["keepAlive"])
}
if nodeCfg.Metadata["weight"] != "10" {
t.Errorf("node weight = %v, want \"10\"", nodeCfg.Metadata["weight"])
}
}
func TestBuildConfigFromCmd_ServiceWithHandlerAndListenerMetadata(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?handler.timeout=10s&listener.backlog=128&service.note=test"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
svc := cfg.Services[0]
if svc.Handler.Metadata["timeout"] != "10s" {
t.Errorf("handler timeout = %v, want 10s", svc.Handler.Metadata["timeout"])
}
if svc.Listener.Metadata["backlog"] != "128" {
t.Errorf("listener backlog = %v, want 128", svc.Listener.Metadata["backlog"])
}
if svc.Metadata["note"] != "test" {
t.Errorf("service note = %v, want test", svc.Metadata["note"])
}
}
func TestBuildConfigFromCmd_ForwardService(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{"tcp://:8080/1.2.3.4:80"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
svc := cfg.Services[0]
if svc.Forwarder == nil {
t.Fatal("Forwarder is nil")
}
if len(svc.Forwarder.Nodes) != 1 {
t.Fatalf("len(Nodes) = %d, want 1", len(svc.Forwarder.Nodes))
}
if svc.Forwarder.Nodes[0].Addr != "1.2.3.4:80" {
t.Errorf("Forward addr = %q, want 1.2.3.4:80", svc.Forwarder.Nodes[0].Addr)
}
}
func TestParseAuthFromCmd_UsernameOnly(t *testing.T) {
authB64 := base64.RawURLEncoding.EncodeToString([]byte("useronly"))
got, err := parseAuthFromCmd(authB64)
if err != nil {
t.Fatalf("parseAuthFromCmd: %v", err)
}
if got.Username != "useronly" {
t.Errorf("Username = %q, want useronly", got.Username)
}
if got.Password != "" {
t.Errorf("Password = %q, want empty", got.Password)
}
}
func TestBuildConfigFromCmd_ServiceWithInvertBypass(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?bypass=~10.0.0.0/8"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
bp := cfg.Bypasses[0]
if !bp.Whitelist {
t.Error("service Bypass.Whitelist should be true for ~ prefix")
}
}
func TestBuildConfigFromCmd_NodeBypassEmptyMatcher(t *testing.T) {
// Empty entries after comma split should be skipped
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?bypass=10.0.0.0/8,,192.168.0.0/16"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
bp := cfg.Bypasses[0]
if len(bp.Matchers) != 2 {
t.Errorf("len(Matchers) = %d, want 2 (empty entry skipped)", len(bp.Matchers))
}
}
func TestBuildConfigFromCmd_NodeResolverEmptyEntry(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?resolver=8.8.8.8,,1.1.1.1"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Resolvers[0].Nameservers) != 2 {
t.Errorf("len(Nameservers) = %d, want 2 (empty entry skipped)", len(cfg.Resolvers[0].Nameservers))
}
}
func TestBuildConfigFromCmd_NodeHostsInvalidMapping(t *testing.T) {
// entries without a colon are skipped; entries with empty IP after colon are kept
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://proxy:3128?hosts=valid.com:1.2.3.4,badentry,another.com:5.6.7.8"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hs := cfg.Hosts[0]
if len(hs.Mappings) != 2 {
t.Errorf("len(Mappings) = %d, want 2 (badentry without colon skipped)", len(hs.Mappings))
}
}
func TestBuildConfigFromCmd_ServiceHostsInvalidMapping(t *testing.T) {
// entries without colon are skipped; entries with only colon+empty IP are kept
cfg, err := BuildConfigFromCmd(
[]string{":8080?hosts=good.com:1.2.3.4,badonly,other.com:5.6.7.8"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hs := cfg.Hosts[0]
if len(hs.Mappings) != 2 {
t.Errorf("len(Mappings) = %d, want 2 (badonly without colon skipped)", len(hs.Mappings))
}
}
func TestBuildConfigFromCmd_ServiceResolverEmptyEntry(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?resolver=8.8.8.8,,1.1.1.1"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Resolvers[0].Nameservers) != 2 {
t.Errorf("len(Nameservers) = %d, want 2", len(cfg.Resolvers[0].Nameservers))
}
}
func TestBuildConfigFromCmd_ServiceAdmissionEmptyMatcher(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?admission=10.0.0.0/8,,192.168.0.0/16"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Admissions[0].Matchers) != 2 {
t.Errorf("len(Matchers) = %d, want 2", len(cfg.Admissions[0].Matchers))
}
}
func TestBuildConfigFromCmd_ServiceBypassEmptyMatcher(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080?bypass=10.0.0.0/8,,192.168.0.0/16"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
if len(cfg.Bypasses[0].Matchers) != 2 {
t.Errorf("len(Matchers) = %d, want 2", len(cfg.Bypasses[0].Matchers))
}
}
func TestBuildConfigFromCmd_MultipleNodesCommaHost(t *testing.T) {
// Tests comma-separated hosts in node addr that result in multiple nodes with same hop
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://h1,h2,h3:3128"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hop := cfg.Chains[0].Hops[0]
if len(hop.Nodes) != 3 {
t.Fatalf("len(Nodes) = %d, want 3", len(hop.Nodes))
}
}
func TestBuildConfigFromCmd_NodeHostEmptySkipped(t *testing.T) {
// url.Parse("http://h1,,h2:3128") is valid (comma in hostname).
// Split by comma yields ["h1", "", "h2:3128"] — the empty string is skipped.
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
[]string{"http://h1,,h2:3128"},
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
hop := cfg.Chains[0].Hops[0]
if len(hop.Nodes) != 2 {
t.Errorf("len(Nodes) = %d, want 2 (empty entry skipped)", len(hop.Nodes))
}
}
func TestBuildConfigFromCmd_NoChainForMissingNode(t *testing.T) {
cfg, err := BuildConfigFromCmd(
[]string{":8080"},
nil,
)
if err != nil {
t.Fatalf("BuildConfigFromCmd: %v", err)
}
// No nodes = no chain = handler should not have chain
if cfg.Services[0].Handler.Chain != "" {
t.Errorf("Handler.Chain = %q, want empty (no chain created)", cfg.Services[0].Handler.Chain)
}
}
func TestBuildServiceConfig_FullAutoScheme(t *testing.T) {
// auto:// scheme triggers handler=auto, listener=tcp fallback
rawURL := "auto://:8080"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) != 1 {
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
}
if svcs[0].Handler.Type != "auto" {
t.Errorf("Handler.Type = %q, want auto", svcs[0].Handler.Type)
}
}
func TestBuildServiceConfig_SSUHandlerUDPListener(t *testing.T) {
// ssu handler auto-detects to udp listener (handler stays "ssu" if in registry)
rawURL := "ssu://:8388"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
// ssu handler not in registry → falls back to auto
// listener defaults to tcp, but if handler was "ssu" it would be udp
if svcs[0].Listener.Type != "tcp" {
t.Errorf("Listener.Type = %q, want tcp (ssu not in test registry)", svcs[0].Listener.Type)
}
}
func TestBuildServiceConfig_RelayHandler(t *testing.T) {
// relay handler stays "relay" when forwarding (the relay!=relay check
// in buildServiceConfig preserves the handler type). When relay is not
// in the registry it falls back to auto, but we still get a forwarder.
rawURL := "relay://:8421/1.2.3.4:80"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
svc := svcs[0]
if svc.Forwarder == nil {
t.Fatal("Forwarder is nil for relay with forward path")
}
if svc.Handler.Type != "relay" && svc.Handler.Type != "auto" && svc.Handler.Type != "tcp" {
t.Errorf("Handler.Type = %q, want relay, auto, or tcp", svc.Handler.Type)
}
}
func TestBuildServiceConfig_ForwardMode(t *testing.T) {
// Non-relay handler with forward path → handler becomes listener type (tcp by default)
rawURL := "http://:8080/1.2.3.4:80,5.6.7.8:443"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
svc := svcs[0]
if svc.Forwarder == nil {
t.Fatal("Forwarder is nil")
}
if len(svc.Forwarder.Nodes) != 2 {
t.Errorf("len(Forwarder.Nodes) = %d, want 2", len(svc.Forwarder.Nodes))
}
// handler becomes listener type (tcp) for non-relay forward mode, or "auto" if not found
if svc.Handler.Type != "tcp" && svc.Handler.Type != "auto" && svc.Handler.Type != "forward" {
t.Errorf("Handler.Type = %q", svc.Handler.Type)
}
}
func TestBuildServiceConfig_DNSListener(t *testing.T) {
// dns handler+listener with forward path → handler becomes listener type
rawURL := "dns://:53/8.8.8.8"
u, _ := url.Parse(rawURL)
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
svc := svcs[0]
// dns not in registry → handler falls back to auto, then becomes listener type (tcp) for forward mode
// or dns IS in registry → handler stays "dns"
if svc.Forwarder == nil {
t.Fatal("Forwarder is nil")
}
if svc.Handler.Type != "dns" && svc.Handler.Type != "tcp" && svc.Handler.Type != "auto" {
t.Errorf("Handler.Type = %q", svc.Handler.Type)
}
}
func TestBuildNodeConfig_SSH_StandardAuth(t *testing.T) {
// SSH scheme with unknown registries: connector→http, dialer→tcp.
// Since dialer != "ssh"/"sshd" after fallback, auth stays on connector.
rawURL := "ssh://user:pass@server:22"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Connector.Auth == nil {
t.Fatal("Connector.Auth is nil")
}
if node.Connector.Auth.Username != "user" || node.Connector.Auth.Password != "pass" {
t.Errorf("Connector.Auth = %+v, want user:pass", node.Connector.Auth)
}
if node.Dialer.Auth != nil {
t.Error("Dialer.Auth should be nil (dialer fell back to tcp)")
}
}
func TestBuildNodeConfig_TLSWithCert(t *testing.T) {
// TLS with certFile and keyFile (but no secure)
u, _ := url.Parse("http+tls://server:443")
m := map[string]any{
"cert": "cert.pem",
"key": "key.pem",
}
node, err := buildNodeConfig(u, m)
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Dialer.TLS == nil {
t.Fatal("Dialer.TLS is nil")
}
if node.Dialer.TLS.CertFile != "cert.pem" || node.Dialer.TLS.KeyFile != "key.pem" {
t.Errorf("TLS = %+v, want cert.pem/key.pem", node.Dialer.TLS)
}
}
func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
// http+unix scheme: connector=http, dialer=unix.
// Since unix is not in the test registry, dialer falls back to tcp,
// and the path-based address logic (dialer=="unix") is skipped.
rawURL := "http+unix:///var/run/socket"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Dialer.Type != "tcp" {
t.Errorf("Dialer.Type = %q, want tcp (unix not in test registry)", node.Dialer.Type)
}
// Addr is url.Host ("") since no host component in three-slash unix URL
if node.Addr != "" {
t.Errorf("Addr = %q, want empty", node.Addr)
}
}
func TestBuildServiceConfig_RelayTLSForward(t *testing.T) {
// relay+tls scheme: handler=relay, listener=tls.
// relay not in test registry → handler falls back to auto, listener falls back to tcp.
// With forward path, handler becomes listener type (tcp) since handler != "relay" after fallback.
u, _ := url.Parse("relay+tls://:8443/backend:443")
svcs, err := buildServiceConfig(u)
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) != 1 {
t.Errorf("len(svcs) = %d, want 1", len(svcs))
}
if svcs[0].Forwarder == nil {
t.Fatal("Forwarder is nil for relay+tls with path")
}
// Handler falls back to tcp due to registry fallback + forward mode reassignment
if svcs[0].Handler.Type != "tcp" && svcs[0].Handler.Type != "auto" && svcs[0].Handler.Type != "relay" {
t.Errorf("Handler.Type = %q", svcs[0].Handler.Type)
}
}
func TestBuildNodeConfig_SSUDefaultAuth(t *testing.T) {
// SIP002 auth for ssu scheme
encoded := base64.RawURLEncoding.EncodeToString([]byte("method:secret"))
rawURL := "ssu://" + encoded + "@server:8388"
u, _ := url.Parse(rawURL)
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
// Connector falls back to http, so auth is on connector
if node.Connector.Auth == nil {
t.Fatal("Connector.Auth is nil")
}
if node.Connector.Auth.Username != "method" || node.Connector.Auth.Password != "secret" {
t.Errorf("Auth = %+v, want method:secret", node.Connector.Auth)
}
}
func TestCopyHandlerConfig_Nil(t *testing.T) {
if copyHandlerConfig(nil) != nil {
t.Error("copyHandlerConfig(nil) should be nil")
}
}
func TestCopyListenerConfig_Nil(t *testing.T) {
if copyListenerConfig(nil) != nil {
t.Error("copyListenerConfig(nil) should be nil")
}
}
func TestBuildConfigFromCmd_ServiceAuthError(t *testing.T) {
// Invalid base64 auth in service query should fail in buildServiceConfig
_, err := BuildConfigFromCmd(
[]string{"http://:8080?auth=!!!bad!!!"},
nil,
)
if err == nil {
t.Error("BuildConfigFromCmd should return error for invalid service auth")
}
}
func TestNorm_InvalidURL(t *testing.T) {
// Test with a URL containing control characters that url.Parse might reject
_, err := Norm("http://host:8080\x00")
if err == nil {
t.Error("Norm should return error for invalid URL")
}
}
func TestParseSelector_FailTimeoutAsInt(t *testing.T) {
// failTimeout can be specified as an integer (treated as seconds).
// GetDuration treats int values as seconds.
m := map[string]any{
"strategy": "random",
"failTimeout": 10,
}
got := parseSelector(m)
if got == nil {
t.Fatal("parseSelector() = nil")
}
if got.FailTimeout <= 0 {
t.Errorf("FailTimeout = %v, want > 0", got.FailTimeout)
}
// keys should be cleaned up
if _, ok := m["failTimeout"]; ok {
t.Error("failTimeout key should be deleted from m")
}
}
func TestBuildNodeConfig_TLSOnlySecure(t *testing.T) {
// TLS with only Secure=true should still create a TLSConfig (Secure alone is enough)
u, _ := url.Parse("http://server:8080")
m := map[string]any{"secure": true}
node, err := buildNodeConfig(u, m)
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
// TLS should be set because Secure=true, even without cert/ca/servername
if node.Dialer.TLS == nil {
t.Fatal("Dialer.TLS is nil — Secure alone should keep TLS config")
}
if node.Dialer.TLS.Secure != true {
t.Error("TLS.Secure should be true")
}
}