package config import ( "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "math/big" "os" "path/filepath" "strings" "testing" "time" "github.com/cristalhq/aconfig" "github.com/cristalhq/aconfig/aconfigtoml" ) func TestSowerdConfigPasswordVerbatim(t *testing.T) { t.Parallel() path := t.TempDir() + "/sowerd.toml" if err := os.WriteFile(path, []byte(` password = "fnhkwfnh" fake_site = "127.0.0.1:18080" `), 0o600); err != nil { t.Fatalf("write config: %v", err) } var cfg SowerdConfig if err := aconfig.LoaderFor(&cfg, aconfig.Config{ SkipEnv: true, SkipFlags: true, Files: []string{path}, FileDecoders: map[string]aconfig.FileDecoder{ ".toml": aconfigtoml.New(), }, }).Load(); err != nil { t.Fatalf("load config: %v", err) } // "fnhkwfnh" is valid canonical base64; it must stay verbatim or client // auth (byte-for-byte checksum over the password) silently breaks. if got := cfg.Password; got != "fnhkwfnh" { t.Fatalf("sowerd password = %q, want verbatim fnhkwfnh", got) } } func TestSowerdConfigSiteRouteBasicAuthFromTOML(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "sowerd.toml") if err := os.WriteFile(path, []byte(` password = "secret" fake_site = "127.0.0.1:18080" [[site_routes]] domains = ["nas.example.com"] upstream = "http://127.0.0.1:9000" [site_routes.auth] username = "alice" password = "s3cret" [site_routes.routes] "/ws" = "http://127.0.0.1:9002" [[site_routes]] domains = ["open.example.com"] upstream = "http://127.0.0.1:9001" `), 0o600); err != nil { t.Fatalf("write config: %v", err) } var cfg SowerdConfig if err := loadConfigFileForTest(path, &cfg); err != nil { t.Fatalf("load config: %v", err) } // The gate lives in a nested table of an array-of-tables element; a // decoder that silently drops it would leave the route open, so the // parsed values (not just the load result) are the assertion. if len(cfg.SiteRoutes) != 2 { t.Fatalf("site routes = %d, want 2", len(cfg.SiteRoutes)) } if got := cfg.SiteRoutes[0].Auth; got.Username != "alice" || got.Password != "s3cret" { t.Fatalf("basic auth = %#v, want alice/s3cret", got) } if cfg.SiteRoutes[0].Routes["/ws"] != "http://127.0.0.1:9002" { t.Fatalf("path routes = %#v, want the /ws override alongside auth", cfg.SiteRoutes[0].Routes) } if got := cfg.SiteRoutes[1].Auth; got.Username != "" || got.Password != "" { t.Fatalf("ungated route basic auth = %#v, want empty", got) } if err := cfg.Validate(); err != nil { t.Fatalf("validate config: %v", err) } } func TestSowerdConfigSiteRouteAuthorityFromTOML(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "sowerd.toml") if err := os.WriteFile(path, []byte(` password = "secret" fake_site = "127.0.0.1:18080" [[site_routes]] domains = ["web.example.com"] upstream = "http://192.0.2.10:30141" authority = "client" [[site_routes]] domains = ["plain.example.com"] upstream = "http://127.0.0.1:9001" `), 0o600); err != nil { t.Fatalf("write config: %v", err) } var cfg SowerdConfig if err := loadConfigFileForTest(path, &cfg); err != nil { t.Fatalf("load config: %v", err) } if len(cfg.SiteRoutes) != 2 { t.Fatalf("site routes = %d, want 2", len(cfg.SiteRoutes)) } // An upstream that validates the visited authority itself only works when the // client's name survives the proxy, so the value must round-trip from TOML and // must stay off when the route does not ask for it. if got := cfg.SiteRoutes[0].Authority; got != AuthorityClient { t.Errorf("authority = %q, want %q", got, AuthorityClient) } if !cfg.SiteRoutes[0].PreserveHost() { t.Error("PreserveHost() = false for an authority = client route") } if cfg.SiteRoutes[1].Authority != "" { t.Errorf("authority = %q, want the empty default", cfg.SiteRoutes[1].Authority) } if cfg.SiteRoutes[1].PreserveHost() { t.Error("PreserveHost() = true for a route that did not ask for it") } if err := cfg.Validate(); err != nil { t.Fatalf("validate config: %v", err) } } func TestSowerdConfigSiteRouteAuthorityRejectsUnknownValue(t *testing.T) { t.Parallel() // FakeSite is set so Validate reaches the route check: an earlier field // failing first would make this test green without exercising the authority. cfg := SowerdConfig{Password: "secret", FakeSite: "127.0.0.1:18080"} cfg.SiteRoutes = []SiteRoute{ {Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000", Authority: "keep"}, } err := cfg.Validate() if err == nil { t.Fatal("Validate accepted an unknown authority, want an error") } if !strings.Contains(err.Error(), "authority") { t.Fatalf("Validate error = %v, want it to name the authority field", err) } } func TestSowerdConfigValidate(t *testing.T) { t.Parallel() tests := []struct { name string cfg SowerdConfig wantErr bool }{ { name: "valid remote fake site", cfg: SowerdConfig{ ServeIP: "0.0.0.0", Password: "secret", FakeSite: "127.0.0.1:8080", }, }, { name: "invalid serve ip", cfg: SowerdConfig{ ServeIP: "bad-ip", Password: "secret", FakeSite: "127.0.0.1:8080", }, wantErr: true, }, { name: "missing password", cfg: SowerdConfig{ FakeSite: "127.0.0.1:8080", }, wantErr: true, }, { name: "invalid fake site address", cfg: SowerdConfig{ Password: "secret", FakeSite: "not-an-address", }, wantErr: true, }, { name: "partial cert config", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", Cert: struct { Email string Cert string Key string Domains []string }{ Cert: "/tmp/cert.pem", }, }, wantErr: true, }, { name: "valid site routes", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com", "b.example.com"}, Upstream: "http://127.0.0.1:9000"}, {Domains: []string{"c.example.com"}, Upstream: "https://backend.example.com"}, }, }, }, { name: "valid path routes", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000", Routes: map[string]string{ "/ws": "http://127.0.0.1:9002", "/admin/": "http://127.0.0.1:9003", }, }, }, }, }, { name: "path route without leading slash", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000", Routes: map[string]string{"ws": "http://127.0.0.1:9002"}, }, }, }, wantErr: true, }, { name: "basic auth visitor gate", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"nas.example.com"}, Upstream: "http://127.0.0.1:9000", Auth: SiteRouteAuth{Username: "alice", Password: "s3cret"}, }, }, }, }, { name: "basic auth username without password", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"nas.example.com"}, Upstream: "http://127.0.0.1:9000", Auth: SiteRouteAuth{Username: "alice"}, }, }, }, wantErr: true, }, { name: "basic auth password without username", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"nas.example.com"}, Upstream: "http://127.0.0.1:9000", Auth: SiteRouteAuth{Password: "s3cret"}, }, }, }, wantErr: true, }, { name: "basic auth username with colon", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"nas.example.com"}, Upstream: "http://127.0.0.1:9000", Auth: SiteRouteAuth{Username: "ali:ce", Password: "s3cret"}, }, }, }, wantErr: true, }, { name: "path route with query", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000", Routes: map[string]string{"/ws?x=1": "http://127.0.0.1:9002"}, }, }, }, wantErr: true, }, { name: "path route invalid upstream scheme", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ { Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000", Routes: map[string]string{"/ws": "ftp://127.0.0.1:21"}, }, }, }, wantErr: true, }, { name: "empty domains", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{}, Upstream: "http://127.0.0.1:9000"}, }, }, wantErr: true, }, { name: "empty upstream", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com"}, Upstream: ""}, }, }, wantErr: true, }, { name: "invalid upstream scheme", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com"}, Upstream: "ftp://127.0.0.1:21"}, }, }, wantErr: true, }, { name: "upstream missing host", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com"}, Upstream: "http://"}, }, }, wantErr: true, }, { name: "wildcard domain rejected", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"*.example.com"}, Upstream: "http://127.0.0.1:9000"}, }, }, wantErr: true, }, { name: "duplicate domain across routes", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000"}, {Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9001"}, }, }, wantErr: true, }, { name: "empty domain in list", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com", ""}, Upstream: "http://127.0.0.1:9000"}, }, }, wantErr: true, }, { name: "domain with whitespace rejected", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{" a.example.com"}, Upstream: "http://127.0.0.1:9000"}, }, }, wantErr: true, }, { name: "domain as url rejected", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"https://a.example.com"}, Upstream: "http://127.0.0.1:9000"}, }, }, wantErr: true, }, { name: "domain with port rejected", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com:443"}, Upstream: "http://127.0.0.1:9000"}, }, }, wantErr: true, }, { name: "domain with trailing dot rejected", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com."}, Upstream: "http://127.0.0.1:9000"}, }, }, wantErr: true, }, { name: "no site routes is valid", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", }, }, { name: "valid cert domains", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", Cert: struct { Email string Cert string Key string Domains []string }{ Domains: []string{"proxy.example.com"}, }, }, }, { name: "invalid cert domain rejected", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", Cert: struct { Email string Cert string Key string Domains []string }{ Domains: []string{"not a domain"}, }, }, wantErr: true, }, { name: "duplicate cert domain rejected", cfg: SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", Cert: struct { Email string Cert string Key string Domains []string }{ Domains: []string{"proxy.example.com", "proxy.example.com"}, }, }, wantErr: true, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() err := tt.cfg.Validate() if tt.wantErr && err == nil { t.Fatal("expected error") } if !tt.wantErr && err != nil { t.Fatalf("unexpected error: %v", err) } }) } } func TestSowerdConfigValidateSiteRouteCertificate(t *testing.T) { t.Parallel() certPath, keyPath := writeTestCertificate(t, []string{"a.example.com", "b.example.com"}) cfg := SowerdConfig{ Password: "secret", FakeSite: "127.0.0.1:8080", SiteRoutes: []SiteRoute{ {Domains: []string{"a.example.com", "b.example.com"}, Upstream: "http://127.0.0.1:9000"}, }, } cfg.Cert.Cert = certPath cfg.Cert.Key = keyPath if err := cfg.Validate(); err != nil { t.Fatalf("unexpected validation error: %v", err) } cfg.SiteRoutes = []SiteRoute{ {Domains: []string{"missing.example.com"}, Upstream: "http://127.0.0.1:9000"}, } if err := cfg.Validate(); err == nil { t.Fatal("expected validation error for uncovered site route domain") } } func writeTestCertificate(t *testing.T, domains []string) (string, 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: domains[0], }, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), DNSNames: domains, } der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { t.Fatalf("create cert: %v", err) } dir := t.TempDir() certPath := filepath.Join(dir, "cert.pem") keyPath := filepath.Join(dir, "key.pem") certFile, err := os.Create(certPath) if err != nil { t.Fatalf("create cert file: %v", err) } if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { t.Fatalf("write cert file: %v", err) } if err := certFile.Close(); err != nil { t.Fatalf("close cert file: %v", err) } keyFile, err := os.Create(keyPath) if err != nil { t.Fatalf("create key file: %v", err) } if err := pem.Encode(keyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}); err != nil { t.Fatalf("write key file: %v", err) } if err := keyFile.Close(); err != nil { t.Fatalf("close key file: %v", err) } return certPath, keyPath }