initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,825 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"helix-proxy/internal/certificate"
|
||||
"helix-proxy/internal/config"
|
||||
"helix-proxy/internal/store"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// TestEngine_Integration_FullSetup wires proxy hosts, redirections, dead hosts,
|
||||
// access lists, certificates, locations, and streams against configurable whoami upstreams.
|
||||
func TestEngine_Integration_FullSetup(t *testing.T) {
|
||||
eng, st := newTestEngine(t)
|
||||
defer closeStore(st)
|
||||
|
||||
// --- upstream mocks ---
|
||||
mainUpstream := NewWhoami(t, WhoamiConfig{
|
||||
Routes: map[string]WhoamiRoute{
|
||||
"/health": {Status: 200, Body: `{"status":"ok"}`, Headers: map[string]string{"X-Secret": "hidden"}},
|
||||
},
|
||||
})
|
||||
apiUpstream := NewWhoami(t, WhoamiConfig{
|
||||
ValidPrefixes: []string{"/v2"},
|
||||
})
|
||||
authUpstream := NewWhoami(t, WhoamiConfig{})
|
||||
tcpEcho := NewTCPEcho(t, "STREAM-OK\n")
|
||||
|
||||
certRec := issueDevCert(t, st, "secure.example", "stream.example")
|
||||
|
||||
// --- access list ---
|
||||
al, _ := st.CreateAccessList(store.AccessList{
|
||||
Name: "integration-acl",
|
||||
SatisfyAny: false,
|
||||
Clients: []store.AccessClient{{Address: "all", Directive: "allow"}},
|
||||
Items: []store.AccessItem{{Username: "testuser", Password: "secret"}},
|
||||
PassAuth: false,
|
||||
})
|
||||
|
||||
// --- proxy host: headers, location rewrite, access, cert ---
|
||||
_, _ = st.CreateProxyHost(store.ProxyHost{
|
||||
DomainNames: []string{"proxy-int.example"},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: mainUpstream.Hostname,
|
||||
ForwardPort: mainUpstream.Port,
|
||||
CertificateID: certRec.ID,
|
||||
AccessListID: al.ID,
|
||||
TrustForwardedProto: true,
|
||||
RequestHeaders: []store.HeaderRule{{Name: "X-Injected-Req", Value: "yes"}},
|
||||
ResponseHeaders: []store.HeaderRule{{Name: "X-Injected-Resp", Value: "yes"}},
|
||||
HideHeaders: []string{"X-Secret"},
|
||||
Enabled: true,
|
||||
Locations: []store.Location{{
|
||||
Path: "/api",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: apiUpstream.Hostname,
|
||||
ForwardPort: apiUpstream.Port,
|
||||
ForwardPath: "/v2",
|
||||
}},
|
||||
})
|
||||
|
||||
// proxy with passAuth=true to verify Authorization reaches upstream
|
||||
alPass, _ := st.CreateAccessList(store.AccessList{
|
||||
Name: "pass-auth-acl",
|
||||
Clients: []store.AccessClient{{Address: "all", Directive: "allow"}},
|
||||
Items: []store.AccessItem{{Username: "u", Password: "p"}},
|
||||
PassAuth: true,
|
||||
})
|
||||
_, _ = st.CreateProxyHost(store.ProxyHost{
|
||||
DomainNames: []string{"passauth.example"},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: authUpstream.Hostname,
|
||||
ForwardPort: authUpstream.Port,
|
||||
AccessListID: alPass.ID,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
// --- redirection host ---
|
||||
_, _ = st.CreateRedirectionHost(store.RedirectionHost{
|
||||
DomainNames: []string{"redir-int.example"},
|
||||
ForwardHTTPCode: 302,
|
||||
ForwardScheme: "https",
|
||||
ForwardDomainName: "target-int.example",
|
||||
PreservePath: true,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
// --- dead hosts ---
|
||||
_, _ = st.CreateDeadHost(store.DeadHost{
|
||||
DomainNames: []string{"dead-int.example"},
|
||||
Enabled: true,
|
||||
Meta: map[string]any{"html": "<h1>integration dead</h1>"},
|
||||
})
|
||||
wwwDir := config.Resolve("www")
|
||||
_ = os.MkdirAll(wwwDir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(wwwDir, "404.html"), []byte("<h1>INTEGRATION 404 FILE</h1>"), 0o644)
|
||||
_, _ = st.CreateDeadHost(store.DeadHost{DomainNames: []string{"deadfile-int.example"}, Enabled: true})
|
||||
|
||||
// --- default site modes ---
|
||||
_ = st.SetSetting("default_site", "404")
|
||||
|
||||
// --- stream (TCP) ---
|
||||
t.Setenv("DISABLE_IPV6", "1")
|
||||
streamPort := pickFreePort(t)
|
||||
_, _ = st.CreateStream(store.Stream{
|
||||
IncomingPort: streamPort,
|
||||
ForwardingHost: tcpEcho.Host,
|
||||
ForwardingPort: tcpEcho.Port,
|
||||
TCPForwarding: true,
|
||||
Enabled: true,
|
||||
})
|
||||
cleanupStreams(t, eng, st)
|
||||
|
||||
eng.ReloadFromStore()
|
||||
h := eng.Handler()
|
||||
|
||||
// --- redirection ---
|
||||
rr := doReq(h, "GET", "redir-int.example", "/foo?bar=1", nil, nil)
|
||||
if rr.Code != 302 || !strings.Contains(rr.Header().Get("Location"), "https://target-int.example/foo?bar=1") {
|
||||
t.Errorf("redirection: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// --- dead hosts ---
|
||||
rr = doReq(h, "GET", "dead-int.example", "/", nil, nil)
|
||||
if rr.Code != 404 || !strings.Contains(rr.Body.String(), "integration dead") {
|
||||
t.Errorf("dead meta: code=%d body=%q", rr.Code, rr.Body.String())
|
||||
}
|
||||
rr = doReq(h, "GET", "deadfile-int.example", "/", nil, nil)
|
||||
if rr.Code != 404 || !strings.Contains(rr.Body.String(), "INTEGRATION 404 FILE") {
|
||||
t.Errorf("dead file: code=%d body=%q", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// --- default 404 ---
|
||||
rr = doReq(h, "GET", "unknown-int.example", "/", nil, nil)
|
||||
if rr.Code != 404 {
|
||||
t.Errorf("default 404: %d", rr.Code)
|
||||
}
|
||||
|
||||
// --- certificate SNI ---
|
||||
cert, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "secure.example"})
|
||||
if err != nil || cert == nil {
|
||||
t.Errorf("GetCertificate secure.example: err=%v cert=%v", err, cert)
|
||||
}
|
||||
|
||||
// --- proxy: main path with auth + injected headers ---
|
||||
authHdr := http.Header{
|
||||
"Authorization": []string{"Basic " + basic("testuser", "secret")},
|
||||
"X-Forwarded-For": []string{"10.0.0.5"},
|
||||
"X-Forwarded-Proto": []string{"https"},
|
||||
}
|
||||
rr = doReq(h, "GET", "proxy-int.example", "/health", authHdr, nil)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("proxy /health: code=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr.Header().Get("X-Injected-Resp") != "yes" {
|
||||
t.Error("response header injection missing")
|
||||
}
|
||||
if rr.Header().Get("X-Secret") != "" {
|
||||
t.Error("hide header failed: X-Secret leaked")
|
||||
}
|
||||
if got, ok := mainUpstream.LastRequest(); !ok {
|
||||
t.Fatal("main upstream saw no request")
|
||||
} else {
|
||||
if got.Path != "/health" {
|
||||
t.Errorf("main path: got %q want /health", got.Path)
|
||||
}
|
||||
if got.HeaderValue("X-Injected-Req") != "yes" {
|
||||
t.Errorf("request header injection: got %q", got.HeaderValue("X-Injected-Req"))
|
||||
}
|
||||
if got.HeaderValue("Authorization") != "" {
|
||||
t.Error("passAuth=false should strip Authorization before upstream")
|
||||
}
|
||||
if got.HeaderValue("X-Forwarded-Proto") != "https" {
|
||||
t.Errorf("X-Forwarded-Proto: got %q", got.HeaderValue("X-Forwarded-Proto"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- location path rewrite ---
|
||||
apiUpstream.ClearRequests()
|
||||
rr = doReq(h, "GET", "proxy-int.example", "/api/foo", authHdr, nil)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("location /api/foo: code=%d", rr.Code)
|
||||
}
|
||||
got, ok := apiUpstream.LastRequest()
|
||||
if !ok {
|
||||
t.Fatal("api upstream saw no request for location")
|
||||
}
|
||||
if got.Path != "/v2/foo" {
|
||||
t.Errorf("location rewrite: upstream path=%q want /v2/foo", got.Path)
|
||||
}
|
||||
if rr.Header().Get("X-Whoami-Path") != "/v2/foo" {
|
||||
t.Errorf("X-Whoami-Path header: %q", rr.Header().Get("X-Whoami-Path"))
|
||||
}
|
||||
|
||||
// --- passAuth=true keeps Authorization ---
|
||||
authUpstream.ClearRequests()
|
||||
rr = doReq(h, "GET", "passauth.example", "/", http.Header{
|
||||
"Authorization": []string{"Basic " + basic("u", "p")},
|
||||
}, nil)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("passauth proxy: code=%d", rr.Code)
|
||||
}
|
||||
got, ok = authUpstream.LastRequest()
|
||||
if !ok {
|
||||
t.Fatal("auth upstream saw no request")
|
||||
}
|
||||
if got.HeaderValue("Authorization") == "" {
|
||||
t.Error("passAuth=true should forward Authorization to upstream")
|
||||
}
|
||||
|
||||
// --- whoami JSON echo on default route ---
|
||||
mainUpstream.ClearRequests()
|
||||
rr = doReq(h, "GET", "proxy-int.example", "/echo-test?q=1", authHdr, nil)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("echo route: code=%d", rr.Code)
|
||||
}
|
||||
var echoed WhoamiRequest
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &echoed); err != nil {
|
||||
t.Fatalf("parse whoami json: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
if echoed.Path != "/echo-test" || echoed.Query != "q=1" {
|
||||
t.Errorf("whoami json echo: path=%q query=%q", echoed.Path, echoed.Query)
|
||||
}
|
||||
|
||||
// --- stream TCP forwarding ---
|
||||
waitForTCP(t, net.JoinHostPort("127.0.0.1", itoa(streamPort)), 5*time.Second)
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(streamPort)), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial stream port %d: %v", streamPort, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := io.WriteString(conn, "ping\n"); err != nil {
|
||||
t.Fatalf("stream write: %v", err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("stream read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(buf[:n]), "STREAM-OK") {
|
||||
t.Errorf("stream echo: got %q", string(buf[:n]))
|
||||
}
|
||||
|
||||
// reload lifecycle: cleanupStreams t.Cleanup also exercises disable path
|
||||
strms := st.GetStreams()
|
||||
if len(strms) == 0 {
|
||||
t.Fatal("no streams in store")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_Integration_Websocket verifies end-to-end websocket upgrade and message echo.
|
||||
func TestEngine_Integration_Websocket(t *testing.T) {
|
||||
eng, st := newTestEngine(t)
|
||||
defer closeStore(st)
|
||||
|
||||
wsUp := NewWhoamiWS(t, "/ws")
|
||||
_, _ = st.CreateProxyHost(store.ProxyHost{
|
||||
DomainNames: []string{"ws-int.example"},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: wsUp.Hostname,
|
||||
ForwardPort: wsUp.Port,
|
||||
Enabled: true,
|
||||
AllowWebsocketUpgrade: true,
|
||||
TrustForwardedProto: true,
|
||||
})
|
||||
eng.ReloadFromStore()
|
||||
|
||||
proxy := newProxyServer(t, eng)
|
||||
ws := dialWhoamiWS(t, proxy.URL, "ws-int.example", "/ws", http.Header{
|
||||
"X-Forwarded-For": []string{"10.9.8.7"},
|
||||
"X-Forwarded-Proto": []string{"https"},
|
||||
})
|
||||
|
||||
if err := websocket.Message.Send(ws, "hello"); err != nil {
|
||||
t.Fatalf("ws send: %v", err)
|
||||
}
|
||||
var reply string
|
||||
if err := websocket.Message.Receive(ws, &reply); err != nil {
|
||||
t.Fatalf("ws recv: %v", err)
|
||||
}
|
||||
if reply != "echo:hello" {
|
||||
t.Errorf("ws echo: got %q", reply)
|
||||
}
|
||||
|
||||
got, ok := wsUp.UpgradeRequest()
|
||||
if !ok {
|
||||
t.Fatal("upstream missed websocket upgrade request")
|
||||
}
|
||||
if got.Path != "/ws" {
|
||||
t.Errorf("upgrade path: %q", got.Path)
|
||||
}
|
||||
if !strings.Contains(got.HeaderValue("X-Forwarded-For"), "10.9.8.7") {
|
||||
t.Errorf("upgrade X-Forwarded-For: %q", got.HeaderValue("X-Forwarded-For"))
|
||||
}
|
||||
if got.HeaderValue("X-Forwarded-Proto") != "https" {
|
||||
t.Errorf("upgrade X-Forwarded-Proto: %q", got.HeaderValue("X-Forwarded-Proto"))
|
||||
}
|
||||
if got.HeaderValue("Connection") != "Upgrade" || got.HeaderValue("Upgrade") != "websocket" {
|
||||
t.Errorf("upgrade headers: conn=%q up=%q", got.HeaderValue("Connection"), got.HeaderValue("Upgrade"))
|
||||
}
|
||||
msgs := wsUp.Messages()
|
||||
if len(msgs) != 1 || msgs[0] != "hello" {
|
||||
t.Errorf("upstream messages: %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_Integration_Caching exercises cache HIT/MISS through whoami upstreams.
|
||||
func TestEngine_Integration_Caching(t *testing.T) {
|
||||
eng, st := newTestEngine(t)
|
||||
defer closeStore(st)
|
||||
|
||||
upstream := NewWhoami(t, WhoamiConfig{
|
||||
DefaultHeaders: map[string]string{"Cache-Control": "max-age=3600"},
|
||||
Routes: map[string]WhoamiRoute{
|
||||
"/asset": {Body: "CACHED-ASSET"},
|
||||
},
|
||||
})
|
||||
_, _ = st.CreateProxyHost(store.ProxyHost{
|
||||
DomainNames: []string{"cache-int.example"},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: upstream.Hostname,
|
||||
ForwardPort: upstream.Port,
|
||||
Enabled: true,
|
||||
CachingEnabled: true,
|
||||
})
|
||||
eng.ReloadFromStore()
|
||||
h := eng.Handler()
|
||||
|
||||
rr1 := doReq(h, "GET", "cache-int.example", "/asset", nil, nil)
|
||||
if rr1.Code != 200 || rr1.Header().Get("X-Proxy-Cache") != "MISS" || rr1.Body.String() != "CACHED-ASSET" {
|
||||
t.Fatalf("cache MISS: code=%d cache=%s body=%q", rr1.Code, rr1.Header().Get("X-Proxy-Cache"), rr1.Body.String())
|
||||
}
|
||||
if reqs := upstream.Requests(); len(reqs) != 1 {
|
||||
t.Fatalf("upstream requests after MISS: %d", len(reqs))
|
||||
}
|
||||
|
||||
rr2 := doReq(h, "GET", "cache-int.example", "/asset", nil, nil)
|
||||
if rr2.Header().Get("X-Proxy-Cache") != "HIT" || rr2.Body.String() != "CACHED-ASSET" {
|
||||
t.Errorf("cache HIT: cache=%s body=%q", rr2.Header().Get("X-Proxy-Cache"), rr2.Body.String())
|
||||
}
|
||||
if reqs := upstream.Requests(); len(reqs) != 1 {
|
||||
t.Errorf("upstream should not be hit again on HIT; requests=%d", len(reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_Integration_ProxyMiddleware exercises ssl_forced, block_exploits, and HSTS
|
||||
// against a real whoami upstream.
|
||||
func TestEngine_Integration_ProxyMiddleware(t *testing.T) {
|
||||
eng, st := newTestEngine(t)
|
||||
defer closeStore(st)
|
||||
|
||||
upstream := NewWhoami(t, WhoamiConfig{})
|
||||
_, _ = st.CreateProxyHost(store.ProxyHost{
|
||||
DomainNames: []string{"middleware.example"},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: upstream.Hostname,
|
||||
ForwardPort: upstream.Port,
|
||||
Enabled: true,
|
||||
SslForced: true,
|
||||
BlockExploits: true,
|
||||
HstsEnabled: true,
|
||||
HstsSubdomains: true,
|
||||
TrustForwardedProto: true,
|
||||
})
|
||||
eng.ReloadFromStore()
|
||||
h := eng.Handler()
|
||||
|
||||
// ssl_forced redirects plain http
|
||||
rr := doReq(h, "GET", "middleware.example", "/ok", http.Header{
|
||||
"X-Forwarded-Proto": []string{"http"},
|
||||
}, nil)
|
||||
if rr.Code != 301 || !strings.HasPrefix(rr.Header().Get("Location"), "https://") {
|
||||
t.Errorf("ssl_forced: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// block_exploits on https path
|
||||
req := httptest.NewRequest("GET", "/.env", nil)
|
||||
req.Host = "middleware.example"
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != 403 || !strings.Contains(rr.Body.String(), "exploits") {
|
||||
t.Errorf("block_exploits: code=%d body=%q", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// HSTS + successful whoami proxy on https
|
||||
req = httptest.NewRequest("GET", "/ok", nil)
|
||||
req.Host = "middleware.example"
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("proxy ok: code=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if hs := rr.Header().Get("Strict-Transport-Security"); !strings.Contains(hs, "includeSubDomains") {
|
||||
t.Errorf("hsts: %q", hs)
|
||||
}
|
||||
got, ok := upstream.LastRequest()
|
||||
if !ok || got.Path != "/ok" {
|
||||
t.Errorf("upstream path: got=%v ok=%v", got.Path, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// issueDevCert creates and issues a self-signed certificate in development mode.
|
||||
func issueDevCert(t *testing.T, st store.Store, domains ...string) store.Certificate {
|
||||
t.Helper()
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
cm := certificate.NewManager(st)
|
||||
rec, _ := st.CreateCertificate(store.Certificate{
|
||||
Provider: "letsencrypt",
|
||||
DomainNames: domains,
|
||||
Meta: map[string]any{},
|
||||
})
|
||||
if err := cm.Issue(rec, cm.GetEmailForCert(rec)); err != nil {
|
||||
t.Fatalf("issue cert: %v", err)
|
||||
}
|
||||
rec, _ = st.GetCertificate(rec.ID)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestEngine_Integration_TLSStream verifies TLS-terminated TCP stream forwarding.
|
||||
func TestEngine_Integration_TLSStream(t *testing.T) {
|
||||
eng, st := newTestEngine(t)
|
||||
defer closeStore(st)
|
||||
t.Setenv("DISABLE_IPV6", "1")
|
||||
|
||||
certRec := issueDevCert(t, st, "stream-tls.example")
|
||||
tcpEcho := NewTCPEcho(t, "STREAM-TLS-OK\n")
|
||||
streamPort := 20000 + int(time.Now().UnixNano()%10000)
|
||||
|
||||
_, _ = st.CreateStream(store.Stream{
|
||||
IncomingPort: streamPort,
|
||||
ForwardingHost: tcpEcho.Host,
|
||||
ForwardingPort: tcpEcho.Port,
|
||||
TCPForwarding: true,
|
||||
Enabled: true,
|
||||
CertificateID: certRec.ID,
|
||||
})
|
||||
cleanupStreams(t, eng, st)
|
||||
eng.ReloadFromStore()
|
||||
|
||||
if eng.getStreamCert(certRec.ID) == nil {
|
||||
t.Fatalf("stream cert %d not loaded after reload", certRec.ID)
|
||||
}
|
||||
|
||||
trust, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "stream-tls.example"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetCertificate: %v", err)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort("127.0.0.1", itoa(streamPort))
|
||||
var waitErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
waitErr = tryWaitForTLSStream(addr, "stream-tls.example", trust, 2*time.Second)
|
||||
if waitErr == nil {
|
||||
break
|
||||
}
|
||||
eng.ReloadFromStore()
|
||||
}
|
||||
if waitErr != nil {
|
||||
t.Fatalf("tls stream listener on %s: %v", addr, waitErr)
|
||||
}
|
||||
|
||||
conn := DialTLSStream(t, addr, "stream-tls.example", trust)
|
||||
if _, err := io.WriteString(conn, "ping\n"); err != nil {
|
||||
t.Fatalf("tls stream write: %v", err)
|
||||
}
|
||||
got := ReadStreamGreeting(t, conn, 64, 2*time.Second)
|
||||
if !strings.Contains(got, "STREAM-TLS-OK") {
|
||||
t.Errorf("tls stream echo: got %q", got)
|
||||
}
|
||||
|
||||
// plain TCP to a TLS listener should fail handshake quickly
|
||||
raw, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("raw dial: %v", err)
|
||||
}
|
||||
defer raw.Close()
|
||||
_ = raw.SetDeadline(time.Now().Add(time.Second))
|
||||
if _, err := io.WriteString(raw, "not-tls\n"); err != nil {
|
||||
t.Fatalf("raw write: %v", err)
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
if _, err := raw.Read(buf); err == nil {
|
||||
t.Error("expected plain TCP to TLS stream to fail read, got data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_Integration_UDPStream verifies UDP stream forwarding.
|
||||
func TestEngine_Integration_UDPStream(t *testing.T) {
|
||||
eng, st := newTestEngine(t)
|
||||
defer closeStore(st)
|
||||
t.Setenv("DISABLE_IPV6", "1") // bind 0.0.0.0 so 127.0.0.1 dials reach the listener
|
||||
|
||||
udpEcho := NewUDPEcho(t, "UDP-STREAM-OK\n")
|
||||
streamPort := pickFreePort(t)
|
||||
|
||||
_, _ = st.CreateStream(store.Stream{
|
||||
IncomingPort: streamPort,
|
||||
ForwardingHost: udpEcho.Host,
|
||||
ForwardingPort: udpEcho.Port,
|
||||
UDPForwarding: true,
|
||||
Enabled: true,
|
||||
})
|
||||
cleanupStreams(t, eng, st)
|
||||
eng.ReloadFromStore()
|
||||
|
||||
addr := net.JoinHostPort("127.0.0.1", itoa(streamPort))
|
||||
waitForUDP(t, addr, 2*time.Second)
|
||||
|
||||
target, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve stream udp: %v", err)
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, target)
|
||||
if err != nil {
|
||||
t.Fatalf("dial stream udp: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
if _, err := conn.Write([]byte("probe")); err != nil {
|
||||
t.Fatalf("udp write: %v", err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("udp read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(buf[:n]), "UDP-STREAM-OK") {
|
||||
t.Errorf("udp stream echo: got %q", string(buf[:n]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"helix-proxy/internal/store"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// WhoamiRequest captures what the upstream mock received.
|
||||
type WhoamiRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Body string `json:"body,omitempty"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
}
|
||||
|
||||
// WhoamiRoute configures a response for a specific path (and optionally method).
|
||||
type WhoamiRoute struct {
|
||||
Method string
|
||||
Status int
|
||||
Body string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// WhoamiConfig configures whoami upstream behavior.
|
||||
type WhoamiConfig struct {
|
||||
DefaultStatus int
|
||||
DefaultBody string
|
||||
DefaultHeaders map[string]string
|
||||
Routes map[string]WhoamiRoute // key: path or "METHOD path"
|
||||
ValidPaths []string // exact paths allowed; empty = all
|
||||
ValidPrefixes []string // path prefixes allowed (checked after ValidPaths)
|
||||
}
|
||||
|
||||
// WhoamiServer is a test upstream that records requests and returns configurable responses.
|
||||
type WhoamiServer struct {
|
||||
Server *httptest.Server
|
||||
Hostname string
|
||||
Port int
|
||||
|
||||
cfg WhoamiConfig
|
||||
|
||||
mu sync.Mutex
|
||||
requests []WhoamiRequest
|
||||
}
|
||||
|
||||
// NewWhoami starts a whoami upstream and registers cleanup on t.
|
||||
func NewWhoami(t *testing.T, cfg WhoamiConfig) *WhoamiServer {
|
||||
t.Helper()
|
||||
if cfg.DefaultStatus == 0 {
|
||||
cfg.DefaultStatus = http.StatusOK
|
||||
}
|
||||
w := &WhoamiServer{cfg: cfg}
|
||||
w.Server = httptest.NewServer(http.HandlerFunc(w.serve))
|
||||
t.Cleanup(w.Server.Close)
|
||||
|
||||
u, err := url.Parse(w.Server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("whoami url: %v", err)
|
||||
}
|
||||
w.Hostname = u.Hostname()
|
||||
w.Port, err = strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("whoami port: %v", err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *WhoamiServer) serve(rw http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
|
||||
req := WhoamiRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Host: r.Host,
|
||||
Headers: cloneHeaderMap(r.Header),
|
||||
Body: string(body),
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.requests = append(w.requests, req)
|
||||
w.mu.Unlock()
|
||||
|
||||
if !w.pathAllowed(r.URL.Path) {
|
||||
http.Error(rw, "whoami: path not allowed", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
route, ok := w.matchRoute(r.Method, r.URL.Path)
|
||||
status := w.cfg.DefaultStatus
|
||||
respBody := w.cfg.DefaultBody
|
||||
respHeaders := map[string]string{}
|
||||
for k, v := range w.cfg.DefaultHeaders {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
|
||||
if ok {
|
||||
if route.Status != 0 {
|
||||
status = route.Status
|
||||
}
|
||||
if route.Body != "" {
|
||||
respBody = route.Body
|
||||
}
|
||||
for k, v := range route.Headers {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// If no custom body, echo the captured request as JSON (whoami-style).
|
||||
if respBody == "" {
|
||||
b, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
http.Error(rw, "whoami: marshal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respBody = string(b)
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.Header().Set("X-Whoami-Method", req.Method)
|
||||
rw.Header().Set("X-Whoami-Path", req.Path)
|
||||
rw.Header().Set("X-Whoami-Host", req.Host)
|
||||
rw.Header().Set("X-Whoami-Query", req.Query)
|
||||
for name, vals := range req.Headers {
|
||||
rw.Header().Set("X-Whoami-H-"+name, strings.Join(vals, ", "))
|
||||
}
|
||||
for k, v := range respHeaders {
|
||||
rw.Header().Set(k, v)
|
||||
}
|
||||
rw.WriteHeader(status)
|
||||
_, _ = io.WriteString(rw, respBody)
|
||||
}
|
||||
|
||||
func (w *WhoamiServer) pathAllowed(path string) bool {
|
||||
if len(w.cfg.ValidPaths) == 0 && len(w.cfg.ValidPrefixes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, p := range w.cfg.ValidPaths {
|
||||
if path == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, p := range w.cfg.ValidPrefixes {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (w *WhoamiServer) matchRoute(method, path string) (WhoamiRoute, bool) {
|
||||
if len(w.cfg.Routes) == 0 {
|
||||
return WhoamiRoute{}, false
|
||||
}
|
||||
key := strings.ToUpper(method) + " " + path
|
||||
if r, ok := w.cfg.Routes[key]; ok {
|
||||
return r, true
|
||||
}
|
||||
if r, ok := w.cfg.Routes[path]; ok {
|
||||
return r, true
|
||||
}
|
||||
return WhoamiRoute{}, false
|
||||
}
|
||||
|
||||
// Requests returns a copy of all recorded upstream requests.
|
||||
func (w *WhoamiServer) Requests() []WhoamiRequest {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
out := make([]WhoamiRequest, len(w.requests))
|
||||
copy(out, w.requests)
|
||||
return out
|
||||
}
|
||||
|
||||
// LastRequest returns the most recent upstream request, if any.
|
||||
func (w *WhoamiServer) LastRequest() (WhoamiRequest, bool) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.requests) == 0 {
|
||||
return WhoamiRequest{}, false
|
||||
}
|
||||
return w.requests[len(w.requests)-1], true
|
||||
}
|
||||
|
||||
// ClearRequests resets the recorded request history.
|
||||
func (w *WhoamiServer) ClearRequests() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.requests = nil
|
||||
}
|
||||
|
||||
// ParseWhoamiBody decodes a JSON whoami echo body from a proxied response.
|
||||
func ParseWhoamiBody(body []byte) (WhoamiRequest, error) {
|
||||
var req WhoamiRequest
|
||||
err := json.Unmarshal(body, &req)
|
||||
return req, err
|
||||
}
|
||||
|
||||
// HeaderValue returns the first value for a header name (case-insensitive).
|
||||
func (r WhoamiRequest) HeaderValue(name string) string {
|
||||
for k, vs := range r.Headers {
|
||||
if strings.EqualFold(k, name) && len(vs) > 0 {
|
||||
return vs[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneHeaderMap(h http.Header) map[string][]string {
|
||||
out := make(map[string][]string, len(h))
|
||||
for k, vs := range h {
|
||||
cp := make([]string, len(vs))
|
||||
copy(cp, vs)
|
||||
out[k] = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TCPEchoServer accepts connections and echoes a fixed greeting.
|
||||
type TCPEchoServer struct {
|
||||
Listener net.Listener
|
||||
Host string
|
||||
Port int
|
||||
Greeting string
|
||||
}
|
||||
|
||||
// NewTCPEcho starts a TCP server that writes Greeting on each accepted connection.
|
||||
func NewTCPEcho(t *testing.T, greeting string) *TCPEchoServer {
|
||||
t.Helper()
|
||||
if greeting == "" {
|
||||
greeting = "STREAM-ECHO\n"
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("tcp echo listen: %v", err)
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
s := &TCPEchoServer{Listener: ln, Host: "127.0.0.1", Port: port, Greeting: greeting}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_, _ = io.WriteString(c, s.Greeting)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
// pickFreePort returns a likely-free TCP port on 127.0.0.1.
|
||||
func pickFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("pick port: %v", err)
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
_ = ln.Close()
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return port
|
||||
}
|
||||
|
||||
// UDPEchoServer responds to each datagram with a fixed payload.
|
||||
type UDPEchoServer struct {
|
||||
Conn *net.UDPConn
|
||||
Host string
|
||||
Port int
|
||||
Response []byte
|
||||
}
|
||||
|
||||
// NewUDPEcho starts a UDP server that writes Response for every datagram received.
|
||||
func NewUDPEcho(t *testing.T, response string) *UDPEchoServer {
|
||||
t.Helper()
|
||||
if response == "" {
|
||||
response = "UDP-ECHO\n"
|
||||
}
|
||||
addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("udp resolve: %v", err)
|
||||
}
|
||||
conn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("udp listen: %v", err)
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
s := &UDPEchoServer{
|
||||
Conn: conn,
|
||||
Host: "127.0.0.1",
|
||||
Port: port,
|
||||
Response: []byte(response),
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, client, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = conn.WriteToUDP(s.Response, client)
|
||||
_ = n
|
||||
}
|
||||
}()
|
||||
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
// tryWaitForTLSStream returns nil once a TLS stream listener accepts connections.
|
||||
func tryWaitForTLSStream(addr, serverName string, trust *tls.Certificate, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
pool := x509.NewCertPool()
|
||||
if len(trust.Certificate) > 0 {
|
||||
if leaf, err := x509.ParseCertificate(trust.Certificate[0]); err == nil {
|
||||
pool.AddCert(leaf)
|
||||
}
|
||||
}
|
||||
cfg := &tls.Config{ServerName: serverName, RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 200 * time.Millisecond}, "tcp", addr, cfg)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// waitForTLSStream dials addr with TLS until success or timeout.
|
||||
func waitForTLSStream(t *testing.T, addr, serverName string, trust *tls.Certificate, timeout time.Duration) {
|
||||
t.Helper()
|
||||
if err := tryWaitForTLSStream(addr, serverName, trust, timeout); err != nil {
|
||||
t.Fatalf("wait for tls stream %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// tryWaitForTCP returns nil once a TCP listener accepts connections.
|
||||
func tryWaitForTCP(addr string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// waitForTCP dials addr until success or timeout (stream listeners start async).
|
||||
func waitForTCP(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
if err := tryWaitForTCP(addr, timeout); err != nil {
|
||||
t.Fatalf("wait for tcp %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// tlsConfigForCert builds a client TLS config that trusts the given server certificate.
|
||||
func tlsConfigForCert(t *testing.T, serverName string, cert *tls.Certificate) *tls.Config {
|
||||
t.Helper()
|
||||
if len(cert.Certificate) == 0 {
|
||||
t.Fatal("tls cert has no certificate chain")
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(cert.Certificate[0]) {
|
||||
// X509KeyPair stores DER; re-parse for the pool
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf cert: %v", err)
|
||||
}
|
||||
pool.AddCert(leaf)
|
||||
}
|
||||
return &tls.Config{
|
||||
ServerName: serverName,
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
// DialTLSStream connects to a TLS-terminated stream listener.
|
||||
func DialTLSStream(t *testing.T, addr, serverName string, trust *tls.Certificate) net.Conn {
|
||||
t.Helper()
|
||||
var cfg *tls.Config
|
||||
if trust != nil {
|
||||
cfg = tlsConfigForCert(t, serverName, trust)
|
||||
} else {
|
||||
cfg = &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec // test-only self-signed dev certs
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", addr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("tls dial %s: %v", addr, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// waitForUDP sends a probe datagram until a response arrives or timeout.
|
||||
func waitForUDP(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
target, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve udp %s: %v", addr, err)
|
||||
}
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialUDP("udp", nil, target)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
if _, err := conn.Write([]byte("probe")); err != nil {
|
||||
lastErr = err
|
||||
_ = conn.Close()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
buf := make([]byte, 8)
|
||||
_, err = conn.Read(buf)
|
||||
_ = conn.Close()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("wait for udp %s: %v", addr, lastErr)
|
||||
}
|
||||
|
||||
// WhoamiWSServer is a websocket upstream that records the upgrade request and echoes messages.
|
||||
type WhoamiWSServer struct {
|
||||
Server *httptest.Server
|
||||
Hostname string
|
||||
Port int
|
||||
Path string
|
||||
|
||||
mu sync.Mutex
|
||||
upgradeReq WhoamiRequest
|
||||
messages []string
|
||||
}
|
||||
|
||||
// NewWhoamiWS starts a websocket echo upstream on path (default /ws).
|
||||
func NewWhoamiWS(t *testing.T, path string) *WhoamiWSServer {
|
||||
t.Helper()
|
||||
if path == "" {
|
||||
path = "/ws"
|
||||
}
|
||||
w := &WhoamiWSServer{Path: path}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(path, websocket.Handler(w.handleWS))
|
||||
w.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(w.Server.Close)
|
||||
|
||||
u, err := url.Parse(w.Server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("whoami ws url: %v", err)
|
||||
}
|
||||
w.Hostname = u.Hostname()
|
||||
w.Port, err = strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("whoami ws port: %v", err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *WhoamiWSServer) handleWS(ws *websocket.Conn) {
|
||||
req := ws.Request()
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
_ = req.Body.Close()
|
||||
captured := WhoamiRequest{
|
||||
Method: req.Method,
|
||||
Path: req.URL.Path,
|
||||
Query: req.URL.RawQuery,
|
||||
Host: req.Host,
|
||||
Headers: cloneHeaderMap(req.Header),
|
||||
Body: string(body),
|
||||
RemoteAddr: req.RemoteAddr,
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.upgradeReq = captured
|
||||
w.mu.Unlock()
|
||||
|
||||
for {
|
||||
var msg string
|
||||
if err := websocket.Message.Receive(ws, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.messages = append(w.messages, msg)
|
||||
w.mu.Unlock()
|
||||
if err := websocket.Message.Send(ws, "echo:"+msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpgradeRequest returns the HTTP request observed at websocket upgrade time.
|
||||
func (w *WhoamiWSServer) UpgradeRequest() (WhoamiRequest, bool) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.upgradeReq.Method == "" {
|
||||
return WhoamiRequest{}, false
|
||||
}
|
||||
return w.upgradeReq, true
|
||||
}
|
||||
|
||||
// Messages returns client messages received after upgrade.
|
||||
func (w *WhoamiWSServer) Messages() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
out := make([]string, len(w.messages))
|
||||
copy(out, w.messages)
|
||||
return out
|
||||
}
|
||||
|
||||
// cleanupStreams disables all streams and reloads the engine on test end.
|
||||
func cleanupStreams(t *testing.T, eng *Engine, st store.Store) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
for _, s := range st.GetStreams() {
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
s.Enabled = false
|
||||
_, _ = st.UpdateStream(s)
|
||||
}
|
||||
eng.ReloadFromStore()
|
||||
})
|
||||
}
|
||||
|
||||
// newProxyServer exposes eng.Handler() on an httptest server (needed for websocket hijack).
|
||||
func newProxyServer(t *testing.T, eng *Engine) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(eng.Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// dialWhoamiWS connects through a proxy front to the websocket upstream path.
|
||||
// The vhost is embedded in the handshake URL (Host header); TCP dials proxyURL directly.
|
||||
func dialWhoamiWS(t *testing.T, proxyURL, vhost, wsPath string, hdrs http.Header) *websocket.Conn {
|
||||
t.Helper()
|
||||
proxyU, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
t.Fatalf("proxy url: %v", err)
|
||||
}
|
||||
wsURL := "ws://" + vhost + wsPath
|
||||
cfg, err := websocket.NewConfig(wsURL, "http://"+vhost+"/")
|
||||
if err != nil {
|
||||
t.Fatalf("ws config: %v", err)
|
||||
}
|
||||
if hdrs != nil {
|
||||
cfg.Header = hdrs.Clone()
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", proxyU.Host, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("tcp dial %s: %v", proxyU.Host, err)
|
||||
}
|
||||
ws, err := websocket.NewClient(cfg, conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatalf("ws handshake %s via %s: %v", wsURL, proxyU.Host, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ws.Close() })
|
||||
return ws
|
||||
}
|
||||
|
||||
// ReadStreamGreeting reads up to n bytes from conn before deadline.
|
||||
func ReadStreamGreeting(t *testing.T, conn net.Conn, n int, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
buf := make([]byte, n)
|
||||
got, err := io.ReadAtLeast(conn, buf, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("stream read: %v", err)
|
||||
}
|
||||
return string(buf[:got])
|
||||
}
|
||||
Reference in New Issue
Block a user