feat: Add Cloudflare Tunnel support (ported from Castle)
Port Castle's tunnel config generator to Go. Central can now manage cloudflared ingress configuration for public service exposure. Services with reach=public get projected at <subdomain>.<publicDomain> with Host/SNI rewriting to the internal domain so the gateway's wildcard cert validates and existing routing works unchanged. - Locally-managed config.yml (not remotely-managed token tunnel) - Public surface is exactly the set of reach=public services - Auto-removes config when tunnel is disabled or no public services - 7 tests covering basic generation, subdomain override, edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
196
internal/tunnel/manager.go
Normal file
196
internal/tunnel/manager.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
157
internal/tunnel/manager_test.go
Normal file
157
internal/tunnel/manager_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user