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,212 @@
|
||||
// Package runtime holds the active, in-memory configuration snapshot that
|
||||
// the hot proxy/stream paths read. SQLite is only touched when the
|
||||
// configuration changes; every request/connection reads this snapshot.
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/access"
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
"github.com/owen/vibeproxy/internal/health"
|
||||
"github.com/owen/vibeproxy/internal/loadbalancer"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
// ProxyHostRuntime is one HTTP(S) proxy or redirect host, resolved and
|
||||
// ready to serve traffic.
|
||||
type ProxyHostRuntime struct {
|
||||
Model models.ProxyHost
|
||||
Pool *loadbalancer.Pool
|
||||
AccessList *access.List // nil if none attached
|
||||
}
|
||||
|
||||
// StreamRuntime is one TCP/UDP stream, ready to serve traffic.
|
||||
type StreamRuntime struct {
|
||||
Model models.Stream
|
||||
}
|
||||
|
||||
// Snapshot is an immutable view of the whole active configuration.
|
||||
type Snapshot struct {
|
||||
HostsByDomain map[string]*ProxyHostRuntime // lowercase domain -> host
|
||||
AccessLists map[int64]*access.List
|
||||
Settings models.Settings
|
||||
TrustedProxies []*net.IPNet
|
||||
}
|
||||
|
||||
func (s *Snapshot) LookupHost(hostHeader string) *ProxyHostRuntime {
|
||||
name := hostHeader
|
||||
if h, _, err := net.SplitHostPort(hostHeader); err == nil {
|
||||
name = h
|
||||
}
|
||||
name = strings.ToLower(strings.TrimSuffix(name, "."))
|
||||
return s.HostsByDomain[name]
|
||||
}
|
||||
|
||||
func (s *Snapshot) IsTrustedProxy(ip net.IP) bool {
|
||||
for _, n := range s.TrustedProxies {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Store owns the current Snapshot (read via atomic pointer for lock-free,
|
||||
// allocation-free reads on the hot path) and knows how to rebuild it from
|
||||
// the database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
current atomic.Pointer[Snapshot]
|
||||
|
||||
checker *health.Checker
|
||||
watchCtx context.Context
|
||||
watchStop context.CancelFunc
|
||||
watchMu sync.Mutex
|
||||
|
||||
onHealthChange func(hostID int64, backendAddr string, healthy bool)
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
s := &Store{db: db}
|
||||
s.checker = health.NewChecker(s.handleHealthChange)
|
||||
s.current.Store(&Snapshot{HostsByDomain: map[string]*ProxyHostRuntime{}, AccessLists: map[int64]*access.List{}})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Store) Current() *Snapshot { return s.current.Load() }
|
||||
|
||||
func (s *Store) SetHealthChangeCallback(f func(hostID int64, backendAddr string, healthy bool)) {
|
||||
s.onHealthChange = f
|
||||
}
|
||||
|
||||
func (s *Store) handleHealthChange(hostID int64, b *loadbalancer.Backend, healthy bool) {
|
||||
if s.onHealthChange != nil {
|
||||
s.onHealthChange(hostID, net.JoinHostPort(b.Model.Host, strconv.Itoa(b.Model.Port)), healthy)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload rebuilds the snapshot from the database and atomically swaps it
|
||||
// in. Existing in-flight requests keep using the previous snapshot object
|
||||
// until they finish; new requests immediately see the new one.
|
||||
func (s *Store) Reload(ctx context.Context) error {
|
||||
hosts, err := database.ListProxyHosts(s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessLists, err := database.ListAccessLists(s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsMap, err := database.AllSettings(s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings := settingsFromMap(settingsMap)
|
||||
|
||||
compiledACLs := map[int64]*access.List{}
|
||||
for _, a := range accessLists {
|
||||
compiledACLs[a.ID] = access.Compile(a)
|
||||
}
|
||||
|
||||
var trusted []*net.IPNet
|
||||
for _, c := range settings.TrustedProxyCIDRs {
|
||||
if _, n, err := net.ParseCIDR(c); err == nil {
|
||||
trusted = append(trusted, n)
|
||||
}
|
||||
}
|
||||
|
||||
snap := &Snapshot{
|
||||
HostsByDomain: map[string]*ProxyHostRuntime{},
|
||||
AccessLists: compiledACLs,
|
||||
Settings: settings,
|
||||
TrustedProxies: trusted,
|
||||
}
|
||||
|
||||
// Stop the previous health-check watchers before starting new ones so
|
||||
// we don't leak goroutines across reloads.
|
||||
s.watchMu.Lock()
|
||||
if s.watchStop != nil {
|
||||
s.watchStop()
|
||||
}
|
||||
watchCtx, cancel := context.WithCancel(context.Background())
|
||||
s.watchCtx, s.watchStop = watchCtx, cancel
|
||||
s.watchMu.Unlock()
|
||||
|
||||
for _, h := range hosts {
|
||||
if !h.Enabled {
|
||||
continue
|
||||
}
|
||||
var pool *loadbalancer.Pool
|
||||
if h.Type == models.ProxyHostTypeProxy {
|
||||
backends := make([]*loadbalancer.Backend, 0, len(h.Backends))
|
||||
for _, bm := range h.Backends {
|
||||
if !bm.Enabled {
|
||||
continue
|
||||
}
|
||||
b := loadbalancer.NewBackend(bm)
|
||||
backends = append(backends, b)
|
||||
s.checker.Watch(watchCtx, h.ID, b)
|
||||
}
|
||||
pool = loadbalancer.NewPool(h.LoadBalancingMethod, backends)
|
||||
}
|
||||
var acl *access.List
|
||||
if h.AccessListID != nil {
|
||||
acl = compiledACLs[*h.AccessListID]
|
||||
}
|
||||
rt := &ProxyHostRuntime{Model: h, Pool: pool, AccessList: acl}
|
||||
for _, d := range h.Domains {
|
||||
snap.HostsByDomain[strings.ToLower(d)] = rt
|
||||
}
|
||||
}
|
||||
|
||||
s.current.Store(snap)
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsFromMap(m map[string]string) models.Settings {
|
||||
s := models.Settings{
|
||||
AdminUsername: get(m, "admin_username", "admin"),
|
||||
ACMEEmail: get(m, "acme_email", ""),
|
||||
ACMEStaging: get(m, "acme_staging", "false") == "true",
|
||||
ACMERenewalWindowDays: atoi(get(m, "acme_renewal_window_days", "30"), 30),
|
||||
CertStoragePath: get(m, "cert_storage_path", "./data/certs"),
|
||||
LogRetentionDays: atoi(get(m, "log_retention_days", "30"), 30),
|
||||
BackupEnabled: get(m, "backup_enabled", "true") == "true",
|
||||
BackupIntervalHours: atoi(get(m, "backup_interval_hours", "24"), 24),
|
||||
BackupRetentionCount: atoi(get(m, "backup_retention_count", "7"), 7),
|
||||
BackupPath: get(m, "backup_path", "./data/backups"),
|
||||
}
|
||||
if tp := get(m, "trusted_proxy_cidrs", ""); tp != "" {
|
||||
s.TrustedProxyCIDRs = strings.Split(tp, ",")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func get(m map[string]string, k, def string) string {
|
||||
if v, ok := m[k]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func atoi(s string, def int) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TLSCertLookup is implemented by the certificate manager and used for
|
||||
// SNI-based certificate selection in the HTTPS listener.
|
||||
type TLSCertLookup interface {
|
||||
GetCertificateForDomain(domain string) (*tls.Certificate, error)
|
||||
}
|
||||
Reference in New Issue
Block a user