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
350 lines
9.8 KiB
Go
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
|
|
}
|