619 lines
16 KiB
Go
619 lines
16 KiB
Go
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])
|
|
}
|