mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
Review of the previous commit found the validation test green for a reason it did not claim: SowerdConfig with an empty FakeSite fails Validate on "fake site is required" before validateSiteRoutes runs, so the bogus authority was never exercised. Set a valid FakeSite and assert the error names the authority field, which the assertion can no longer satisfy by accident. Also fold the router mapping case into the end-to-end test and drop the standalone TestSiteRouterPreserveHost: the positive mapping is already implied by the wire assertion, and the default case costs one extra route and two checks there. Both directions were verified by mutation — inverting the mapping and letting Validate accept the bogus value each turn the covering test red.
624 lines
15 KiB
Go
624 lines
15 KiB
Go
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
|
|
}
|