Initial vibeproxy implementation

Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go,
with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS
framework), and a REST API under /api/v1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
This commit is contained in:
2026-08-17 10:28:59 -05:00
co-authored by Claude Sonnet 5
commit ba1f7c4c03
105 changed files with 9142 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
package proxy
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/owen/vibeproxy/internal/loadbalancer"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/models"
"github.com/owen/vibeproxy/internal/runtime"
)
// sharedTransport is reused across every proxied request so backend
// connections are pooled (keep-alive) instead of dialed fresh each time.
// It supports HTTP/2 to upstreams that offer it via ALPN.
var sharedTransport = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
// buildReverseProxy constructs a one-shot ReverseProxy for a single
// request/backend pair. Constructing it per request is cheap (no
// goroutines or connections are created here); the expensive parts —
// backend TCP/TLS connections — are pooled by sharedTransport across
// calls.
func buildReverseProxy(rt *runtime.ProxyHostRuntime, backend *loadbalancer.Backend, snap *runtime.Snapshot, log *slog.Logger) *httputil.ReverseProxy {
targetScheme := rt.Model.ForwardScheme
if targetScheme == "" {
targetScheme = "http"
}
targetAddr := net.JoinHostPort(backend.Model.Host, fmt.Sprintf("%d", backend.Model.Port))
director := func(req *http.Request) {
clientIP := ClientIP(req, snap)
priorFor := req.Header.Get("X-Forwarded-For")
req.URL.Scheme = targetScheme
req.URL.Host = targetAddr
if !rt.Model.PreserveHostHeader {
req.Host = targetAddr
}
// else: leave req.Host as the original inbound Host header
if priorFor == "" {
req.Header.Set("X-Forwarded-For", clientIP)
} else {
req.Header.Set("X-Forwarded-For", priorFor+", "+clientIP)
}
proto := "http"
if req.TLS != nil {
proto = "https"
}
req.Header.Set("X-Forwarded-Proto", proto)
if fh := req.Header.Get("X-Forwarded-Host"); fh == "" {
req.Header.Set("X-Forwarded-Host", req.Host)
}
req.Header.Set("X-Real-IP", clientIP)
applyCustomHeaders(req.Header, rt.Model.CustomHeaders, models.HeaderDirectionRequest)
// WebSocket upgrade headers must pass through untouched; Go's
// httputil.ReverseProxy handles the hijack/tunnel automatically
// once Connection: Upgrade is present, which we never strip.
if !rt.Model.WebsocketSupport {
req.Header.Del("Upgrade")
req.Header.Set("Connection", "close")
}
}
modifyResponse := func(resp *http.Response) error {
applyCustomHeaders(resp.Header, rt.Model.CustomHeaders, models.HeaderDirectionResponse)
if rt.Model.HSTSEnabled && resp.Request.TLS != nil {
resp.Header.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
return nil
}
errorHandler := func(w http.ResponseWriter, r *http.Request, err error) {
log.Warn("backend request failed", "proxy_host", rt.Model.Name, "backend", targetAddr, "err", err)
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "502").Inc()
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
return &httputil.ReverseProxy{
Director: director,
ModifyResponse: modifyResponse,
ErrorHandler: errorHandler,
Transport: sharedTransport,
FlushInterval: 200 * time.Millisecond, // needed for SSE/streaming responses
}
}
func applyCustomHeaders(h http.Header, headers []models.CustomHeader, direction models.CustomHeaderDirection) {
for _, c := range headers {
if c.Direction != direction {
continue
}
switch c.Action {
case models.HeaderActionSet:
h.Set(c.Name, c.Value)
case models.HeaderActionRemove:
h.Del(c.Name)
}
}
}
// withConnectTimeout returns a context that bounds only the dial phase of
// an outbound request via a deadline; read/write timeouts are handled by
// the surrounding http.Server / http.ResponseController.
func withConnectTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
if d <= 0 {
d = 5 * time.Second
}
deadline := time.Now().Add(d)
return context.WithDeadline(ctx, deadline)
}
func normalizeHostHeader(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(h)
}
return strings.ToLower(host)
}