package proxy import ( "bytes" "crypto/tls" "encoding/base64" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "helix-proxy/internal/certificate" "helix-proxy/internal/config" "helix-proxy/internal/store" ) // --- pure helper tests (table driven as recommended) --- func TestItoa(t *testing.T) { if itoa(80) != "80" || itoa(0) != "0" || itoa(443) != "443" { t.Error("itoa wrong") } } func TestIsWebsocketRequest(t *testing.T) { tests := []struct { name string h http.Header want bool }{ {"none", http.Header{}, false}, {"upgrade ws conn upgrade", http.Header{"Upgrade": []string{"websocket"}, "Connection": []string{"Upgrade"}}, true}, {"case insen", http.Header{"Upgrade": []string{"WebSocket"}, "Connection": []string{"keep-alive, Upgrade"}}, true}, {"no upgrade header", http.Header{"Connection": []string{"Upgrade"}}, false}, {"no conn upgrade", http.Header{"Upgrade": []string{"websocket"}}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &http.Request{Header: tt.h} if got := isWebsocketRequest(r); got != tt.want { t.Errorf("isWebsocketRequest() = %v want %v", got, tt.want) } }) } } func TestGetRealClientIP(t *testing.T) { tests := []struct { name string r *http.Request trust bool want string }{ {"xff first (trust)", &http.Request{Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, RemoteAddr: "9.9.9.9:1"}, true, "1.2.3.4"}, {"xff ignored when !trust", &http.Request{Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, RemoteAddr: "9.9.9.9:1"}, false, "9.9.9.9"}, {"no xff use remote", &http.Request{RemoteAddr: "10.0.0.1:1234"}, true, "10.0.0.1"}, {"remote no port", &http.Request{RemoteAddr: "10.0.0.2"}, false, "10.0.0.2"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := getRealClientIP(tt.r, tt.trust); got != tt.want { t.Errorf("getRealClientIP() = %v want %v", got, tt.want) } }) } } func TestParseAdvancedConfig(t *testing.T) { tests := []struct { name string adv string wantSets map[string]string wantAdds map[string]string wantHides []string }{ {"empty", "", map[string]string{}, map[string]string{}, []string{}}, {"comment and blank", "# foo\n\n ", map[string]string{}, map[string]string{}, []string{}}, {"set_header", "proxy_set_header X-Foo bar baz", map[string]string{"X-Foo": "bar baz"}, map[string]string{}, []string{}}, {"add_header quoted", `add_header X-Bar "val with space";`, map[string]string{}, map[string]string{"X-Bar": "val with space"}, []string{}}, {"hide", "proxy_hide_header X-Baz\nproxy_hide_header X-Qux", map[string]string{}, map[string]string{}, []string{"X-Baz", "X-Qux"}}, {"mixed sep ; and nl", "proxy_set_header A 1; add_header B 2; proxy_hide_header C", map[string]string{"A": "1"}, map[string]string{"B": "2"}, []string{"C"}}, {"unknown ignored", "set $foo bar\nproxy_redirect / /", map[string]string{}, map[string]string{}, []string{}}, {"trim quotes in add", `add_header X "v";`, map[string]string{}, map[string]string{"X": "v"}, []string{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sets, adds, hides := parseAdvancedConfig(tt.adv) if !mapsEqual(sets, tt.wantSets) { t.Errorf("reqSets = %v want %v", sets, tt.wantSets) } if !mapsEqual(adds, tt.wantAdds) { t.Errorf("respAdds = %v want %v", adds, tt.wantAdds) } if !slicesEqual(hides, tt.wantHides) { t.Errorf("respHides = %v want %v", hides, tt.wantHides) } }) } } func TestResolveProxyHeaders(t *testing.T) { structured := store.ProxyHost{ RequestHeaders: []store.HeaderRule{{Name: "X-Req", Value: "1"}}, ResponseHeaders: []store.HeaderRule{{Name: "X-Resp", Value: "2"}}, HideHeaders: []string{"X-Hide"}, } sets, adds, hides := resolveProxyHeaders(structured) if !mapsEqual(sets, map[string]string{"X-Req": "1"}) { t.Errorf("structured reqSets = %v", sets) } if !mapsEqual(adds, map[string]string{"X-Resp": "2"}) { t.Errorf("structured respAdds = %v", adds) } if !slicesEqual(hides, []string{"X-Hide"}) { t.Errorf("structured respHides = %v", hides) } legacy := store.ProxyHost{AdvancedConfig: "proxy_set_header A 1\nadd_header B 2;\nproxy_hide_header C"} sets, adds, hides = resolveProxyHeaders(legacy) if !mapsEqual(sets, map[string]string{"A": "1"}) || !mapsEqual(adds, map[string]string{"B": "2"}) || !slicesEqual(hides, []string{"C"}) { t.Errorf("legacy fallback failed: sets=%v adds=%v hides=%v", sets, adds, hides) } // structured fields take precedence over legacy advancedConfig both := store.ProxyHost{ RequestHeaders: []store.HeaderRule{{Name: "New", Value: "v"}}, AdvancedConfig: "proxy_set_header Old 1", } sets, _, _ = resolveProxyHeaders(both) if !mapsEqual(sets, map[string]string{"New": "v"}) { t.Errorf("structured should win over legacy: %v", sets) } } func mapsEqual(a, b map[string]string) bool { if len(a) != len(b) { return false } for k, v := range a { if b[k] != v { return false } } return true } func slicesEqual(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func TestCheckAccess(t *testing.T) { tests := []struct { name string clients []store.AccessClient satisfyAny bool ip string want bool }{ {"no clients allow", nil, false, "1.2.3.4", true}, {"empty allow", []store.AccessClient{}, false, "1.2.3.4", true}, {"deny all", []store.AccessClient{{Address: "all", Directive: "deny"}}, false, "9.9.9.9", false}, {"allow all", []store.AccessClient{{Address: "all", Directive: "allow"}}, false, "1.1.1.1", true}, {"exact allow", []store.AccessClient{{Address: "10.0.0.1", Directive: "allow"}}, false, "10.0.0.1:123", true}, {"exact deny", []store.AccessClient{{Address: "10.0.0.1", Directive: "deny"}}, false, "10.0.0.1", false}, {"cidr allow", []store.AccessClient{{Address: "192.168.0.0/16", Directive: "allow"}}, false, "192.168.1.2", true}, {"cidr deny", []store.AccessClient{{Address: "192.168.0.0/16", Directive: "deny"}}, false, "192.168.1.2", false}, {"satisfyAny: one allow sufficient", []store.AccessClient{{Address: "1.1.1.1", Directive: "allow"}, {Address: "2.2.2.2", Directive: "allow"}}, true, "1.1.1.1", true}, {"satisfyAny: no allow", []store.AccessClient{{Address: "1.1.1.1", Directive: "deny"}}, true, "9.9.9.9", false}, {"satisfy all (default): denies block even if allow present", []store.AccessClient{{Address: "1.1.1.1", Directive: "allow"}, {Address: "1.1.1.1", Directive: "deny"}}, false, "1.1.1.1", false}, {"bad ip", []store.AccessClient{{Address: "all", Directive: "allow"}}, false, "not-an-ip", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := checkAccess(tt.clients, tt.satisfyAny, tt.ip) if got != tt.want { t.Errorf("checkAccess(%v, %v, %q) = %v want %v", tt.clients, tt.satisfyAny, tt.ip, got, tt.want) } }) } } func TestEngineReloadClearsCache(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) eng.cacheMu.Lock() eng.cache["k"] = struct { body []byte headers http.Header status int expires time.Time }{body: []byte("x")} eng.cacheMu.Unlock() // use public ReloadFromStore (exercises the cache clear inside it) eng.ReloadFromStore() eng.cacheMu.Lock() if len(eng.cache) != 0 { t.Error("reload did not clear cache") } eng.cacheMu.Unlock() } // --- helpers for handler tests --- func newTestEngine(t *testing.T) (*Engine, store.Store) { t.Helper() tmp := t.TempDir() config.ResetForTest() t.Setenv("DATA_DIR", tmp) _ = config.EnsureDataDirs() st, err := store.New() if err != nil { t.Fatalf("test store: %v", err) } eng := NewEngine(st) eng.ReloadFromStore() return eng, st } func doReq(h http.Handler, method, host, path string, headers http.Header, body []byte) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, bytes.NewReader(body)) req.Host = host if headers != nil { req.Header = headers.Clone() } rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } // TestEngineHandler_HostMatchingRedirsDeadsDefaults exercises core routing branches. func TestEngineHandler_HostMatchingRedirsDeadsDefaults(t *testing.T) { eng, st := newTestEngine(t) defer func() { if c, ok := st.(interface{ Close() error }); ok { c.Close() } }() h := eng.Handler() // no hosts: default congratulations (or index if present) rr := doReq(h, "GET", "nohost.local", "/", nil, nil) if rr.Code != 200 || !strings.Contains(rr.Body.String(), "Welcome to Helix Proxy") { t.Errorf("default congrats: code=%d body=%s", rr.Code, rr.Body.String()[:min(100, rr.Body.Len())]) } // set default 404 st.SetSetting("default_site", "404") eng.ReloadFromStore() rr = doReq(h, "GET", "nohost.local", "/", nil, nil) if rr.Code != 404 { t.Errorf("default 404: %d", rr.Code) } // 444 st.SetSetting("default_site", "444") eng.ReloadFromStore() rr = doReq(h, "GET", "nohost.local", "/", nil, nil) if rr.Code != 444 { t.Errorf("default 444: %d", rr.Code) } // redirect default st.SetSetting("default_site", "redirect") st.SetSetting("default_site_redirect", "https://def.example") eng.ReloadFromStore() rr = doReq(h, "GET", "nohost.local", "/", nil, nil) if rr.Code != 302 || !strings.Contains(rr.Header().Get("Location"), "def.example") { t.Errorf("default redirect: %d %s", rr.Code, rr.Header().Get("Location")) } // create a redir host rh := store.RedirectionHost{ DomainNames: []string{"redir.example"}, ForwardHTTPCode: 301, ForwardScheme: "https", ForwardDomainName: "target.example", PreservePath: true, Enabled: true, } _, _ = st.CreateRedirectionHost(rh) eng.ReloadFromStore() rr = doReq(h, "GET", "redir.example", "/foo?x=1", nil, nil) if rr.Code != 301 || !strings.Contains(rr.Header().Get("Location"), "https://target.example/foo?x=1") { t.Errorf("redir preserve: %d loc=%s", rr.Code, rr.Header().Get("Location")) } // dead host plain dh := store.DeadHost{DomainNames: []string{"dead.example"}, Enabled: true} _, _ = st.CreateDeadHost(dh) eng.ReloadFromStore() rr = doReq(h, "GET", "dead.example", "/", nil, nil) if rr.Code != 404 || !strings.Contains(rr.Body.String(), "dead/blocked") { t.Errorf("dead plain: %d %s", rr.Code, rr.Body.String()) } // dead with meta.html dh2 := store.DeadHost{DomainNames: []string{"deadhtml.example"}, Enabled: true, Meta: map[string]any{"html": "

custom dead

"}} _, _ = st.CreateDeadHost(dh2) eng.ReloadFromStore() rr = doReq(h, "GET", "deadhtml.example", "/", nil, nil) if rr.Code != 404 || !strings.Contains(rr.Body.String(), "custom dead") { t.Errorf("dead meta html: %d %s", rr.Code, rr.Body.String()) } // custom 404.html file fallback (not just meta) wwwDir := config.Resolve("www") _ = os.MkdirAll(wwwDir, 0o755) _ = os.WriteFile(filepath.Join(wwwDir, "404.html"), []byte("

CUSTOM 404 FILE

"), 0o644) dh3 := store.DeadHost{DomainNames: []string{"deadfile.example"}, Enabled: true} _, _ = st.CreateDeadHost(dh3) eng.ReloadFromStore() rr = doReq(h, "GET", "deadfile.example", "/", nil, nil) if rr.Code != 404 || !strings.Contains(rr.Body.String(), "CUSTOM 404 FILE") { t.Errorf("dead custom 404.html: %d %s", rr.Code, rr.Body.String()) } } // TestEngineHandler_ProxyHostBasic + locations + ssl + block + hsts + ws + xforward func TestEngineHandler_ProxyBasics(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) // create a simple proxy host (no backend will 502 but we can check early behaviors before proxy) ph := store.ProxyHost{ DomainNames: []string{"proxy.example"}, ForwardScheme: "http", ForwardHost: "127.0.0.1", ForwardPort: 9, // invalid to not actually connect in test Enabled: true, SslForced: true, BlockExploits: true, HstsEnabled: true, HstsSubdomains: true, TrustForwardedProto: true, RequestHeaders: []store.HeaderRule{{Name: "X-Custom", Value: "reqval"}}, ResponseHeaders: []store.HeaderRule{{Name: "X-Resp", Value: "respval"}}, HideHeaders: []string{"X-HideMe"}, } created, _ := st.CreateProxyHost(ph) _ = created eng.ReloadFromStore() h := eng.Handler() // ssl forced on plain rr := doReq(h, "GET", "proxy.example", "/path", http.Header{"X-Forwarded-Proto": []string{"http"}}, nil) if rr.Code != 301 || !strings.HasPrefix(rr.Header().Get("Location"), "https://") { t.Errorf("ssl_forced: %d %s", rr.Code, rr.Header().Get("Location")) } // block exploits - use TLS set on req to bypass ssl_forced redirect (which is before block check) req := httptest.NewRequest("GET", "/.env", nil) req.Host = "proxy.example" req.TLS = &tls.ConnectionState{} // simulate https so no ssl redirect rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != 403 || !strings.Contains(rr.Body.String(), "exploits") { t.Errorf("block_exploits: %d %s", rr.Code, rr.Body.String()) } // HSTS set on response (simulate https to reach hsts code) req = httptest.NewRequest("GET", "/ok", nil) req.Host = "proxy.example" req.TLS = &tls.ConnectionState{} rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if hs := rr.Header().Get("Strict-Transport-Security"); !strings.Contains(hs, "includeSubDomains") { t.Errorf("hsts not set or missing sub: %q", hs) } // X-Forwarded injection happens in director, but since we hit error backend we may not see, test via other means later // for now check advanced req set happens early? but applied in director too. } // Test locations forwardPath rewrite func TestEngineHandler_Locations(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) ph := store.ProxyHost{ DomainNames: []string{"loc.example"}, ForwardScheme: "http", ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true, Locations: []store.Location{ {Path: "/api", ForwardScheme: "http", ForwardHost: "backend", ForwardPort: 9000, ForwardPath: "/v2"}, }, } _, _ = st.CreateProxyHost(ph) eng.ReloadFromStore() h := eng.Handler() // we can't easily assert the rewritten without real backend or spying, but at least no panic and host match rr := doReq(h, "GET", "loc.example", "/api/foo", nil, nil) // expect 502 from revproxy to bad host, but routing happened if rr.Code == 404 { t.Error("location host not matched, got 404 default") } } // Test access lists IP + basic auth + passauth func TestEngineHandler_AccessLists(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) al := store.AccessList{ Name: "acl1", SatisfyAny: false, Clients: []store.AccessClient{{Address: "10.0.0.0/8", Directive: "allow"}, {Address: "1.2.3.4", Directive: "deny"}}, Items: []store.AccessItem{{Username: "user", Password: "pass"}}, PassAuth: false, } alCreated, _ := st.CreateAccessList(al) ph := store.ProxyHost{ DomainNames: []string{"acl.example"}, ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true, AccessListID: alCreated.ID, TrustForwardedProto: true, // enable XFF for spoof tests in access list (default false would ignore XFF) } _, _ = st.CreateProxyHost(ph) // ph with default TrustForwardedProto=false for testing XFF spoof ignored in access + clientIP phNoTrust := store.ProxyHost{ DomainNames: []string{"acl-notrust.example"}, ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true, AccessListID: alCreated.ID, // TrustForwardedProto: false (default) => XFF ignored, use RemoteAddr for access decisions/injection } _, _ = st.CreateProxyHost(phNoTrust) eng.ReloadFromStore() h := eng.Handler() // deny via explicit deny client (note: 11.x would be allowed under !satisfyAny if no deny hit; use the deny addr) rr := doReq(h, "GET", "acl.example", "/", http.Header{"X-Forwarded-For": []string{"1.2.3.4"}}, nil) if rr.Code != 403 { t.Errorf("access deny exact: %d", rr.Code) } // trust=false + spoof XFF (deny addr): should ignore XFF (use Remote which doesn't match deny) => no 403, reaches auth rr = doReq(h, "GET", "acl-notrust.example", "/", http.Header{"X-Forwarded-For": []string{"1.2.3.4"}}, nil) if rr.Code != 401 { t.Errorf("TrustForwardedProto=false should ignore XFF spoof (reaches auth 401); got %d", rr.Code) } // allow by cidr via xff rr = doReq(h, "GET", "acl.example", "/", http.Header{"X-Forwarded-For": []string{"10.1.2.3"}}, nil) // will 401 because basic auth required next if rr.Code != 401 { t.Errorf("access allow but no auth expected 401: %d", rr.Code) } // basic auth fail rr = doReq(h, "GET", "acl.example", "/", http.Header{ "X-Forwarded-For": []string{"10.1.2.3"}, "Authorization": []string{"Basic " + basic("bad", "bad")}, }, nil) if rr.Code != 401 { t.Errorf("basic bad: %d", rr.Code) } // basic good, passauth false so stripped rr = doReq(h, "GET", "acl.example", "/", http.Header{ "X-Forwarded-For": []string{"10.1.2.3"}, "Authorization": []string{"Basic " + basic("user", "pass")}, }, nil) if rr.Code == 401 { t.Error("basic good should not 401") } // (502 later ok) } func basic(u, p string) string { return base64.StdEncoding.EncodeToString([]byte(u + ":" + p)) } func TestEngineHandler_Caching(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) upstream := NewWhoami(t, WhoamiConfig{ DefaultHeaders: map[string]string{ "Cache-Control": "max-age=3600", "X-Upstream": "yes", }, Routes: map[string]WhoamiRoute{ "/cpath": {Body: "CACHED-BODY-FOR-/cpath"}, }, }) _, _ = st.CreateProxyHost(store.ProxyHost{ DomainNames: []string{"cache.example"}, ForwardScheme: "http", ForwardHost: upstream.Hostname, ForwardPort: upstream.Port, Enabled: true, CachingEnabled: true, }) eng.ReloadFromStore() h := eng.Handler() rr1 := doReq(h, "GET", "cache.example", "/cpath", nil, nil) if rr1.Code != 200 || !strings.Contains(rr1.Body.String(), "CACHED-BODY") || rr1.Header().Get("X-Proxy-Cache") != "MISS" { t.Errorf("first MISS: code=%d cache=%s body=%s", rr1.Code, rr1.Header().Get("X-Proxy-Cache"), rr1.Body.String()) } rr2 := doReq(h, "GET", "cache.example", "/cpath", nil, nil) if rr2.Header().Get("X-Proxy-Cache") != "HIT" || rr2.Body.String() != rr1.Body.String() { t.Errorf("second HIT: cache=%s body=%s", rr2.Header().Get("X-Proxy-Cache"), rr2.Body.String()) } noStore := NewWhoami(t, WhoamiConfig{ DefaultHeaders: map[string]string{"Cache-Control": "no-store"}, Routes: map[string]WhoamiRoute{"/p": {Body: "NO-STORE-BODY"}}, }) _, _ = st.CreateProxyHost(store.ProxyHost{ DomainNames: []string{"cache-nostore.example"}, ForwardScheme: "http", ForwardHost: noStore.Hostname, ForwardPort: noStore.Port, Enabled: true, CachingEnabled: true, }) eng.ReloadFromStore() h = eng.Handler() rr3 := doReq(h, "GET", "cache-nostore.example", "/p", nil, nil) if rr3.Header().Get("X-Proxy-Cache") != "" { t.Error("no-store should not set X-Proxy-Cache") } rr4 := doReq(h, "GET", "cache-nostore.example", "/p", nil, nil) if rr4.Header().Get("X-Proxy-Cache") == "HIT" { t.Error("no-store response should not have been cached for HIT") } eng.ReloadFromStore() } // Test X-Forwarded* injection (respects trust), ws header munging via whoami upstream. func TestEngineHandler_ForwardedAndWS(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) up := NewWhoami(t, WhoamiConfig{}) _, _ = st.CreateProxyHost(store.ProxyHost{ DomainNames: []string{"fwd.example"}, ForwardScheme: "http", ForwardHost: up.Hostname, ForwardPort: up.Port, Enabled: true, TrustForwardedProto: true, AllowWebsocketUpgrade: true, }) eng.ReloadFromStore() h := eng.Handler() hdrs := http.Header{ "X-Forwarded-For": []string{"10.9.8.7"}, "X-Forwarded-Proto": []string{"https"}, "Connection": []string{"Upgrade"}, "Upgrade": []string{"websocket"}, } rr := doReq(h, "GET", "fwd.example", "/ws", hdrs, nil) if rr.Code != 200 { t.Fatalf("forwarded/ws proxy: code=%d", rr.Code) } got, ok := up.LastRequest() if !ok { t.Fatal("upstream saw no request") } if !strings.Contains(got.HeaderValue("X-Forwarded-For"), "10.9.8.7") || got.HeaderValue("X-Forwarded-Proto") != "https" { t.Errorf("X-Forwarded* injection: ff=%s proto=%s", got.HeaderValue("X-Forwarded-For"), got.HeaderValue("X-Forwarded-Proto")) } if got.HeaderValue("Connection") != "Upgrade" || got.HeaderValue("Upgrade") != "websocket" { t.Errorf("ws munging: conn=%s up=%s", got.HeaderValue("Connection"), got.HeaderValue("Upgrade")) } } // Test ACME challenge serving (before host) func TestEngineHandler_ACME(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) h := eng.Handler() chDir := config.Resolve("letsencrypt-acme-challenge") acmeFileDir := filepath.Join(chDir, ".well-known", "acme-challenge") _ = os.MkdirAll(acmeFileDir, 0o755) token := "testtoken123" content := "challenge-content-xyz" if err := os.WriteFile(filepath.Join(acmeFileDir, token), []byte(content), 0o644); err != nil { t.Fatal(err) } rr := doReq(h, "GET", "anyhost", "/.well-known/acme-challenge/"+token, nil, nil) if rr.Code != 200 || rr.Body.String() != content { t.Errorf("acme serve: code=%d body=%q", rr.Code, rr.Body.String()) } rr = doReq(h, "GET", "anyhost", "/.well-known/acme-challenge/missing", nil, nil) if rr.Code != 404 { t.Errorf("acme missing: %d", rr.Code) } } // Test loadCerts / GetCertificate (SNI, compat keys) func TestEngine_LoadCertsGetCertificate(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) // create via cert manager's test path (dev mode makes *all* LE certs self-signed test certs) config.ResetForTest() t.Setenv("DATA_DIR", config.DataDir()) // already set in newTest t.Setenv("PROXY_MODE", "development") cm := certificate.NewManager(st) certRec := store.Certificate{ Provider: "letsencrypt", DomainNames: []string{"sni.example"}, Meta: map[string]any{}, } created, _ := st.CreateCertificate(certRec) email := cm.GetEmailForCert(created) if err := cm.Issue(created, email); err != nil { t.Fatalf("issue test cert for load: %v", err) } // now attach to host ph := store.ProxyHost{ DomainNames: []string{"sni.example"}, ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true, CertificateID: created.ID, } _, _ = st.CreateProxyHost(ph) eng.ReloadFromStore() // loadCerts called in reload; should succeed with real PEMs cert, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "sni.example"}) if err != nil { t.Fatalf("GetCertificate: %v", err) } if cert == nil { t.Error("nil cert") } // fallback any (there is one now) _, _ = eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown"}) } // Test streams reload (at least lifecycle no panic) func TestEngine_StreamsReload(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) strm := store.Stream{ IncomingPort: 0, // use high? but test may bind fail; use invalid port to just test start path without real listen success ForwardingHost: "127.0.0.1", ForwardingPort: 9, TCPForwarding: true, Enabled: true, } // port 0 not good; pick a likely free high port for test? but to avoid bind in CI, just create disabled first strm.IncomingPort = 19999 + (int(time.Now().UnixNano()) % 1000) // somewhat unique _, _ = st.CreateStream(strm) eng.ReloadFromStore() runtime := eng.StreamStatus(strm.IncomingPort) if !runtime.Listening { t.Fatalf("expected stream listening on port %d: %+v", strm.IncomingPort, runtime) } // now disable and reload to exercise cancel path strm.Enabled = false _, _ = st.UpdateStream(strm) eng.ReloadFromStore() // ok if no crash } func TestEngine_StreamListenFailure(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) port := 19800 + (int(time.Now().UnixNano()) % 1000) ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) if err != nil { t.Fatalf("bind probe port: %v", err) } defer ln.Close() strm := store.Stream{ IncomingPort: port, ForwardingHost: "127.0.0.1", ForwardingPort: 9, TCPForwarding: true, Enabled: true, } _, _ = st.CreateStream(strm) eng.ReloadFromStore() runtime := eng.StreamStatus(port) if runtime.Listening { t.Fatal("expected stream not listening when port is taken") } if runtime.ListenError == "" { t.Fatal("expected listen error when port is taken") } } func TestEngine_StreamReloadSamePort(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) port := 19700 + (int(time.Now().UnixNano()) % 1000) strm := store.Stream{ IncomingPort: port, ForwardingHost: "127.0.0.1", ForwardingPort: 9, TCPForwarding: true, Enabled: true, } created, _ := st.CreateStream(strm) eng.ReloadFromStore() runtime := eng.StreamStatus(port) if !runtime.Listening { t.Fatalf("expected stream listening after initial reload: %+v", runtime) } created.ForwardingPort = 10 _, _ = st.UpdateStream(created) eng.ReloadFromStore() runtime = eng.StreamStatus(port) if !runtime.Listening { t.Fatalf("expected stream still listening after edit reload on same port: %+v", runtime) } } func TestEngine_StreamReloadLeavesOthersRunning(t *testing.T) { eng, st := newTestEngine(t) defer closeStore(st) base := 19600 + (int(time.Now().UnixNano()) % 1000) portA := base portB := base + 1 a, _ := st.CreateStream(store.Stream{ IncomingPort: portA, ForwardingHost: "127.0.0.1", ForwardingPort: 9, TCPForwarding: true, Enabled: true, }) b, _ := st.CreateStream(store.Stream{ IncomingPort: portB, ForwardingHost: "127.0.0.1", ForwardingPort: 9, TCPForwarding: true, Enabled: true, }) eng.ReloadFromStore() if st := eng.StreamStatus(portA); !st.Listening { t.Fatalf("stream A not listening: %+v", st) } if st := eng.StreamStatus(portB); !st.Listening { t.Fatalf("stream B not listening: %+v", st) } a.ForwardingPort = 10 _, _ = st.UpdateStream(a) eng.ReloadFromStore() if st := eng.StreamStatus(portA); !st.Listening { t.Fatalf("stream A not listening after edit: %+v", st) } if st := eng.StreamStatus(portB); !st.Listening { t.Fatalf("stream B should still be listening after editing A: %+v", st) } _ = b } func TestDuplicateStreamListenPorts(t *testing.T) { desired := map[int]store.Stream{ 1: {ID: 1, IncomingPort: 9000, TCPForwarding: true}, 2: {ID: 2, IncomingPort: 9000, TCPForwarding: true}, 3: {ID: 3, IncomingPort: 9001, TCPForwarding: true}, } conflicts := duplicateStreamListenPorts(desired) if len(conflicts) != 2 { t.Fatalf("expected 2 conflicts, got %d: %v", len(conflicts), conflicts) } if conflicts[1] == "" || conflicts[2] == "" { t.Fatalf("expected errors for streams 1 and 2, got %v", conflicts) } if _, ok := conflicts[3]; ok { t.Fatalf("stream 3 should not conflict: %v", conflicts) } } func closeStore(s store.Store) { if c, ok := s.(interface{ Close() error }); ok { c.Close() } } func min(a, b int) int { if a < b { return a } return b }