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
+43
View File
@@ -0,0 +1,43 @@
package proxy
import (
"net"
"net/http"
"strings"
"github.com/owen/vibeproxy/internal/runtime"
)
// ClientIP returns the real client IP for r. If the immediate peer is a
// trusted proxy (per snap.TrustedProxies), the right-most untrusted entry
// in X-Forwarded-For is used; otherwise X-Forwarded-For is ignored
// entirely and the TCP peer address is authoritative. This is the only
// place XFF is trusted, and only for configured networks.
func ClientIP(r *http.Request, snap *runtime.Snapshot) string {
peerIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peerIP = r.RemoteAddr
}
ip := net.ParseIP(peerIP)
if ip == nil || snap == nil || !snap.IsTrustedProxy(ip) {
return peerIP
}
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return peerIP
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
candidate := strings.TrimSpace(parts[i])
cip := net.ParseIP(candidate)
if cip == nil {
continue
}
if snap.IsTrustedProxy(cip) {
continue // still a known proxy hop, keep walking left
}
return candidate
}
return peerIP
}
+193
View File
@@ -0,0 +1,193 @@
package proxy
import (
"database/sql"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/owen/vibeproxy/internal/access"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/models"
"github.com/owen/vibeproxy/internal/runtime"
)
// maxRequestBodyBytes bounds request bodies to protect the proxy itself;
// it is generous enough for large uploads while still being a hard limit.
const maxRequestBodyBytes = 5 << 30 // 5 GiB
// Handler is the single entry point for both the HTTP and HTTPS listeners.
// It resolves the target proxy host, enforces access control and
// force-HTTPS/redirect rules, picks a healthy backend, and proxies the
// request — or serves an ACME HTTP-01 challenge.
type Handler struct {
store *runtime.Store
certMgr *certificates.Manager
reqLog *logging.RequestLogger
db *sql.DB
log *slog.Logger
}
func NewHandler(store *runtime.Store, certMgr *certificates.Manager, reqLog *logging.RequestLogger, db *sql.DB, log *slog.Logger) *Handler {
return &Handler{store: store, certMgr: certMgr, reqLog: reqLog, db: db, log: log}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.certMgr.ServeChallenge(w, r) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
snap := h.store.Current()
rt := snap.LookupHost(r.Host)
if rt == nil {
http.Error(w, "404 - no proxy host configured for this domain", http.StatusNotFound)
return
}
if !rt.Model.Enabled {
http.Error(w, "404 - not found", http.StatusNotFound)
return
}
clientIP := ClientIP(r, snap)
if rt.AccessList != nil {
if !h.checkAccess(w, r, rt.AccessList, clientIP) {
return
}
}
if rt.Model.ForceHTTPS && r.TLS == nil {
target := "https://" + normalizeHostHeader(r.Host) + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
if rt.Model.Type == models.ProxyHostTypeRedirect {
h.serveRedirect(w, r, rt)
return
}
h.serveProxy(w, r, rt, snap, clientIP)
}
func (h *Handler) checkAccess(w http.ResponseWriter, r *http.Request, list *access.List, clientIP string) bool {
ip := parseIPLoose(clientIP)
if ip == nil || !list.Allowed(ip) {
http.Error(w, "403 - forbidden", http.StatusForbidden)
return false
}
if list.RequiresAuth() {
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
return false
}
hash, exists := list.Users[username]
if !exists {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
return false
}
valid, err := auth.VerifyPassword(password, hash)
if err != nil || !valid {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
return false
}
}
return true
}
func (h *Handler) serveRedirect(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime) {
target := rt.Model.RedirectTargetURL
if target == "" {
http.Error(w, "502 - redirect host has no target configured", http.StatusBadGateway)
return
}
target = strings.TrimRight(target, "/")
if rt.Model.RedirectPreservePath {
target += r.URL.Path
}
if rt.Model.RedirectPreserveQS && r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
code := rt.Model.RedirectCode
switch code {
case 301, 302, 307, 308:
default:
code = http.StatusMovedPermanently
}
http.Redirect(w, r, target, code)
}
func (h *Handler) serveProxy(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime, snap *runtime.Snapshot, clientIP string) {
if rt.Pool == nil {
http.Error(w, "502 - proxy host has no backends", http.StatusBadGateway)
return
}
backend, err := rt.Pool.Pick()
if err != nil {
h.log.Warn("no healthy backend", "proxy_host", rt.Model.Name, "err", err)
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "503").Inc()
http.Error(w, "503 - no healthy backend available", http.StatusServiceUnavailable)
return
}
backend.Acquire()
defer backend.Release()
metrics.ActiveConnections.WithLabelValues("http").Inc()
defer metrics.ActiveConnections.WithLabelValues("http").Dec()
rp := buildReverseProxy(rt, backend, snap, h.log)
readTimeout := time.Duration(rt.Model.ReadTimeoutMS) * time.Millisecond
if readTimeout <= 0 {
readTimeout = 60 * time.Second
}
ctx, cancel := withConnectTimeout(r.Context(), readTimeout)
defer cancel()
r = r.WithContext(ctx)
rec := &statusRecorder{ResponseWriter: w}
start := time.Now()
rp.ServeHTTP(rec, r)
elapsed := time.Since(start)
backendAddr := backend.Addr() + ":" + strconv.Itoa(backend.Model.Port)
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, strconv.Itoa(rec.status)).Inc()
metrics.HTTPRequestDuration.WithLabelValues(rt.Model.Name).Observe(elapsed.Seconds())
metrics.BackendRequestDuration.WithLabelValues(rt.Model.Name, backendAddr).Observe(elapsed.Seconds())
metrics.BytesTransferred.WithLabelValues("out", "http").Add(float64(rec.bytes))
hostID := rt.Model.ID
h.reqLog.Log(models.RequestLog{
Timestamp: start,
ProxyHostID: &hostID,
ProxyHostName: rt.Model.Name,
ClientIP: clientIP,
Method: r.Method,
Host: r.Host,
Path: r.URL.Path,
Status: rec.status,
ResponseSize: rec.bytes,
Backend: backendAddr,
ResponseTimeMS: elapsed.Milliseconds(),
UserAgent: r.UserAgent(),
})
}
func parseIPLoose(s string) net.IP {
if h, _, err := net.SplitHostPort(s); err == nil {
s = h
}
return net.ParseIP(s)
}
+55
View File
@@ -0,0 +1,55 @@
package proxy
import (
"bufio"
"fmt"
"net"
"net/http"
)
// statusRecorder wraps http.ResponseWriter to capture status code and
// bytes written for logging/metrics, while still supporting hijacking
// (required for WebSocket upgrades) and flushing (required for streaming
// responses / SSE).
type statusRecorder struct {
http.ResponseWriter
status int
bytes int64
wroteHeader bool
}
func (w *statusRecorder) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.status = code
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
n, err := w.ResponseWriter.Write(b)
w.bytes += int64(n)
return n, err
}
func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking")
}
// A hijacked WebSocket connection never goes through WriteHeader, so
// treat it as a successful 101 for logging purposes.
w.status = http.StatusSwitchingProtocols
w.wroteHeader = true
return hj.Hijack()
}
func (w *statusRecorder) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
+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)
}
+120
View File
@@ -0,0 +1,120 @@
package proxy
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/runtime"
)
// Server owns the plain-HTTP and TLS listeners that serve all proxy hosts.
type Server struct {
httpSrv *http.Server
httpsSrv *http.Server
log *slog.Logger
}
type Options struct {
HTTPAddr string // e.g. ":80"
HTTPSAddr string // e.g. ":443"
}
func NewServer(opts Options, store *runtime.Store, certMgr *certificates.Manager, db *sql.DB, log *slog.Logger) *Server {
reqLog := logging.NewRequestLogger(db, log)
go reqLog.Run(context.Background())
handler := NewHandler(store, certMgr, reqLog, db, log)
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2", "http/1.1"},
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := certMgr.GetCertificateForDomain(hello.ServerName)
if err != nil {
metrics.TLSHandshakesTotal.WithLabelValues("error").Inc()
return nil, err
}
metrics.TLSHandshakesTotal.WithLabelValues("ok").Inc()
return cert, nil
},
}
httpAddr := opts.HTTPAddr
if httpAddr == "" {
httpAddr = ":80"
}
httpsAddr := opts.HTTPSAddr
if httpsAddr == "" {
httpsAddr = ":443"
}
return &Server{
log: log,
httpSrv: &http.Server{
Addr: httpAddr,
Handler: handler,
ReadTimeout: 0, // per-request timeouts are enforced inside the handler
WriteTimeout: 0,
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
},
httpsSrv: &http.Server{
Addr: httpsAddr,
Handler: handler,
TLSConfig: tlsConfig,
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
},
}
}
// Start runs both listeners; it blocks until either fails or ctx is done.
func (s *Server) Start(ctx context.Context) error {
errCh := make(chan error, 2)
go func() {
s.log.Info("http listener starting", "addr", s.httpSrv.Addr)
if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("http listener: %w", err)
}
}()
go func() {
s.log.Info("https listener starting", "addr", s.httpsSrv.Addr)
// Certificates are served dynamically via GetCertificate, so we
// don't pass cert/key file paths here.
if err := s.httpsSrv.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("https listener: %w", err)
}
}()
select {
case <-ctx.Done():
return s.Shutdown(context.Background())
case err := <-errCh:
s.Shutdown(context.Background())
return err
}
}
// Shutdown gracefully drains in-flight requests/connections before
// closing the listeners.
func (s *Server) Shutdown(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
err1 := s.httpSrv.Shutdown(shutdownCtx)
err2 := s.httpsSrv.Shutdown(shutdownCtx)
if err1 != nil {
return err1
}
return err2
}