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
213 lines
5.9 KiB
Go
213 lines
5.9 KiB
Go
// 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)
|
|
}
|