// Package tunnel manages Cloudflare Tunnel (cloudflared) configuration. // // Services with reach=public are projected onto the internet at // .. 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 }