/* 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 }