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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user