Files
wild-central/internal/tunnel/manager.go
Paul Payne 2bb162a794 Add per-subsystem config validation for wireguard, authelia, nftables, tunnel
- wireguard: ValidateConfig checks ListenPort range, Address/LanCIDR CIDR
  format. SaveConfig now validates before writing.
- authelia: ValidateConfigOpts checks required fields (Domain, JWTSecret,
  SessionSecret) and StorageEncKey minimum length (20 chars). GenerateConfig
  now validates before generating.
- nftables: ValidateWANInterface checks interface exists via net.InterfaceByName
  before generating rules that reference it.
- tunnel: ValidateConfig checks TunnelID, PublicDomain, GatewayDomain are set
  and credentials file exists. WriteConfig now validates before generating.
2026-07-14 11:54:58 +00:00

218 lines
6.4 KiB
Go

// Package tunnel manages Cloudflare Tunnel (cloudflared) configuration.
//
// Services with reach=public are projected onto the internet at
// <subdomain>.<publicDomain>. The tunnel bridges each public hostname
// to Central's gateway via Host/SNI rewriting, so the gateway's wildcard
// cert validates and existing routing works unchanged.
//
// The config is locally-managed (a config.yml with an explicit ingress
// list), not a remotely-managed token tunnel — the public surface is
// exactly the set of registered public services.
package tunnel
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/storage"
)
// Config holds tunnel configuration.
type Config struct {
Enabled bool `yaml:"enabled" json:"enabled"`
TunnelID string `yaml:"tunnelId" json:"tunnelId"`
PublicDomain string `yaml:"publicDomain" json:"publicDomain"` // e.g. "pub.payne.io"
GatewayDomain string `yaml:"gatewayDomain" json:"gatewayDomain"` // e.g. "payne.io" (internal)
CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"`
}
// PublicDomain describes a domain that should be exposed through the tunnel.
type PublicDomain struct {
Name string // domain name (used as subdomain)
Subdomain string // explicit subdomain override (defaults to Name)
}
// Manager handles cloudflared tunnel configuration.
type Manager struct {
dataDir string
}
// NewManager creates a new tunnel manager.
func NewManager(dataDir string) *Manager {
return &Manager{dataDir: dataDir}
}
// configPath returns the path to the generated cloudflared config.
func (m *Manager) configPath() string {
return filepath.Join(m.dataDir, "tunnel", "config.yml")
}
// credentialsPath returns the path to the tunnel credentials JSON.
func credentialsPath(credentialsDir, tunnelID string) string {
if credentialsDir == "" {
credentialsDir = filepath.Join(os.Getenv("HOME"), ".cloudflared")
}
return filepath.Join(credentialsDir, tunnelID+".json")
}
// ingressEntry is a single cloudflared ingress rule.
type ingressEntry struct {
Hostname string `yaml:"hostname,omitempty"`
Service string `yaml:"service"`
OriginRequest *originRequestConfig `yaml:"originRequest,omitempty"`
}
type originRequestConfig struct {
OriginServerName string `yaml:"originServerName,omitempty"`
HTTPHostHeader string `yaml:"httpHostHeader,omitempty"`
}
type cloudflaredConfig struct {
Tunnel string `yaml:"tunnel"`
CredentialsFile string `yaml:"credentials-file"`
Ingress []ingressEntry `yaml:"ingress"`
}
// GenerateConfig renders the cloudflared config.yml for the given public services.
// Returns the YAML string, or empty string if tunnel is not configured or no services are public.
func (m *Manager) GenerateConfig(cfg Config, services []PublicDomain) string {
if !cfg.Enabled || cfg.TunnelID == "" || cfg.PublicDomain == "" || cfg.GatewayDomain == "" {
return ""
}
if len(services) == 0 {
return ""
}
// The gateway origin — HAProxy terminates TLS on 443, so we connect there.
gatewayOrigin := "https://localhost:443"
var ingress []ingressEntry
for _, svc := range services {
subdomain := svc.Subdomain
if subdomain == "" {
subdomain = svc.Name
}
publicHost := subdomain + "." + cfg.PublicDomain
internalHost := subdomain + "." + cfg.GatewayDomain
ingress = append(ingress, ingressEntry{
Hostname: publicHost,
Service: gatewayOrigin,
OriginRequest: &originRequestConfig{
OriginServerName: internalHost,
HTTPHostHeader: internalHost,
},
})
}
// Cloudflared requires a terminal catch-all
ingress = append(ingress, ingressEntry{
Service: "http_status:404",
})
config := cloudflaredConfig{
Tunnel: cfg.TunnelID,
CredentialsFile: credentialsPath(cfg.CredentialsDir, cfg.TunnelID),
Ingress: ingress,
}
data, err := yaml.Marshal(config)
if err != nil {
slog.Error("failed to marshal tunnel config", "error", err)
return ""
}
return string(data)
}
// ValidateConfig checks tunnel config for common errors before generating.
func ValidateConfig(cfg Config) error {
if !cfg.Enabled {
return nil // disabled is valid
}
if cfg.TunnelID == "" {
return fmt.Errorf("tunnel ID is required")
}
if cfg.PublicDomain == "" {
return fmt.Errorf("public domain is required")
}
if cfg.GatewayDomain == "" {
return fmt.Errorf("gateway domain is required")
}
credsFile := credentialsPath(cfg.CredentialsDir, cfg.TunnelID)
if _, err := os.Stat(credsFile); err != nil {
return fmt.Errorf("credentials file not found: %s", credsFile)
}
return nil
}
// WriteConfig validates, generates, and writes the cloudflared config to disk.
func (m *Manager) WriteConfig(cfg Config, services []PublicDomain) error {
if err := ValidateConfig(cfg); err != nil {
return fmt.Errorf("config validation: %w", err)
}
content := m.GenerateConfig(cfg, services)
if content == "" {
// Remove config if tunnel is disabled or no public services
path := m.configPath()
if _, err := os.Stat(path); err == nil {
os.Remove(path)
slog.Info("tunnel config removed (no public services)")
}
return nil
}
if err := storage.WriteFileAtomic(m.configPath(), []byte(content), 0644); err != nil {
return fmt.Errorf("writing tunnel config: %w", err)
}
slog.Info("tunnel config written", "path", m.configPath(), "services", len(services))
return nil
}
// GetStatus returns the current tunnel status.
func (m *Manager) GetStatus(cfg Config) map[string]any {
status := map[string]any{
"enabled": cfg.Enabled,
"tunnelId": cfg.TunnelID,
"publicDomain": cfg.PublicDomain,
"configPath": m.configPath(),
}
// Check if cloudflared is running
cmd := exec.Command("systemctl", "is-active", "cloudflared.service")
output, err := cmd.Output()
if err != nil {
status["service"] = "inactive"
} else {
status["service"] = strings.TrimSpace(string(output))
}
// Check if config exists
if _, err := os.Stat(m.configPath()); err == nil {
status["configExists"] = true
} else {
status["configExists"] = false
}
return status
}
// ReloadService restarts the cloudflared service to pick up new config.
func (m *Manager) ReloadService() error {
cmd := exec.Command("systemctl", "restart", "cloudflared.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restart cloudflared: %w (output: %s)", err, string(output))
}
slog.Info("cloudflared service restarted")
return nil
}