diff --git a/README.md b/README.md index 37be2c8..b205aff 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,97 @@ # smallprox +**smallprox** is a lightweight, zero-dependency reverse proxy written in Go. It accepts incoming client connections over **TLS 1.2** (or configurable TLS versions) and forwards requests to backends running modern TLS protocols (such as TLS 1.3) or any SSL/TLS version. + +## Key Features + +- **TLS Protocol Bridging**: Solves the incompatibility where legacy clients (e.g. PowerShell scripts configured for TLS 1.2) need to access backends requiring TLS 1.3. +- **Zero Configuration HTTPS**: Generates an in-memory self-signed certificate on startup with localhost and IP Subject Alternative Names (SANs) if no cert/key files are provided. +- **Full Backend Trust**: Bypasses backend certificate checks (`InsecureSkipVerify: true`) to work with self-signed, untrusted, or internal certificates. +- **Virtual Host & SNI Support**: Automatically updates the `Host` header to match the destination backend (can be disabled with `-preserve-host`). +- **Zero External Dependencies**: Built entirely with Go standard library (`net/http`, `crypto/tls`, `crypto/x509`, `net/http/httputil`). + +--- + +## Installation & Building + +```powershell +# Clone and build +cd smallprox +go build -o smallprox.exe . +``` + +--- + +## Usage + +```powershell +smallprox -backend [options] +``` + +If no backend is provided, usage instructions are automatically printed. + +### Command-line Options + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `-backend` | `-b` | *(Required)* | Target backend URL (e.g. `https://tls13.example.com`) | +| `-listen` | `-l` | `:8443` | Address and port to bind | +| `-tls-min` | | `1.2` | Minimum incoming TLS version (`1.0`, `1.1`, `1.2`, `1.3`) | +| `-tls-max` | | `1.2` | Maximum incoming TLS version (`1.0`, `1.1`, `1.2`, `1.3`) | +| `-cert` | | | Path to custom TLS certificate PEM file (optional) | +| `-key` | | | Path to custom TLS private key PEM file (optional) | +| `-http` | | `false` | Listen in plain HTTP mode instead of HTTPS | +| `-preserve-host` | | `false` | Preserve incoming Host header instead of target host | +| `-version` | `-v` | | Print version and exit | +| `-help` | `-h` | | Show help message | + +--- + +## Examples + +### 1. Basic TLS 1.2 Proxy to TLS 1.3 Backend +```powershell +.\smallprox.exe -backend https://api.example.com +``` +*Listens on `https://127.0.0.1:8443` with TLS 1.2 and proxies to `https://api.example.com`.* + +### 2. Custom Port and TLS Range +```powershell +.\smallprox.exe -listen :9443 -backend https://api.example.com -tls-min 1.2 -tls-max 1.3 +``` + +### 3. Using Custom Certificates +```powershell +.\smallprox.exe -backend https://api.example.com -cert server.crt -key server.key +``` + +### 4. Plain HTTP Ingress to HTTPS Backend +```powershell +.\smallprox.exe -http -listen :8080 -backend https://api.example.com +``` + +--- + +## PowerShell Client Example + +To communicate through `smallprox` from a PowerShell session requiring TLS 1.2 and trusting self-signed certificates: + +```powershell +# Enforce TLS 1.2 in PowerShell +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Trust local self-signed certificate +[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} + +# Make request through proxy +$response = Invoke-RestMethod -Uri "https://localhost:8443/api/v1/resource" -Method Get +Write-Output $response +``` + +--- + +## Running Tests + +```powershell +go test -v ./... +``` diff --git a/cert.go b/cert.go new file mode 100644 index 0000000..72cb359 --- /dev/null +++ b/cert.go @@ -0,0 +1,74 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net" + "os" + "time" +) + +// generateSelfSignedCert creates an in-memory self-signed TLS certificate. +func generateSelfSignedCert(extraHosts []string) (tls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err) + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to generate serial number: %w", err) + } + + dnsNames := []string{"localhost"} + ipAddresses := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")} + + if hostname, err := os.Hostname(); err == nil && hostname != "" { + dnsNames = append(dnsNames, hostname) + } + + for _, host := range extraHosts { + if host == "" { + continue + } + if ip := net.ParseIP(host); ip != nil { + ipAddresses = append(ipAddresses, ip) + } else { + dnsNames = append(dnsNames, host) + } + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"smallprox"}, + CommonName: "smallprox-localhost", + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: dnsNames, + IPAddresses: ipAddresses, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err) + } + + cert := tls.Certificate{ + Certificate: [][]byte{derBytes}, + PrivateKey: priv, + } + + return cert, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ef27d54 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.stevenpolley.net/steven/smallprox + +go 1.24 diff --git a/main.go b/main.go new file mode 100644 index 0000000..e64f380 --- /dev/null +++ b/main.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "crypto/tls" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +var ( + version = "1.0.0" +) + +func parseTLSVersion(v string) (uint16, error) { + switch strings.TrimSpace(v) { + case "1.0", "tls1.0", "TLS1.0": + return tls.VersionTLS10, nil + case "1.1", "tls1.1", "TLS1.1": + return tls.VersionTLS11, nil + case "1.2", "tls1.2", "TLS1.2": + return tls.VersionTLS12, nil + case "1.3", "tls1.3", "TLS1.3": + return tls.VersionTLS13, nil + default: + return 0, fmt.Errorf("unknown TLS version %q (valid options: 1.0, 1.1, 1.2, 1.3)", v) + } +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `smallprox v%s - Miniature TLS 1.2 Reverse Proxy + +Usage: + smallprox -backend [options] + +Options: + -backend, -b string + Target backend URL (required, e.g. https://tls13-service.example.com) + -listen, -l string + Address and port to bind to (default ":8443") + -tls-min string + Minimum incoming TLS version: 1.0, 1.1, 1.2, 1.3 (default "1.2") + -tls-max string + Maximum incoming TLS version: 1.0, 1.1, 1.2, 1.3 (default "1.2") + -cert string + Path to TLS certificate PEM file (optional; self-signed generated if omitted) + -key string + Path to TLS private key PEM file (optional; self-signed generated if omitted) + -http + Listen in plain HTTP mode instead of HTTPS (default false) + -preserve-host + Preserve incoming Host header instead of rewriting to backend host (default false) + -version, -v + Print version information and exit + -help, -h + Show this help message + +Examples: + # Basic reverse proxy to a TLS 1.3 backend (PowerShell -> TLS 1.2 proxy:8443 -> TLS 1.3 backend) + smallprox -backend https://api.example.com + + # Custom listen port and specific TLS range + smallprox -listen :9443 -backend https://api.example.com -tls-min 1.2 -tls-max 1.3 + + # Using custom certificates + smallprox -backend https://api.example.com -cert server.crt -key server.key + + # PowerShell usage example: + # [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + # [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} + # Invoke-RestMethod -Uri https://localhost:8443/api/endpoint +`, version) +} + +func main() { + var ( + backendFlag string + backendShort string + listenAddr string + listenShort string + certFile string + keyFile string + tlsMinStr string + tlsMaxStr string + plainHTTP bool + preserveHost bool + showVersion bool + showVersionShort bool + showHelp bool + showHelpShort bool + ) + + flag.StringVar(&backendFlag, "backend", "", "Target backend URL") + flag.StringVar(&backendShort, "b", "", "Target backend URL (short)") + flag.StringVar(&listenAddr, "listen", ":8443", "Listen address") + flag.StringVar(&listenShort, "l", ":8443", "Listen address (short)") + flag.StringVar(&certFile, "cert", "", "Path to TLS cert file") + flag.StringVar(&keyFile, "key", "", "Path to TLS key file") + flag.StringVar(&tlsMinStr, "tls-min", "1.2", "Minimum incoming TLS version") + flag.StringVar(&tlsMaxStr, "tls-max", "1.2", "Maximum incoming TLS version") + flag.BoolVar(&plainHTTP, "http", false, "Listen in plain HTTP mode") + flag.BoolVar(&preserveHost, "preserve-host", false, "Preserve incoming Host header") + flag.BoolVar(&showVersion, "version", false, "Print version") + flag.BoolVar(&showVersionShort, "v", false, "Print version (short)") + flag.BoolVar(&showHelp, "help", false, "Show help") + flag.BoolVar(&showHelpShort, "h", false, "Show help (short)") + + flag.Usage = printUsage + flag.Parse() + + if showHelp || showHelpShort { + printUsage() + os.Exit(0) + } + + if showVersion || showVersionShort { + fmt.Printf("smallprox version %s\n", version) + os.Exit(0) + } + + // Resolve flags with short versions + rawBackend := backendFlag + if rawBackend == "" { + rawBackend = backendShort + } + // Also check positional argument if no flag given + if rawBackend == "" && flag.NArg() > 0 { + rawBackend = flag.Arg(0) + } + + if rawBackend == "" { + fmt.Fprintln(os.Stderr, "[ERROR] Backend URL is required.") + printUsage() + os.Exit(1) + } + + targetURL, err := ParseTargetURL(rawBackend) + if err != nil { + log.Fatalf("[FATAL] %v", err) + } + + // Determine listen address + bindAddr := listenAddr + if bindAddr == ":8443" && listenShort != ":8443" { + bindAddr = listenShort + } + + // Parse TLS versions + minTLS, err := parseTLSVersion(tlsMinStr) + if err != nil { + log.Fatalf("[FATAL] Invalid -tls-min: %v", err) + } + maxTLS, err := parseTLSVersion(tlsMaxStr) + if err != nil { + log.Fatalf("[FATAL] Invalid -tls-max: %v", err) + } + if minTLS > maxTLS { + log.Fatalf("[FATAL] -tls-min (%s) cannot be greater than -tls-max (%s)", tlsMinStr, tlsMaxStr) + } + + proxyHandler := NewProxyHandler(ProxyConfig{ + TargetURL: targetURL, + PreserveHost: preserveHost, + }) + + server := &http.Server{ + Addr: bindAddr, + Handler: proxyHandler, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + protocol := "HTTPS" + if plainHTTP { + protocol = "HTTP" + } else { + tlsConfig := &tls.Config{ + MinVersion: minTLS, + MaxVersion: maxTLS, + } + + if certFile != "" && keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatalf("[FATAL] Failed to load certificate/key: %v", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + log.Printf("[INFO] Loaded TLS certificate from %s and %s", certFile, keyFile) + } else { + hostPart, _, _ := net.SplitHostPort(bindAddr) + extraHosts := []string{} + if hostPart != "" { + extraHosts = append(extraHosts, hostPart) + } + cert, err := generateSelfSignedCert(extraHosts) + if err != nil { + log.Fatalf("[FATAL] Failed to generate self-signed certificate: %v", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + log.Printf("[INFO] Generated ephemeral self-signed TLS certificate") + } + + server.TLSConfig = tlsConfig + } + + // Channel to listen for shutdown signals + stopChan := make(chan os.Signal, 1) + signal.Notify(stopChan, os.Interrupt, syscall.SIGTERM) + + go func() { + log.Printf("[INFO] smallprox v%s starting...", version) + log.Printf("[INFO] Listening on %s://%s (incoming TLS: %s - %s)", protocol, bindAddr, tlsVersionName(minTLS), tlsVersionName(maxTLS)) + log.Printf("[INFO] Proxying to %s (backend TLS verification disabled)", targetURL.String()) + + var err error + if plainHTTP { + err = server.ListenAndServe() + } else { + // TLSConfig already contains the certificate + err = server.ListenAndServeTLS("", "") + } + + if err != nil && err != http.ErrServerClosed { + log.Fatalf("[FATAL] Server listener error: %v", err) + } + }() + + <-stopChan + log.Printf("[INFO] Shutting down smallprox gracefully...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("[ERROR] Server shutdown error: %v", err) + } + + log.Printf("[INFO] smallprox stopped.") +} diff --git a/proxy.go b/proxy.go new file mode 100644 index 0000000..b4949d0 --- /dev/null +++ b/proxy.go @@ -0,0 +1,144 @@ +package main + +import ( + "crypto/tls" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" +) + +// ProxyConfig holds options for the reverse proxy. +type ProxyConfig struct { + TargetURL *url.URL + PreserveHost bool + Verbose bool +} + +// loggingResponseWriter wraps http.ResponseWriter to capture the HTTP status code. +type loggingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +// NewProxyHandler creates an http.Handler that reverse proxies requests to the target URL. +func NewProxyHandler(cfg ProxyConfig) http.Handler { + proxy := httputil.NewSingleHostReverseProxy(cfg.TargetURL) + + // Transport configured with InsecureSkipVerify to trust all backend certificates + proxy.Transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS10, + MaxVersion: tls.VersionTLS13, + }, + } + + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + + // Set Host header to target host unless preserve host is explicitly requested. + // This is vital for backend virtual hosting and SNI. + if !cfg.PreserveHost { + req.Host = cfg.TargetURL.Host + } + + if clientProto := req.Header.Get("X-Forwarded-Proto"); clientProto == "" { + if req.TLS != nil { + req.Header.Set("X-Forwarded-Proto", "https") + } else { + req.Header.Set("X-Forwarded-Proto", "http") + } + } + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("[ERROR] %s %s -> Backend error: %v", r.Method, r.URL.RequestURI(), err) + http.Error(w, fmt.Sprintf("Bad Gateway (smallprox): %v", err), http.StatusBadGateway) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + proxy.ServeHTTP(lrw, r) + + duration := time.Since(start) + tlsVer := "HTTP" + if r.TLS != nil { + tlsVer = tlsVersionName(r.TLS.Version) + } + + log.Printf("[%s] %s | %s %s -> %d (%s)", + tlsVer, + r.RemoteAddr, + r.Method, + r.URL.RequestURI(), + lrw.statusCode, + duration.Round(time.Millisecond/10), + ) + }) +} + +// ParseTargetURL cleans and parses the backend target URL string. +func ParseTargetURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("backend URL cannot be empty") + } + + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + + parsed, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid backend URL %q: %w", raw, err) + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("unsupported backend scheme %q (must be http or https)", parsed.Scheme) + } + + if parsed.Host == "" { + return nil, fmt.Errorf("backend URL must have a host") + } + + return parsed, nil +} + +// tlsVersionName returns the human-readable string for a TLS version identifier. +func tlsVersionName(ver uint16) string { + switch ver { + case tls.VersionTLS10: + return "TLS 1.0" + case tls.VersionTLS11: + return "TLS 1.1" + case tls.VersionTLS12: + return "TLS 1.2" + case tls.VersionTLS13: + return "TLS 1.3" + default: + return fmt.Sprintf("TLS 0x%04x", ver) + } +} diff --git a/proxy_test.go b/proxy_test.go new file mode 100644 index 0000000..1c87a7d --- /dev/null +++ b/proxy_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestParseTLSVersion(t *testing.T) { + tests := []struct { + input string + want uint16 + wantErr bool + }{ + {"1.0", tls.VersionTLS10, false}, + {"TLS1.0", tls.VersionTLS10, false}, + {"1.1", tls.VersionTLS11, false}, + {"1.2", tls.VersionTLS12, false}, + {"tls1.2", tls.VersionTLS12, false}, + {"1.3", tls.VersionTLS13, false}, + {"2.0", 0, true}, + {"invalid", 0, true}, + } + + for _, tt := range tests { + got, err := parseTLSVersion(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseTLSVersion(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("parseTLSVersion(%q) = %x, want %x", tt.input, got, tt.want) + } + } +} + +func TestParseTargetURL(t *testing.T) { + tests := []struct { + input string + wantHost string + wantSch string + wantErr bool + }{ + {"https://example.com", "example.com", "https", false}, + {"http://localhost:9000", "localhost:9000", "http", false}, + {"example.com:8443", "example.com:8443", "https", false}, + {"", "", "", true}, + {"ftp://example.com", "", "", true}, + } + + for _, tt := range tests { + u, err := ParseTargetURL(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTargetURL(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if !tt.wantErr { + if u.Host != tt.wantHost || u.Scheme != tt.wantSch { + t.Errorf("ParseTargetURL(%q) = %s://%s, want %s://%s", tt.input, u.Scheme, u.Host, tt.wantSch, tt.wantHost) + } + } + } +} + +func TestGenerateSelfSignedCert(t *testing.T) { + cert, err := generateSelfSignedCert([]string{"custom.local", "192.168.1.50"}) + if err != nil { + t.Fatalf("generateSelfSignedCert() failed: %v", err) + } + + if len(cert.Certificate) == 0 { + t.Fatal("generateSelfSignedCert() returned empty certificate chain") + } + if cert.PrivateKey == nil { + t.Fatal("generateSelfSignedCert() returned nil private key") + } +} + +func TestProxy_TLS12_To_TLS13_Backend(t *testing.T) { + // 1. Setup backend server that ONLY accepts TLS 1.3 + backendHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/test-endpoint" { + w.Header().Set("X-Backend-Received-Proto", r.Proto) + w.Header().Set("X-Backend-Received-Host", r.Host) + w.WriteHeader(http.StatusOK) + body, _ := io.ReadAll(r.Body) + w.Write([]byte("backend-response: " + string(body))) + return + } + http.NotFound(w, r) + }) + + backendServer := httptest.NewUnstartedServer(backendHandler) + backendServer.TLS = &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + } + backendServer.StartTLS() + defer backendServer.Close() + + backendURL, err := url.Parse(backendServer.URL) + if err != nil { + t.Fatalf("failed to parse backend URL: %v", err) + } + + // 2. Setup smallprox handler pointing to the TLS 1.3 backend + proxyHandler := NewProxyHandler(ProxyConfig{ + TargetURL: backendURL, + PreserveHost: false, + }) + + proxyCert, err := generateSelfSignedCert(nil) + if err != nil { + t.Fatalf("failed to generate proxy cert: %v", err) + } + + proxyServer := httptest.NewUnstartedServer(proxyHandler) + // Proxy frontend only accepts TLS 1.2 + proxyServer.TLS = &tls.Config{ + Certificates: []tls.Certificate{proxyCert}, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + } + proxyServer.StartTLS() + defer proxyServer.Close() + + // 3. Client configured strictly for TLS 1.2 + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + }, + }, + } + + // Make request through proxy + req, err := http.NewRequest(http.MethodPost, proxyServer.URL+"/test-endpoint", strings.NewReader("hello-from-tls12-client")) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client request through proxy failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200 OK, got %d", resp.StatusCode) + } + + if resp.TLS == nil || resp.TLS.Version != tls.VersionTLS12 { + t.Errorf("expected client TLS version to be TLS 1.2 (0x0303), got %x", resp.TLS.Version) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + expectedBody := "backend-response: hello-from-tls12-client" + if string(body) != expectedBody { + t.Errorf("expected body %q, got %q", expectedBody, string(body)) + } + + // Verify Host was rewritten to backend host + if receivedHost := resp.Header.Get("X-Backend-Received-Host"); receivedHost != backendURL.Host { + t.Errorf("expected backend to receive host %q, got %q", backendURL.Host, receivedHost) + } +} + +func TestProxy_PreserveHost(t *testing.T) { + backendHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Received-Host", r.Host) + w.WriteHeader(http.StatusOK) + }) + + backendServer := httptest.NewServer(backendHandler) + defer backendServer.Close() + + backendURL, err := url.Parse(backendServer.URL) + if err != nil { + t.Fatalf("failed to parse backend URL: %v", err) + } + + proxyHandler := NewProxyHandler(ProxyConfig{ + TargetURL: backendURL, + PreserveHost: true, + }) + + proxyServer := httptest.NewServer(proxyHandler) + defer proxyServer.Close() + + req, err := http.NewRequest(http.MethodGet, proxyServer.URL+"/test", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Host = "custom-domain.net" + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if got := resp.Header.Get("X-Received-Host"); got != "custom-domain.net" { + t.Errorf("expected preserved host %q, got %q", "custom-domain.net", got) + } +}