- Add storage.WriteFileAtomic (temp + rename) and storage.CopyFile helpers - Convert all 8 production config writers to atomic writes: config/state.yaml, dnsmasq, nftables, domains, wireguard (config + secrets + peers + wg0.conf), tunnel/cloudflared - Add sync.Mutex to Reconciler to serialize concurrent Reconcile() calls triggered by domain registration goroutines - Add state.yaml backup (.bak) before every write; LoadState falls back to backup if primary is corrupted - Reconciler refuses to use empty config on corruption (only on first run when no state file exists yet)
194 lines
5.7 KiB
Go
194 lines
5.7 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)
|
|
}
|
|
|
|
// WriteConfig generates and writes the cloudflared config to disk.
|
|
func (m *Manager) WriteConfig(cfg Config, services []PublicDomain) error {
|
|
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
|
|
}
|