diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go new file mode 100644 index 0000000..f605390 --- /dev/null +++ b/internal/tunnel/manager.go @@ -0,0 +1,196 @@ +// 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" +) + +// 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"` +} + +// PublicService describes a service that should be exposed through the tunnel. +type PublicService struct { + Name string // service 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 []PublicService) 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 []PublicService) 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 + } + + dir := filepath.Dir(m.configPath()) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating tunnel config dir: %w", err) + } + + if err := os.WriteFile(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 +} diff --git a/internal/tunnel/manager_test.go b/internal/tunnel/manager_test.go new file mode 100644 index 0000000..d8054b6 --- /dev/null +++ b/internal/tunnel/manager_test.go @@ -0,0 +1,157 @@ +package tunnel + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func testConfig() Config { + return Config{ + Enabled: true, + TunnelID: "abc-123", + PublicDomain: "pub.payne.io", + GatewayDomain: "payne.io", + CredentialsDir: "/tmp/creds", + } +} + +func TestGenerateConfig_Basic(t *testing.T) { + m := NewManager(t.TempDir()) + + services := []PublicService{ + {Name: "my-api"}, + {Name: "dashboard"}, + } + + out := m.GenerateConfig(testConfig(), services) + + if out == "" { + t.Fatal("expected non-empty config") + } + + // Check tunnel ID + if !strings.Contains(out, "tunnel: abc-123") { + t.Errorf("expected tunnel ID, got:\n%s", out) + } + + // Check credentials file + if !strings.Contains(out, "credentials-file: /tmp/creds/abc-123.json") { + t.Errorf("expected credentials file, got:\n%s", out) + } + + // Check public hostnames + if !strings.Contains(out, "hostname: my-api.pub.payne.io") { + t.Errorf("expected my-api public hostname, got:\n%s", out) + } + if !strings.Contains(out, "hostname: dashboard.pub.payne.io") { + t.Errorf("expected dashboard public hostname, got:\n%s", out) + } + + // Check Host/SNI rewriting to internal domain + if !strings.Contains(out, "originServerName: my-api.payne.io") { + t.Errorf("expected internal SNI for my-api, got:\n%s", out) + } + if !strings.Contains(out, "httpHostHeader: dashboard.payne.io") { + t.Errorf("expected internal Host header for dashboard, got:\n%s", out) + } + + // Check catch-all + if !strings.Contains(out, "service: http_status:404") { + t.Errorf("expected catch-all 404, got:\n%s", out) + } + + // Check gateway origin + if !strings.Contains(out, "service: https://localhost:443") { + t.Errorf("expected gateway origin, got:\n%s", out) + } +} + +func TestGenerateConfig_SubdomainOverride(t *testing.T) { + m := NewManager(t.TempDir()) + + services := []PublicService{ + {Name: "internal-name", Subdomain: "public-name"}, + } + + out := m.GenerateConfig(testConfig(), services) + + if !strings.Contains(out, "hostname: public-name.pub.payne.io") { + t.Errorf("expected subdomain override, got:\n%s", out) + } +} + +func TestGenerateConfig_Disabled(t *testing.T) { + m := NewManager(t.TempDir()) + + cfg := testConfig() + cfg.Enabled = false + + out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}}) + if out != "" { + t.Errorf("expected empty config when disabled, got:\n%s", out) + } +} + +func TestGenerateConfig_NoServices(t *testing.T) { + m := NewManager(t.TempDir()) + out := m.GenerateConfig(testConfig(), nil) + if out != "" { + t.Errorf("expected empty config with no services, got:\n%s", out) + } +} + +func TestGenerateConfig_MissingTunnelID(t *testing.T) { + m := NewManager(t.TempDir()) + cfg := testConfig() + cfg.TunnelID = "" + + out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}}) + if out != "" { + t.Errorf("expected empty config with missing tunnel ID, got:\n%s", out) + } +} + +func TestWriteConfig(t *testing.T) { + tmpDir := t.TempDir() + m := NewManager(tmpDir) + + services := []PublicService{{Name: "my-api"}} + if err := m.WriteConfig(testConfig(), services); err != nil { + t.Fatalf("WriteConfig failed: %v", err) + } + + // Verify file was written + configPath := filepath.Join(tmpDir, "tunnel", "config.yml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + + if !strings.Contains(string(data), "my-api.pub.payne.io") { + t.Errorf("expected my-api hostname in written config, got:\n%s", string(data)) + } +} + +func TestWriteConfig_RemovesWhenDisabled(t *testing.T) { + tmpDir := t.TempDir() + m := NewManager(tmpDir) + + // Write config first + if err := m.WriteConfig(testConfig(), []PublicService{{Name: "my-api"}}); err != nil { + t.Fatalf("WriteConfig failed: %v", err) + } + + // Now disable and write again — should remove the file + cfg := testConfig() + cfg.Enabled = false + if err := m.WriteConfig(cfg, []PublicService{{Name: "my-api"}}); err != nil { + t.Fatalf("WriteConfig (disable) failed: %v", err) + } + + configPath := filepath.Join(tmpDir, "tunnel", "config.yml") + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("expected config file to be removed when disabled") + } +}