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
This commit is contained in:
15
pkg/ingress/cmd_helper.go
Normal file
15
pkg/ingress/cmd_helper.go
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
Volt Ingress — OS command helpers (avoid import cycle with cmd package).
|
||||
|
||||
Copyright (c) Armored Gates LLC. All rights reserved.
|
||||
*/
|
||||
package ingress
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// newCommand creates an exec.Cmd — thin wrapper to avoid import cycles.
|
||||
func newCommand(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command(name, args...)
|
||||
}
|
||||
349
pkg/ingress/proxy.go
Normal file
349
pkg/ingress/proxy.go
Normal file
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user