Files
volt/pkg/ingress/proxy.go
Karl Clinger 81ad0b597c Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI:
- Container runtime (systemd-nspawn)
- VoltVisor VMs (Neutron Stardust / QEMU)
- Stellarium CAS (content-addressed storage)
- ORAS Registry
- GitOps integration
- Landlock LSM security
- Compose orchestration
- Mesh networking

Copyright (c) Armored Gates LLC. All rights reserved.
Licensed under AGPSL v5.0
2026-03-21 00:31:12 -05:00

350 lines
9.8 KiB
Go

/*
Volt Ingress — Native reverse proxy and API gateway.
Provides hostname/path-based routing of external traffic to containers,
with TLS termination and rate limiting.
Architecture:
- Go-native HTTP reverse proxy (net/http/httputil)
- Route configuration stored at /etc/volt/ingress/routes.json
- TLS via autocert (Let's Encrypt ACME) or user-provided certs
- Rate limiting via token bucket per route
- Runs as volt-ingress systemd service
Copyright (c) Armored Gates LLC. All rights reserved.
AGPSL v5 — Source-available. Anti-competition clauses apply.
*/
package ingress
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"time"
)
// ── Constants ────────────────────────────────────────────────────────────────
const (
IngressConfigDir = "/etc/volt/ingress"
RoutesFile = "/etc/volt/ingress/routes.json"
CertsDir = "/etc/volt/ingress/certs"
DefaultHTTPPort = 80
DefaultHTTPSPort = 443
)
// ── Route ────────────────────────────────────────────────────────────────────
// Route defines a hostname/path → backend mapping.
type Route struct {
ID string `json:"id"`
Domain string `json:"domain"` // hostname to match
Path string `json:"path"` // path prefix (default: "/")
Target string `json:"target"` // container name or IP:port
TargetPort int `json:"target_port"` // backend port
TLS bool `json:"tls"` // enable TLS termination
TLSCertFile string `json:"tls_cert_file,omitempty"` // custom cert path
TLSKeyFile string `json:"tls_key_file,omitempty"` // custom key path
AutoTLS bool `json:"auto_tls"` // use Let's Encrypt
RateLimit int `json:"rate_limit"` // requests per second (0 = unlimited)
Headers map[string]string `json:"headers,omitempty"` // custom headers to add
HealthCheck string `json:"health_check,omitempty"` // health check path
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
// ── Route Store ──────────────────────────────────────────────────────────────
// RouteStore manages ingress route configuration.
type RouteStore struct {
Routes []Route `json:"routes"`
mu sync.RWMutex
}
// LoadRoutes reads routes from disk.
func LoadRoutes() (*RouteStore, error) {
store := &RouteStore{}
data, err := os.ReadFile(RoutesFile)
if err != nil {
if os.IsNotExist(err) {
return store, nil
}
return nil, fmt.Errorf("failed to read routes: %w", err)
}
if err := json.Unmarshal(data, store); err != nil {
return nil, fmt.Errorf("failed to parse routes: %w", err)
}
return store, nil
}
// Save writes routes to disk.
func (s *RouteStore) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
os.MkdirAll(IngressConfigDir, 0755)
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(RoutesFile, data, 0644)
}
// AddRoute adds a new route.
func (s *RouteStore) AddRoute(route Route) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check for duplicate domain+path
for _, existing := range s.Routes {
if existing.Domain == route.Domain && existing.Path == route.Path {
return fmt.Errorf("route for %s%s already exists (id: %s)", route.Domain, route.Path, existing.ID)
}
}
s.Routes = append(s.Routes, route)
return nil
}
// RemoveRoute removes a route by ID or domain.
func (s *RouteStore) RemoveRoute(idOrDomain string) (*Route, error) {
s.mu.Lock()
defer s.mu.Unlock()
var remaining []Route
var removed *Route
for i := range s.Routes {
if s.Routes[i].ID == idOrDomain || s.Routes[i].Domain == idOrDomain {
r := s.Routes[i]
removed = &r
} else {
remaining = append(remaining, s.Routes[i])
}
}
if removed == nil {
return nil, fmt.Errorf("route %q not found", idOrDomain)
}
s.Routes = remaining
return removed, nil
}
// FindRoute matches a request to a route based on Host header and path.
func (s *RouteStore) FindRoute(host, path string) *Route {
s.mu.RLock()
defer s.mu.RUnlock()
// Strip port from host if present
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
var bestMatch *Route
bestPathLen := -1
for i := range s.Routes {
r := &s.Routes[i]
if !r.Enabled {
continue
}
if r.Domain != host && r.Domain != "*" {
continue
}
routePath := r.Path
if routePath == "" {
routePath = "/"
}
if strings.HasPrefix(path, routePath) && len(routePath) > bestPathLen {
bestMatch = r
bestPathLen = len(routePath)
}
}
return bestMatch
}
// ── Reverse Proxy ────────────────────────────────────────────────────────────
// IngressProxy is the HTTP reverse proxy engine.
type IngressProxy struct {
routes *RouteStore
rateLimits map[string]*rateLimiter
mu sync.RWMutex
}
// NewIngressProxy creates a new proxy with the given route store.
func NewIngressProxy(routes *RouteStore) *IngressProxy {
return &IngressProxy{
routes: routes,
rateLimits: make(map[string]*rateLimiter),
}
}
// ServeHTTP implements http.Handler — the main request routing logic.
func (p *IngressProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
route := p.routes.FindRoute(r.Host, r.URL.Path)
if route == nil {
http.Error(w, "502 Bad Gateway — no route found", http.StatusBadGateway)
return
}
// Rate limiting
if route.RateLimit > 0 {
limiter := p.getRateLimiter(route.ID, route.RateLimit)
if !limiter.allow() {
http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
return
}
}
// Resolve backend address
backendAddr := resolveBackend(route.Target, route.TargetPort)
if backendAddr == "" {
http.Error(w, "502 Bad Gateway — backend unavailable", http.StatusBadGateway)
return
}
// Build target URL
targetURL, err := url.Parse(fmt.Sprintf("http://%s", backendAddr))
if err != nil {
http.Error(w, "502 Bad Gateway — invalid backend", http.StatusBadGateway)
return
}
// Create reverse proxy
proxy := httputil.NewSingleHostReverseProxy(targetURL)
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
http.Error(rw, fmt.Sprintf("502 Bad Gateway — %v", err), http.StatusBadGateway)
}
// Add custom headers
for k, v := range route.Headers {
r.Header.Set(k, v)
}
// Set X-Forwarded headers
r.Header.Set("X-Forwarded-Host", r.Host)
r.Header.Set("X-Forwarded-Proto", "https")
if clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
r.Header.Set("X-Real-IP", clientIP)
existing := r.Header.Get("X-Forwarded-For")
if existing != "" {
r.Header.Set("X-Forwarded-For", existing+", "+clientIP)
} else {
r.Header.Set("X-Forwarded-For", clientIP)
}
}
proxy.ServeHTTP(w, r)
}
// resolveBackend resolves a container name or IP to a backend address.
func resolveBackend(target string, port int) string {
if port == 0 {
port = 80
}
// If target already contains ":", it's an IP:port
if strings.Contains(target, ":") {
return target
}
// If it looks like an IP, just add port
if net.ParseIP(target) != nil {
return fmt.Sprintf("%s:%d", target, port)
}
// Try to resolve as container name via machinectl
out, err := runCommandSilent("machinectl", "show", target, "-p", "Addresses", "--value")
if err == nil {
addr := strings.TrimSpace(out)
for _, a := range strings.Fields(addr) {
if net.ParseIP(a) != nil {
return fmt.Sprintf("%s:%d", a, port)
}
}
}
// Fallback: assume it's a hostname
return fmt.Sprintf("%s:%d", target, port)
}
func runCommandSilent(name string, args ...string) (string, error) {
out, err := execCommand(name, args...)
return strings.TrimSpace(out), err
}
func execCommand(name string, args ...string) (string, error) {
cmd := newCommand(name, args...)
out, err := cmd.Output()
return string(out), err
}
// ── Rate Limiting ────────────────────────────────────────────────────────────
type rateLimiter struct {
tokens float64
maxTokens float64
refillRate float64 // tokens per second
lastRefill time.Time
mu sync.Mutex
}
func newRateLimiter(rps int) *rateLimiter {
return &rateLimiter{
tokens: float64(rps),
maxTokens: float64(rps),
refillRate: float64(rps),
lastRefill: time.Now(),
}
}
func (rl *rateLimiter) allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
elapsed := now.Sub(rl.lastRefill).Seconds()
rl.tokens += elapsed * rl.refillRate
if rl.tokens > rl.maxTokens {
rl.tokens = rl.maxTokens
}
rl.lastRefill = now
if rl.tokens >= 1 {
rl.tokens--
return true
}
return false
}
func (p *IngressProxy) getRateLimiter(routeID string, rps int) *rateLimiter {
p.mu.Lock()
defer p.mu.Unlock()
if rl, exists := p.rateLimits[routeID]; exists {
return rl
}
rl := newRateLimiter(rps)
p.rateLimits[routeID] = rl
return rl
}
// ── Route ID Generation ─────────────────────────────────────────────────────
// GenerateRouteID creates a deterministic route ID from domain and path.
func GenerateRouteID(domain, path string) string {
id := strings.ReplaceAll(domain, ".", "-")
if path != "" && path != "/" {
id += "-" + strings.Trim(strings.ReplaceAll(path, "/", "-"), "-")
}
return id
}