826 lines
27 KiB
Go
826 lines
27 KiB
Go
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": "<h1>custom dead</h1>"}}
|
|
_, _ = 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("<h1>CUSTOM 404 FILE</h1>"), 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
|
|
}
|