initial commit
This commit is contained in:
@@ -1,2 +1,97 @@
|
|||||||
# smallprox
|
# 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 <url> [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 ./...
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <url> [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.")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user