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:
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