diff --git a/cmd/obol/tunnel_domain.go b/cmd/obol/tunnel_domain.go index 4a0defc1..79df5b13 100644 --- a/cmd/obol/tunnel_domain.go +++ b/cmd/obol/tunnel_domain.go @@ -79,15 +79,21 @@ func tunnelCommand(cfg *config.Config) *cli.Command { Name: "overwrite-dns", Usage: "Replace any existing A/AAAA/CNAME at the hostname (forwards --overwrite-dns to cloudflared)", }, + &cli.BoolFlag{ + Name: "reuse-cert", + Usage: "Reuse an existing ~/.cloudflared/cert.pem instead of the browser login (headless; cert must already be authenticated to this Cloudflare account)", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { return tunnel.Login(cfg, getUI(cmd), tunnel.LoginOptions{ Hostname: cmd.String("hostname"), TransportProtocol: cmd.String("transport-protocol"), OverwriteDNS: cmd.Bool("overwrite-dns"), + ReuseCert: cmd.Bool("reuse-cert"), }) }, }, + tunnelHostnameCommand(cfg), { Name: "restart", Usage: "Restart the tunnel connector (quick tunnels get a new URL)", @@ -98,11 +104,38 @@ func tunnelCommand(cfg *config.Config) *cli.Command { }, { Name: "stop", - Usage: "Stop the tunnel (scale cloudflared to 0 replicas)", + Usage: "Pause the tunnel (scale cloudflared to 0; config preserved — use 'obol tunnel delete' to tear down)", Action: func(ctx context.Context, cmd *cli.Command) error { return tunnel.Stop(cfg, getUI(cmd)) }, }, + { + Name: "delete", + Aliases: []string{"teardown"}, + Usage: "Tear down the persistent tunnel (deletes the Cloudflare tunnel + cluster state; reverts to a quick tunnel)", + Description: "Permanently removes the persistent tunnel created by 'obol tunnel setup':\n" + + "deletes the Cloudflare tunnel (local-managed, cert-scoped), removes the\n" + + "in-cluster connector resources and storefront, clears local state, and reverts\n" + + "the connector to a default quick tunnel. Unlike 'obol tunnel stop' (which only\n" + + "pauses the connector), this cannot be undone — re-run 'obol tunnel setup' to\n" + + "create a new one.\n\n" + + "DNS records and dashboard-managed tunnels live in Cloudflare (Obol holds no\n" + + "account-wide API token), so those steps are printed for you to finish.", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "Skip the confirmation prompt"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + result, err := tunnel.Delete(cfg, u, tunnel.DeleteOptions{Force: cmd.Bool("force")}) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + return nil + }, + }, { Name: "logs", Usage: "View cloudflared logs", @@ -117,6 +150,117 @@ func tunnelCommand(cfg *config.Config) *cli.Command { } } +// tunnelHostnameCommand manages multiple public hostnames on one tunnel: +// "deploy one domain, then another". Each hostname serves the same x402-gated +// service surface (offer HTTPRoutes are host-agnostic), so adding a second +// hostname never takes down the first. +func tunnelHostnameCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "hostname", + Aliases: []string{"hostnames", "domains"}, + Usage: "Add, list, and remove additional public hostnames on the tunnel", + Description: "A permanent tunnel can serve more than one public hostname at once. Set up the\n" + + "first with 'obol tunnel setup', then add a second (and Nth) here. Every hostname\n" + + "serves the same payment-gated services — adding one never disrupts the others.\n\n" + + "Local-managed tunnels (browser cert) are fully automated: Obol routes the DNS\n" + + "record, re-renders the connector ingress over all hostnames, and reloads it.\n" + + "Dashboard-managed (connector token) tunnels keep their ingress in Cloudflare, so\n" + + "Obol tracks the hostname, updates the storefront route, and prints the one\n" + + "dashboard step to finish.", + Commands: []*cli.Command{ + { + Name: "list", + Usage: "List every public hostname served by the tunnel", + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + result, err := tunnel.ListHostnames(cfg) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + printHostnameList(u, result) + return nil + }, + }, + { + Name: "add", + Usage: "Add another public hostname to the tunnel", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "hostname", Aliases: []string{"H"}, Usage: "Public hostname to add (or pass as a positional argument)"}, + &cli.BoolFlag{Name: "overwrite-dns", Usage: "Local-managed only: replace any existing A/AAAA/CNAME at the hostname"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + hostname := strings.TrimSpace(cmd.String("hostname")) + if hostname == "" { + hostname = strings.TrimSpace(cmd.Args().First()) + } + result, err := tunnel.AddHostname(cfg, u, tunnel.AddHostnameOptions{ + Hostname: hostname, + OverwriteDNS: cmd.Bool("overwrite-dns"), + }) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + return nil + }, + }, + { + Name: "remove", + Aliases: []string{"rm", "delete"}, + Usage: "Remove a public hostname from the tunnel (others keep serving)", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "hostname", Aliases: []string{"H"}, Usage: "Public hostname to remove (or pass as a positional argument)"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + hostname := strings.TrimSpace(cmd.String("hostname")) + if hostname == "" { + hostname = strings.TrimSpace(cmd.Args().First()) + } + result, err := tunnel.RemoveHostname(cfg, u, tunnel.RemoveHostnameOptions{ + Hostname: hostname, + }) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + return nil + }, + }, + }, + } +} + +func printHostnameList(u *ui.UI, result *tunnel.HostnameListResult) { + u.Blank() + u.Bold("Tunnel Hostnames") + if len(result.Hostnames) == 0 { + u.Print("No permanent hostnames configured.") + u.Dim("Create one with: obol tunnel setup") + return + } + for _, h := range result.Hostnames { + label := h.URL + if h.Primary { + label += " (primary)" + } + u.Print("- " + label) + } + u.Blank() + u.Dim("Add another: obol tunnel hostname add .") + u.Dim("Remove one: obol tunnel hostname remove ") +} + func domainCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "domain", diff --git a/internal/tunnel/hostnames.go b/internal/tunnel/hostnames.go new file mode 100644 index 00000000..b512ac03 --- /dev/null +++ b/internal/tunnel/hostnames.go @@ -0,0 +1,337 @@ +package tunnel + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +// AddHostnameOptions configures `obol tunnel hostname add`. +type AddHostnameOptions struct { + Hostname string + + // OverwriteDNS forwards --overwrite-dns to `cloudflared tunnel route dns` + // (local-managed only) so an existing A/AAAA/CNAME at the hostname is + // replaced instead of failing with Cloudflare API error 1003. + OverwriteDNS bool +} + +// RemoveHostnameOptions configures `obol tunnel hostname remove`. +type RemoveHostnameOptions struct { + Hostname string +} + +// HostnameInfo is one entry in the hostname listing. +type HostnameInfo struct { + Hostname string `json:"hostname"` + Primary bool `json:"primary"` + URL string `json:"url"` +} + +// HostnameListResult is the JSON-serialisable result of ListHostnames. +type HostnameListResult struct { + ManagementMode string `json:"management_mode"` + Hostnames []HostnameInfo `json:"hostnames"` +} + +// HostnameMutationResult is the JSON-serialisable result of Add/RemoveHostname. +type HostnameMutationResult struct { + Hostname string `json:"hostname"` + Action string `json:"action"` // "added" | "removed" + ManagementMode string `json:"management_mode"` + Hostnames []HostnameInfo `json:"hostnames"` +} + +// ListHostnames returns every public hostname tracked for the tunnel, primary +// first. Returns an empty list for quick/dormant tunnels. +func ListHostnames(cfg *config.Config) (*HostnameListResult, error) { + st, err := loadTunnelState(cfg) + if err != nil { + return nil, fmt.Errorf("load tunnel state: %w", err) + } + if st == nil || !st.IsPersistent() { + return &HostnameListResult{ManagementMode: tunnelManagementQuick}, nil + } + return &HostnameListResult{ + ManagementMode: st.Management(), + Hostnames: hostnameInfos(st.HostnameSet()), + }, nil +} + +// AddHostname adds a second (or Nth) public hostname to an existing persistent +// tunnel WITHOUT disturbing the hostnames already live. It regenerates the +// cloudflared ingress over the full set (local-managed) and updates the +// storefront HTTPRoute to list every hostname. +// +// - Local-managed (browser cert): routes a DNS CNAME for the new hostname via +// cloudflared (cert-scoped, no API token), re-renders the connector ingress +// over all hostnames, and reloads the connector. +// - Dashboard-managed (connector token): obol holds no account-wide API token +// by design, so it cannot edit the dashboard tunnel's ingress. It tracks the +// hostname, updates the in-cluster storefront route, and prints the exact +// dashboard step the operator must add. +func AddHostname(cfg *config.Config, u *ui.UI, opts AddHostnameOptions) (*HostnameMutationResult, error) { + hostname := normalizeHostname(opts.Hostname) + if hostname == "" { + return nil, errors.New("hostname is required (e.g. data.example.com)") + } + + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return nil, errors.New("stack not running, use 'obol stack up' first") + } + + st, err := loadTunnelState(cfg) + if err != nil { + return nil, fmt.Errorf("load tunnel state: %w", err) + } + if st == nil || !st.IsPersistent() { + return nil, errors.New("no permanent tunnel configured; run 'obol tunnel setup' first, then add more hostnames") + } + + existing := st.HostnameSet() + for _, h := range existing { + if h == hostname { + return nil, fmt.Errorf("%s is already a tunnel hostname; nothing to do", hostname) + } + } + + // Full set = current set + new hostname (appended, primary unchanged). + updated := normalizeHostnames(append(append([]string{}, existing...), hostname)) + + switch st.Management() { + case tunnelManagementLocal: + if err := addHostnameLocal(cfg, u, kubeconfigPath, st, hostname, updated, opts.OverwriteDNS); err != nil { + return nil, err + } + case tunnelManagementRemote: + printRemoteAddHostnameSteps(u, hostname) + default: + return nil, fmt.Errorf("unsupported tunnel management mode %q", st.Management()) + } + + // Storefront `/` route must list every hostname or the new domain's `/` + // falls through to the local frontend catch-all. + if err := CreateStorefront(cfg, updated...); err != nil { + u.Warnf("could not update storefront for new hostname: %v", err) + } + + st.Hostnames = updated + st.Hostname = updated[0] + if err := saveTunnelState(cfg, st); err != nil { + return nil, fmt.Errorf("hostname added in-cluster, but failed to persist state: %w", err) + } + + u.Blank() + u.Successf("Hostname added: https://%s", hostname) + u.Dim(" Existing hostnames keep serving — this did not disrupt them.") + u.Dim(" Verify: obol tunnel hostname list") + + return &HostnameMutationResult{ + Hostname: hostname, + Action: "added", + ManagementMode: st.Management(), + Hostnames: hostnameInfos(updated), + }, nil +} + +func addHostnameLocal(cfg *config.Config, u *ui.UI, kubeconfigPath string, st *tunnelState, hostname string, updated []string, overwriteDNS bool) error { + if st.TunnelID == "" { + return errors.New("local tunnel is missing its tunnel id; re-run 'obol tunnel setup --management local --hostname '") + } + + cloudflaredPath, err := exec.LookPath("cloudflared") + if err != nil { + return errors.New("cloudflared not found in PATH. Install it first (e.g. 'brew install cloudflared' on macOS)") + } + + tunnelName := st.TunnelName + if strings.TrimSpace(tunnelName) == "" { + tunnelName = st.TunnelID // cloudflared accepts the UUID as the tunnel ref + } + + u.Infof("Creating DNS route for %s...", hostname) + routeArgs := routeDNSArgs(tunnelName, hostname, overwriteDNS) + routeOut, err := exec.Command(cloudflaredPath, routeArgs...).CombinedOutput() + if err != nil { + hint := "" + if !overwriteDNS && strings.Contains(string(routeOut), "record with that host already exists") { + hint = "\nhint: a record for this hostname already exists. Re-run with --overwrite-dns to replace it." + } + return fmt.Errorf("cloudflared tunnel route dns failed: %w\n%s%s", err, strings.TrimSpace(string(routeOut)), hint) + } + if err := verifyRoutedHostname(string(routeOut), hostname); err != nil { + return err + } + + // Re-render the local config ConfigMap with one ingress rule per hostname, + // then reload the connector (helm upgrade alone does not roll the pods on a + // ConfigMap-only change, so the connector would keep its old ingress). + cfgYAML := buildLocalManagedConfigYAML(updated, st.TunnelID) + if err := kubectlApply(cfg, u, kubeconfigPath, cfgYAML); err != nil { + return err + } + if err := helmUpgradeCloudflared(cfg, u, kubeconfigPath); err != nil { + return err + } + return restartCloudflaredConnector(cfg, u, kubeconfigPath) +} + +// RemoveHostname removes a public hostname from a persistent tunnel while every +// other hostname keeps serving. It refuses to remove the last hostname. It +// re-renders the ingress over the remaining set (local-managed) and updates the +// storefront route. DNS cleanup is intentionally NOT automated: under the +// least-privilege model obol holds no account-wide API token, so the operator +// removes the now-dangling CNAME in the Cloudflare dashboard. +func RemoveHostname(cfg *config.Config, u *ui.UI, opts RemoveHostnameOptions) (*HostnameMutationResult, error) { + hostname := normalizeHostname(opts.Hostname) + if hostname == "" { + return nil, errors.New("hostname is required") + } + + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return nil, errors.New("stack not running, use 'obol stack up' first") + } + + st, err := loadTunnelState(cfg) + if err != nil { + return nil, fmt.Errorf("load tunnel state: %w", err) + } + if st == nil || !st.IsPersistent() { + return nil, errors.New("no permanent tunnel configured; nothing to remove") + } + + existing := st.HostnameSet() + found := false + remaining := make([]string, 0, len(existing)) + for _, h := range existing { + if h == hostname { + found = true + continue + } + remaining = append(remaining, h) + } + if !found { + return nil, fmt.Errorf("%s is not a tracked tunnel hostname (see 'obol tunnel hostname list')", hostname) + } + if len(remaining) == 0 { + return nil, errors.New("refusing to remove the last hostname; a persistent tunnel needs at least one. " + + "Tear the tunnel down with 'obol tunnel stop', or reconfigure with 'obol tunnel setup'") + } + + switch st.Management() { + case tunnelManagementLocal: + // Re-render ingress over the remaining hostnames and reload the connector. + cfgYAML := buildLocalManagedConfigYAML(remaining, st.TunnelID) + if err := kubectlApply(cfg, u, kubeconfigPath, cfgYAML); err != nil { + return nil, err + } + if err := helmUpgradeCloudflared(cfg, u, kubeconfigPath); err != nil { + return nil, err + } + if err := restartCloudflaredConnector(cfg, u, kubeconfigPath); err != nil { + return nil, err + } + printLocalRemoveDNSHint(u, hostname) + case tunnelManagementRemote: + printRemoteRemoveHostnameSteps(u, hostname) + default: + return nil, fmt.Errorf("unsupported tunnel management mode %q", st.Management()) + } + + // Storefront route now lists only the remaining hostnames. + if err := CreateStorefront(cfg, remaining...); err != nil { + u.Warnf("could not update storefront after removal: %v", err) + } + + st.Hostnames = remaining + st.Hostname = remaining[0] + if err := saveTunnelState(cfg, st); err != nil { + return nil, fmt.Errorf("hostname removed in-cluster, but failed to persist state: %w", err) + } + + u.Blank() + u.Successf("Hostname removed: %s", hostname) + u.Dim(fmt.Sprintf(" Still serving: %s", strings.Join(remaining, ", "))) + + return &HostnameMutationResult{ + Hostname: hostname, + Action: "removed", + ManagementMode: st.Management(), + Hostnames: hostnameInfos(remaining), + }, nil +} + +// restartCloudflaredConnector rolls the cloudflared Deployment so the connector +// reloads its ingress ConfigMap. Adding or removing a hostname changes only the +// ConfigMap, not the Deployment pod spec, so `helm upgrade` does not restart the +// pods and the running connector keeps its old ingress — a newly added hostname +// 404s at the connector (its rule isn't loaded) and a removed hostname keeps +// serving. Rolling the Deployment forces the new config to load. +func restartCloudflaredConnector(cfg *config.Config, u *ui.UI, kubeconfigPath string) error { + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + u.Dim("Reloading tunnel connector...") + if out, err := exec.Command(kubectlPath, "--kubeconfig", kubeconfigPath, + "rollout", "restart", "deployment/cloudflared", "-n", tunnelNamespace).CombinedOutput(); err != nil { + return fmt.Errorf("restart cloudflared connector: %w: %s", err, strings.TrimSpace(string(out))) + } + if out, err := exec.Command(kubectlPath, "--kubeconfig", kubeconfigPath, + "rollout", "status", "deployment/cloudflared", "-n", tunnelNamespace, "--timeout=120s").CombinedOutput(); err != nil { + return fmt.Errorf("wait for cloudflared restart: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func hostnameInfos(hosts []string) []HostnameInfo { + infos := make([]HostnameInfo, 0, len(hosts)) + for i, h := range hosts { + infos = append(infos, HostnameInfo{ + Hostname: h, + Primary: i == 0, + URL: "https://" + h, + }) + } + return infos +} + +// printLocalRemoveDNSHint explains that the hostname has stopped serving (its +// ingress rule is gone) but its CNAME must be removed in the dashboard, because +// obol intentionally holds no account-wide API token to delete DNS records. +func printLocalRemoveDNSHint(u *ui.UI, hostname string) { + u.Blank() + u.Dim(fmt.Sprintf("%s no longer routes to any service (its ingress rule was removed).", hostname)) + u.Dim(" Its CNAME still resolves — delete it in the Cloudflare dashboard (DNS →") + u.Dim(" Records). Obol holds no account-wide API token by design, so it does not") + u.Dim(" delete DNS records for you.") +} + +func printRemoteAddHostnameSteps(u *ui.UI, hostname string) { + u.Blank() + u.Bold("Add this hostname in the Cloudflare dashboard") + u.Print("Your tunnel is dashboard-managed (connector token, least privilege), so Obol") + u.Print("does not hold an API token to edit its ingress. Finish in the dashboard:") + u.Print(" 1. https://one.dash.cloudflare.com → Networks → Tunnels → your tunnel.") + u.Print(" 2. Public Hostname tab → Add a public hostname:") + u.Detail(" Subdomain / Domain", hostname) + u.Detail(" Type", "HTTP") + u.Detail(" Service URL", "http://traefik.traefik.svc.cluster.local:80") + u.Print(" 3. Save. Obol has already updated the in-cluster storefront route.") + u.Blank() +} + +func printRemoteRemoveHostnameSteps(u *ui.UI, hostname string) { + u.Blank() + u.Bold("Remove this hostname in the Cloudflare dashboard") + u.Print("Your tunnel is dashboard-managed, so its ingress lives in Cloudflare:") + u.Print(" 1. https://one.dash.cloudflare.com → Networks → Tunnels → your tunnel.") + u.Printf(" 2. Public Hostname tab → delete the entry for %s.", hostname) + u.Print(" 3. Save. Obol has already updated the in-cluster storefront route.") + u.Blank() +} diff --git a/internal/tunnel/hostnames_test.go b/internal/tunnel/hostnames_test.go new file mode 100644 index 00000000..3178ea58 --- /dev/null +++ b/internal/tunnel/hostnames_test.go @@ -0,0 +1,298 @@ +package tunnel + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +func newHostnameTestConfig(t *testing.T) *config.Config { + t.Helper() + dir := t.TempDir() + return &config.Config{ + ConfigDir: dir, + BinDir: filepath.Join(dir, "bin"), + DataDir: filepath.Join(dir, "data"), + StateDir: filepath.Join(dir, "state"), + } +} + +// writeFakeKubeconfig drops a placeholder kubeconfig so the "stack not running" +// guard passes and the later validation guards (the ones we assert here) fire. +// No cluster is contacted: every test below errors at a guard that runs BEFORE +// any kubectl/cloudflared call. +func writeFakeKubeconfig(t *testing.T, cfg *config.Config) { + t.Helper() + if err := os.MkdirAll(cfg.ConfigDir, 0o700); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + path := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if err := os.WriteFile(path, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil { + t.Fatalf("write fake kubeconfig: %v", err) + } +} + +func persistentLocalState(hostnames ...string) *tunnelState { + return &tunnelState{ + ExposureMode: tunnelExposurePersistent, + ManagementMode: tunnelManagementLocal, + Hostname: hostnames[0], + Hostnames: hostnames, + TunnelID: "00000000-0000-0000-0000-000000000000", + TunnelName: "obol-stack-test-local", + } +} + +// --- state reconciliation (legacy migration / dedup) ----------------------- + +func TestReconcileHostnameSet_LegacyMigration(t *testing.T) { + st := &tunnelState{ + ExposureMode: tunnelExposurePersistent, + ManagementMode: tunnelManagementLocal, + Hostname: "Stack.Example.com", + TunnelID: "00000000-0000-0000-0000-000000000000", + } + got := normalizeTunnelState(st) + + if want := []string{"stack.example.com"}; !reflect.DeepEqual(got.Hostnames, want) { + t.Fatalf("Hostnames = %v, want %v", got.Hostnames, want) + } + if got.Hostname != "stack.example.com" { + t.Fatalf("Hostname = %q, want primary mirror stack.example.com", got.Hostname) + } +} + +func TestReconcileHostnameSet_DedupAndOrder(t *testing.T) { + st := &tunnelState{ + ExposureMode: tunnelExposurePersistent, + ManagementMode: tunnelManagementRemote, + AccountID: "acct", + Hostname: "a.example.com", + Hostnames: []string{"a.example.com", "B.Example.com", "", "a.example.com", "c.example.com"}, + TunnelID: "00000000-0000-0000-0000-000000000000", + } + got := normalizeTunnelState(st) + + want := []string{"a.example.com", "b.example.com", "c.example.com"} + if !reflect.DeepEqual(got.Hostnames, want) { + t.Fatalf("Hostnames = %v, want %v", got.Hostnames, want) + } + if got.Hostname != "a.example.com" { + t.Fatalf("Hostname = %q, want a.example.com (Hostnames[0])", got.Hostname) + } +} + +func TestReconcileHostnameSet_HostnamesWithoutScalar(t *testing.T) { + st := &tunnelState{ + ExposureMode: tunnelExposurePersistent, + ManagementMode: tunnelManagementLocal, + Hostnames: []string{"first.example.com", "second.example.com"}, + TunnelID: "00000000-0000-0000-0000-000000000000", + } + got := normalizeTunnelState(st) + if got.Hostname != "first.example.com" { + t.Fatalf("Hostname = %q, want first.example.com", got.Hostname) + } + if want := []string{"first.example.com", "second.example.com"}; !reflect.DeepEqual(got.Hostnames, want) { + t.Fatalf("Hostnames = %v, want %v", got.Hostnames, want) + } +} + +func TestHostnameSet_RoundTripsThroughDisk(t *testing.T) { + cfg := newHostnameTestConfig(t) + if err := saveTunnelState(cfg, persistentLocalState("a.example.com", "b.example.com")); err != nil { + t.Fatalf("save: %v", err) + } + got, err := loadTunnelState(cfg) + if err != nil { + t.Fatalf("load: %v", err) + } + if want := []string{"a.example.com", "b.example.com"}; !reflect.DeepEqual(got.HostnameSet(), want) { + t.Fatalf("HostnameSet = %v, want %v", got.HostnameSet(), want) + } +} + +// --- listing --------------------------------------------------------------- + +func TestListHostnames_PersistentLocal(t *testing.T) { + cfg := newHostnameTestConfig(t) + if err := saveTunnelState(cfg, persistentLocalState("a.example.com", "b.example.com")); err != nil { + t.Fatalf("save: %v", err) + } + + result, err := ListHostnames(cfg) + if err != nil { + t.Fatalf("ListHostnames: %v", err) + } + if result.ManagementMode != tunnelManagementLocal { + t.Fatalf("ManagementMode = %q, want local", result.ManagementMode) + } + if len(result.Hostnames) != 2 { + t.Fatalf("want 2 hostnames, got %d", len(result.Hostnames)) + } + if !result.Hostnames[0].Primary || result.Hostnames[1].Primary { + t.Fatalf("expected only the first hostname primary: %+v", result.Hostnames) + } + if result.Hostnames[0].URL != "https://a.example.com" { + t.Fatalf("primary URL = %q, want https://a.example.com", result.Hostnames[0].URL) + } +} + +func TestListHostnames_Quick(t *testing.T) { + cfg := newHostnameTestConfig(t) + if err := saveTunnelState(cfg, &tunnelState{Mode: "quick"}); err != nil { + t.Fatalf("save: %v", err) + } + result, err := ListHostnames(cfg) + if err != nil { + t.Fatalf("ListHostnames: %v", err) + } + if len(result.Hostnames) != 0 { + t.Fatalf("quick tunnel should have no hostnames, got %v", result.Hostnames) + } +} + +func TestListHostnames_NoState(t *testing.T) { + cfg := newHostnameTestConfig(t) + result, err := ListHostnames(cfg) + if err != nil { + t.Fatalf("ListHostnames with no state: %v", err) + } + if len(result.Hostnames) != 0 { + t.Fatalf("no-state tunnel should have no hostnames, got %v", result.Hostnames) + } +} + +func TestHostnameInfos_PrimaryAndURL(t *testing.T) { + infos := hostnameInfos([]string{"primary.example.com", "second.example.com"}) + if len(infos) != 2 { + t.Fatalf("want 2 infos, got %d", len(infos)) + } + if !infos[0].Primary || infos[1].Primary { + t.Fatalf("only index 0 should be primary: %+v", infos) + } + if infos[1].URL != "https://second.example.com" { + t.Fatalf("URL = %q, want https://second.example.com", infos[1].URL) + } +} + +// --- AddHostname guards (all error before any cluster call) ---------------- + +func TestAddHostname_RejectsEmptyHostname(t *testing.T) { + cfg := newHostnameTestConfig(t) + if _, err := AddHostname(cfg, ui.New(false), AddHostnameOptions{Hostname: " "}); err == nil { + t.Fatal("expected error for empty hostname") + } +} + +func TestAddHostname_RejectsWithoutStack(t *testing.T) { + cfg := newHostnameTestConfig(t) + // No kubeconfig.yaml on disk → "stack not running" guard fires first. + _, err := AddHostname(cfg, ui.New(false), AddHostnameOptions{Hostname: "x.example.com"}) + if err == nil || !strings.Contains(err.Error(), "stack not running") { + t.Fatalf("expected 'stack not running' error, got %v", err) + } +} + +func TestAddHostname_RejectsWithoutPersistentTunnel(t *testing.T) { + cfg := newHostnameTestConfig(t) + writeFakeKubeconfig(t, cfg) + if err := saveTunnelState(cfg, &tunnelState{Mode: "quick"}); err != nil { + t.Fatalf("save: %v", err) + } + _, err := AddHostname(cfg, ui.New(false), AddHostnameOptions{Hostname: "x.example.com"}) + if err == nil || !strings.Contains(err.Error(), "no permanent tunnel") { + t.Fatalf("expected 'no permanent tunnel' error, got %v", err) + } +} + +func TestAddHostname_RejectsDuplicate(t *testing.T) { + cfg := newHostnameTestConfig(t) + writeFakeKubeconfig(t, cfg) + if err := saveTunnelState(cfg, persistentLocalState("a.example.com")); err != nil { + t.Fatalf("save: %v", err) + } + // Mixed-case duplicate must still be rejected (normalized match). + _, err := AddHostname(cfg, ui.New(false), AddHostnameOptions{Hostname: "A.Example.com"}) + if err == nil || !strings.Contains(err.Error(), "already a tunnel hostname") { + t.Fatalf("expected duplicate rejection, got %v", err) + } +} + +// --- RemoveHostname guards (all error before any cluster call) ------------- + +func TestRemoveHostname_RejectsWithoutStack(t *testing.T) { + cfg := newHostnameTestConfig(t) + _, err := RemoveHostname(cfg, ui.New(false), RemoveHostnameOptions{Hostname: "x.example.com"}) + if err == nil || !strings.Contains(err.Error(), "stack not running") { + t.Fatalf("expected 'stack not running' error, got %v", err) + } +} + +func TestRemoveHostname_RefusesLast(t *testing.T) { + cfg := newHostnameTestConfig(t) + writeFakeKubeconfig(t, cfg) + if err := saveTunnelState(cfg, persistentLocalState("only.example.com")); err != nil { + t.Fatalf("save: %v", err) + } + _, err := RemoveHostname(cfg, ui.New(false), RemoveHostnameOptions{Hostname: "only.example.com"}) + if err == nil || !strings.Contains(err.Error(), "refusing to remove the last hostname") { + t.Fatalf("expected refuse-last error, got %v", err) + } +} + +func TestRemoveHostname_UnknownHostname(t *testing.T) { + cfg := newHostnameTestConfig(t) + writeFakeKubeconfig(t, cfg) + if err := saveTunnelState(cfg, persistentLocalState("a.example.com", "b.example.com")); err != nil { + t.Fatalf("save: %v", err) + } + _, err := RemoveHostname(cfg, ui.New(false), RemoveHostnameOptions{Hostname: "nope.example.com"}) + if err == nil || !strings.Contains(err.Error(), "not a tracked tunnel hostname") { + t.Fatalf("expected unknown-hostname error, got %v", err) + } +} + +// --- Delete (teardown) guards + state cleanup ------------------------------ + +func TestDelete_RejectsWithoutStack(t *testing.T) { + cfg := newHostnameTestConfig(t) + _, err := Delete(cfg, ui.New(false), DeleteOptions{}) + if err == nil || !strings.Contains(err.Error(), "stack not running") { + t.Fatalf("expected 'stack not running' error, got %v", err) + } +} + +func TestDelete_RejectsWithoutPersistentTunnel(t *testing.T) { + cfg := newHostnameTestConfig(t) + writeFakeKubeconfig(t, cfg) + if err := saveTunnelState(cfg, &tunnelState{Mode: "quick"}); err != nil { + t.Fatalf("save: %v", err) + } + _, err := Delete(cfg, ui.New(false), DeleteOptions{}) + if err == nil || !strings.Contains(err.Error(), "no permanent tunnel") { + t.Fatalf("expected 'no permanent tunnel' error, got %v", err) + } +} + +func TestDeleteTunnelState_RemovesFile(t *testing.T) { + cfg := newHostnameTestConfig(t) + if err := saveTunnelState(cfg, persistentLocalState("a.example.com")); err != nil { + t.Fatalf("save: %v", err) + } + if err := deleteTunnelState(cfg); err != nil { + t.Fatalf("deleteTunnelState: %v", err) + } + if st, _ := loadTunnelState(cfg); st != nil { + t.Fatalf("expected nil state after delete, got %+v", st) + } + // Idempotent: deleting again is not an error. + if err := deleteTunnelState(cfg); err != nil { + t.Fatalf("second deleteTunnelState should be a no-op, got %v", err) + } +} diff --git a/internal/tunnel/login.go b/internal/tunnel/login.go index 90a84f9c..de548fc8 100644 --- a/internal/tunnel/login.go +++ b/internal/tunnel/login.go @@ -25,6 +25,14 @@ type LoginOptions struct { // fails with "An A, AAAA, or CNAME record with that host already exists" // (Cloudflare API error 1003). OverwriteDNS bool + + // ReuseCert skips the interactive `cloudflared tunnel login` browser step and + // reuses an existing ~/.cloudflared/cert.pem instead. Set it to provision + // headlessly (CI, or additional clusters on the same Cloudflare account) with + // a cert copied from a host already authenticated to the SAME account+zone. + // When set but no usable cert is present, Login errors rather than silently + // falling back to the browser, so the headless contract is explicit. + ReuseCert bool } // Login provisions a locally-managed tunnel using `cloudflared tunnel login` (browser auth), @@ -64,15 +72,31 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { return errors.New("cloudflared not found in PATH. Install it first (e.g. 'brew install cloudflared' on macOS)") } - u.Info("Authenticating cloudflared (browser)...") - - loginCmd := exec.Command(cloudflaredPath, "tunnel", "login") - loginCmd.Stdin = os.Stdin - loginCmd.Stdout = os.Stdout - - loginCmd.Stderr = os.Stderr - if err := loginCmd.Run(); err != nil { - return fmt.Errorf("cloudflared tunnel login failed: %w", err) + // --reuse-cert provisions headlessly by reusing an existing cloudflared origin + // cert instead of the browser. `cloudflared tunnel login` always opens a + // browser to fetch a fresh cert, but tunnel create/info/route/run only need + // the cert that login produced — so a cert.pem copied from another host on the + // SAME Cloudflare account+zone lets us stand up additional clusters without a + // browser. The `route dns` step below validates the cert's zone, so a + // wrong-account cert still fails loudly. We require the flag (not mere cert + // presence) so `obol tunnel login` keeps doing a fresh browser auth by + // default — a stale cert never silently bypasses login. + if opts.ReuseCert { + certPath := filepath.Join(defaultCloudflaredDir(), "cert.pem") + if info, statErr := os.Stat(certPath); statErr != nil || info.Size() == 0 { + return fmt.Errorf("--reuse-cert set but no usable cert at %s (missing or empty); omit --reuse-cert to authenticate via browser", certPath) + } + u.Infof("Reusing existing cloudflared cert at %s (skipping browser login)", certPath) + } else { + u.Info("Authenticating cloudflared (browser)...") + + loginCmd := exec.Command(cloudflaredPath, "tunnel", "login") + loginCmd.Stdin = os.Stdin + loginCmd.Stdout = os.Stdout + loginCmd.Stderr = os.Stderr + if err := loginCmd.Run(); err != nil { + return fmt.Errorf("cloudflared tunnel login failed: %w", err) + } } u.Infof("Creating tunnel: %s", tunnelName) @@ -121,7 +145,14 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { return err } - if err := applyLocalManagedK8sResources(cfg, u, kubeconfigPath, hostname, tunnelID, cert, cred); err != nil { + // Preserve any additional hostnames already tracked for this local tunnel so a + // re-login doesn't silently drop them; the freshly-routed hostname is primary. + hostnames := []string{hostname} + if st != nil && st.Management() == tunnelManagementLocal && st.TunnelID == tunnelID { + hostnames = normalizeHostnames(append(hostnames, st.Hostnames...)) + } + + if err := applyLocalManagedK8sResources(cfg, u, kubeconfigPath, hostnames, tunnelID, cert, cred); err != nil { return err } if err := deleteRemoteManagedK8sResources(cfg, u, kubeconfigPath); err != nil { @@ -146,7 +177,8 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { st.ExposureMode = tunnelExposurePersistent st.ManagementMode = tunnelManagementLocal st.TransportProtocol = transportProtocol - st.Hostname = hostname + st.Hostname = hostnames[0] + st.Hostnames = hostnames st.AccountID = "" st.ZoneID = "" st.TunnelID = tunnelID @@ -252,7 +284,7 @@ func verifyRoutedHostname(routedOutput, requestedHostname string) error { return nil } -func applyLocalManagedK8sResources(cfg *config.Config, u *ui.UI, kubeconfigPath, hostname, tunnelID string, certPEM, credJSON []byte) error { +func applyLocalManagedK8sResources(cfg *config.Config, u *ui.UI, kubeconfigPath string, hostnames []string, tunnelID string, certPEM, credJSON []byte) error { // Secret: account certificate + tunnel credentials (locally-managed tunnel requires origincert). secretYAML := buildLocalManagedSecretYAML(certPEM, credJSON) @@ -261,7 +293,7 @@ func applyLocalManagedK8sResources(cfg *config.Config, u *ui.UI, kubeconfigPath, } // ConfigMap: config.yml + tunnel_id used for command arg expansion. - cfgYAML := buildLocalManagedConfigYAML(hostname, tunnelID) + cfgYAML := buildLocalManagedConfigYAML(hostnames, tunnelID) if err := kubectlApply(cfg, u, kubeconfigPath, cfgYAML); err != nil { return err } @@ -292,7 +324,23 @@ data: return []byte(secret) } -func buildLocalManagedConfigYAML(hostname, tunnelID string) []byte { +// buildLocalManagedConfigYAML renders the cloudflared-local-config ConfigMap with +// one ingress rule per public hostname (all routed to the same in-cluster Traefik +// service), terminated by the mandatory catch-all http_status:404 rule. Adding or +// removing a hostname re-renders over the full set, so a second domain never +// displaces the first. +func buildLocalManagedConfigYAML(hostnames []string, tunnelID string) []byte { + hostnames = normalizeHostnames(hostnames) + + var ingress strings.Builder + for _, h := range hostnames { + ingress.WriteString(" - hostname: ") + ingress.WriteString(h) + ingress.WriteString("\n service: http://traefik.traefik.svc.cluster.local:80\n") + } + // Mandatory catch-all: cloudflared rejects an ingress list without one. + ingress.WriteString(" - service: http_status:404\n") + cfg := fmt.Sprintf(`apiVersion: v1 kind: ConfigMap metadata: @@ -305,14 +353,21 @@ data: credentials-file: /etc/cloudflared/credentials.json ingress: - - hostname: %s - service: http://traefik.traefik.svc.cluster.local:80 - - service: http_status:404 -`, localManagedConfigMapName, tunnelNamespace, tunnelID, tunnelID, hostname) +%s`, localManagedConfigMapName, tunnelNamespace, tunnelID, tunnelID, ingress.String()) return []byte(cfg) } +// normalizeHostnames normalizes and de-duplicates a slice of hostnames, +// preserving first-seen order (so the primary stays at index 0). +func normalizeHostnames(in []string) []string { + out := make([]string, 0, len(in)) + for _, h := range in { + out = appendHostname(out, h) + } + return out +} + func kubectlApply(cfg *config.Config, u *ui.UI, kubeconfigPath string, manifest []byte) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") diff --git a/internal/tunnel/provision.go b/internal/tunnel/provision.go index fa544b29..a658f214 100644 --- a/internal/tunnel/provision.go +++ b/internal/tunnel/provision.go @@ -75,6 +75,7 @@ func ProvisionWithToken(cfg *config.Config, u *ui.UI, opts TokenProvisionOptions st.ManagementMode = tunnelManagementRemote st.TransportProtocol = transportProtocol st.Hostname = hostname + st.Hostnames = []string{hostname} st.AccountID = claims.AccountTag st.ZoneID = "" // DNS is managed in the dashboard, not by us. st.TunnelID = claims.TunnelID diff --git a/internal/tunnel/restore.go b/internal/tunnel/restore.go index daff1845..6ceea523 100644 --- a/internal/tunnel/restore.go +++ b/internal/tunnel/restore.go @@ -56,7 +56,7 @@ func restoreLocalManagedResources(cfg *config.Config, u *ui.UI, kubeconfigPath s return fmt.Errorf("read local cloudflared credentials %s: %w", credPath, err) } - if err := applyLocalManagedK8sResources(cfg, u, kubeconfigPath, st.Hostname, st.TunnelID, cert, cred); err != nil { + if err := applyLocalManagedK8sResources(cfg, u, kubeconfigPath, st.HostnameSet(), st.TunnelID, cert, cred); err != nil { return err } if err := deleteRemoteManagedK8sResources(cfg, u, kubeconfigPath); err != nil { diff --git a/internal/tunnel/state.go b/internal/tunnel/state.go index fbcb5a9b..fa6a3d20 100644 --- a/internal/tunnel/state.go +++ b/internal/tunnel/state.go @@ -42,6 +42,7 @@ type tunnelState struct { ManagementMode string `json:"management_mode,omitempty"` TransportProtocol string `json:"transport_protocol,omitempty"` Hostname string `json:"hostname,omitempty"` + Hostnames []string `json:"hostnames,omitempty"` AccountID string `json:"account_id,omitempty"` ZoneID string `json:"zone_id,omitempty"` TunnelID string `json:"tunnel_id,omitempty"` @@ -153,11 +154,57 @@ func normalizeTunnelState(st *tunnelState) *tunnelState { clone.TransportProtocol = tunnelTransportAuto } + reconcileHostnameSet(&clone) + clone.Mode = legacyTunnelMode(clone.ExposureMode) return &clone } +// reconcileHostnameSet keeps the scalar Hostname and the Hostnames slice +// consistent: legacy state files (only Hostname) seed the slice; new state +// mirrors Hostnames[0] back into Hostname. Every entry is normalized and +// de-duplicated, preserving first-seen order so the primary (index 0) is stable. +func reconcileHostnameSet(st *tunnelState) { + merged := make([]string, 0, len(st.Hostnames)+1) + merged = appendHostname(merged, st.Hostname) + for _, h := range st.Hostnames { + merged = appendHostname(merged, h) + } + + st.Hostnames = merged + if len(merged) > 0 { + st.Hostname = merged[0] + } else { + st.Hostname = "" + } +} + +// appendHostname normalizes h and appends it to dst unless it is empty or +// already present (case-insensitive after normalization). +func appendHostname(dst []string, h string) []string { + n := normalizeHostname(h) + if n == "" { + return dst + } + for _, existing := range dst { + if existing == n { + return dst + } + } + return append(dst, n) +} + +// HostnameSet returns the full set of public hostnames served by the tunnel, +// primary first. Returns nil for quick/dormant tunnels with no hostname. +func (st *tunnelState) HostnameSet() []string { + normalized := normalizeTunnelState(st) + if normalized == nil { + return nil + } + return normalized.Hostnames +} + func legacyTunnelMode(exposureMode string) string { if exposureMode == tunnelExposurePersistent { return legacyTunnelModeDNS @@ -238,6 +285,16 @@ func deleteRemoteTunnelToken(cfg *config.Config) error { return nil } +// deleteTunnelState removes the persisted tunnel state file so the stack reverts +// to "no persistent tunnel". A missing file is not an error (idempotent). +func deleteTunnelState(cfg *config.Config) error { + if err := os.Remove(tunnelStatePath(cfg)); err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + // TunnelState is an exported alias so other packages (agent, openclaw) // can read tunnel state without reaching into unexported types. type TunnelState = tunnelState diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index ed49ba67..42513674 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -457,7 +457,7 @@ func syncTunnelDependents(cfg *config.Config, u *ui.UI, tunnelURL string) { if err := SyncTunnelConfigMap(cfg, tunnelURL); err != nil { u.Dim("Could not sync tunnel URL to frontend ConfigMap: " + err.Error()) } - if err := CreateStorefront(cfg, tunnelURL); err != nil { + if err := CreateStorefront(cfg, storefrontHostnames(cfg, tunnelURL)...); err != nil { u.Dim("Could not create storefront: " + err.Error()) } } @@ -1035,7 +1035,7 @@ func EnsureTunnelForSell(cfg *config.Config, u *ui.UI) (string, error) { } // EnsureRunning already calls InjectBaseURL + SyncTunnelConfigMap. // Create the storefront landing page for the tunnel hostname. - if err := CreateStorefront(cfg, tunnelURL); err != nil { + if err := CreateStorefront(cfg, storefrontHostnames(cfg, tunnelURL)...); err != nil { u.Warnf("could not create storefront: %v", err) } @@ -1066,19 +1066,176 @@ func Stop(cfg *config.Config, u *ui.UI) error { return nil } +// DeleteOptions configures `obol tunnel delete`. +type DeleteOptions struct { + // Force skips the interactive confirmation prompt. + Force bool +} + +// DeleteResult is the JSON-serialisable result of Delete. +type DeleteResult struct { + ManagementMode string `json:"management_mode"` + DeletedHostnames []string `json:"deleted_hostnames"` + CloudflareTunnelDeleted bool `json:"cloudflare_tunnel_deleted"` + DashboardCleanupRequired bool `json:"dashboard_cleanup_required"` +} + +// Delete tears down the persistent tunnel completely and reverts the connector +// to a default quick tunnel. It is the destructive counterpart to Stop (which +// only pauses the connector). Where Obol holds the credential — a local-managed +// (cert) tunnel — it deletes the Cloudflare tunnel directly. For a +// dashboard-managed (connector token) tunnel Obol holds no account-wide API +// token, so it cleans up the cluster side and prints the dashboard steps. DNS +// CNAME records are never deleted (no broad token by design) and are left to the +// operator with explicit instructions. +func Delete(cfg *config.Config, u *ui.UI, opts DeleteOptions) (*DeleteResult, error) { + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return nil, errors.New("stack not running, use 'obol stack up' first") + } + + st, err := loadTunnelState(cfg) + if err != nil { + return nil, fmt.Errorf("load tunnel state: %w", err) + } + if st == nil || !st.IsPersistent() { + return nil, errors.New("no permanent tunnel to delete (the current tunnel is quick or none); use 'obol tunnel stop' to pause a quick tunnel") + } + + management := st.Management() + hostnames := st.HostnameSet() + + if !opts.Force && u.IsTTY() { + u.Blank() + u.Bold("This permanently tears down the persistent tunnel serving:") + for _, h := range hostnames { + u.Dim(" - https://" + h) + } + u.Dim("The stack reverts to a default quick tunnel (new temporary URL).") + if !u.Confirm("Delete this tunnel?", false) { + return nil, errors.New("aborted") + } + } + + result := &DeleteResult{ManagementMode: management, DeletedHostnames: hostnames} + + // 1. Delete the Cloudflare-side tunnel where Obol holds the credential. + switch management { + case tunnelManagementLocal: + if err := deleteLocalCloudflareTunnel(u, st.TunnelName, st.TunnelID); err != nil { + // Non-fatal: keep cleaning the cluster even if the cloudflared delete + // fails (e.g. the tunnel was already removed). + u.Warnf("could not delete the Cloudflare tunnel (continuing cleanup): %v", err) + } else { + result.CloudflareTunnelDeleted = true + } + case tunnelManagementRemote: + result.DashboardCleanupRequired = true + } + + // 2. Remove in-cluster persistent resources + storefront + local token. + if err := deleteLocalManagedK8sResources(cfg, u, kubeconfigPath); err != nil { + u.Warnf("could not delete local-managed resources: %v", err) + } + if err := deleteRemoteManagedK8sResources(cfg, u, kubeconfigPath); err != nil { + u.Warnf("could not delete remote-managed resources: %v", err) + } + if err := deleteRemoteTunnelToken(cfg); err != nil { + u.Warnf("could not delete local tunnel token: %v", err) + } + if err := DeleteStorefront(cfg); err != nil { + u.Warnf("could not delete storefront: %v", err) + } + + // 3. Clear local state and revert the connector to the default quick tunnel + // (a persistent→quick mode change rolls the pods via helm upgrade). + if err := deleteTunnelState(cfg); err != nil { + u.Warnf("could not clear tunnel state: %v", err) + } + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementQuick, tunnelTransportAuto); err != nil { + u.Warnf("could not reset connector to quick mode: %v", err) + } + if err := helmUpgradeCloudflared(cfg, u, kubeconfigPath); err != nil { + u.Warnf("could not re-render cloudflared to quick mode: %v", err) + } + + // 4. Tell the operator what Obol could not do without a broad credential. + u.Blank() + u.Success("Tunnel deleted — reverted to a default quick tunnel") + u.Dim(" 'obol tunnel status' shows the new temporary URL; 'obol tunnel stop' disables public access.") + if management == tunnelManagementLocal { + u.Blank() + u.Dim("The Cloudflare tunnel was deleted. Its DNS CNAME record(s) still resolve —") + u.Dim("delete them in the Cloudflare dashboard (DNS → Records):") + for _, h := range hostnames { + u.Dim(" - " + h) + } + } else { + u.Blank() + u.Bold("Finish teardown in the Cloudflare dashboard") + u.Print("Obol holds no API token for a dashboard-managed tunnel, so delete it there:") + u.Print(" https://one.dash.cloudflare.com → Networks → Tunnels → delete the tunnel,") + u.Print(" then remove its Public Hostname(s) and DNS record(s).") + } + + return result, nil +} + +// deleteLocalCloudflareTunnel deletes the cert-scoped cloudflared tunnel. The -f +// flag cleans up any active connections so the delete succeeds even while the +// connector is still running. +func deleteLocalCloudflareTunnel(u *ui.UI, tunnelName, tunnelID string) error { + cloudflaredPath, err := exec.LookPath("cloudflared") + if err != nil { + return errors.New("cloudflared not found in PATH") + } + + ref := strings.TrimSpace(tunnelName) + if ref == "" { + ref = strings.TrimSpace(tunnelID) // cloudflared accepts the UUID as the tunnel ref + } + if ref == "" { + return errors.New("local tunnel has no name or id to delete") + } + + u.Infof("Deleting Cloudflare tunnel %s...", ref) + if out, err := exec.Command(cloudflaredPath, "tunnel", "delete", "-f", ref).CombinedOutput(); err != nil { + return fmt.Errorf("cloudflared tunnel delete failed: %w: %s", err, strings.TrimSpace(string(out))) + } + + return nil +} + // storefrontNamespace is where the storefront landing page resources live. const storefrontNamespace = "traefik" -// CreateStorefront creates (or updates) a simple HTML landing page served at -// the tunnel hostname's root path. This uses the same busybox-httpd + ConfigMap -// pattern as the .well-known registration in monetize.py. -func CreateStorefront(cfg *config.Config, tunnelURL string) error { - parsed, err := url.Parse(tunnelURL) - if err != nil { - return fmt.Errorf("invalid tunnel URL: %w", err) +// storefrontHostnames returns the hostnames the public storefront should be +// published on: the full tracked set for a persistent tunnel, else the host +// parsed from tunnelURL (quick tunnels). Empty entries are dropped. +func storefrontHostnames(cfg *config.Config, tunnelURL string) []string { + if st, _ := loadTunnelState(cfg); st != nil && st.IsPersistent() { + if set := st.HostnameSet(); len(set) > 0 { + return set + } + } + if parsed, err := url.Parse(tunnelURL); err == nil { + if h := parsed.Hostname(); h != "" { + return []string{h} + } } + return nil +} - hostname := parsed.Hostname() +// CreateStorefront creates (or updates) the public storefront landing page and +// publishes it at the root path of EVERY supplied hostname. Each argument may be +// a bare hostname or a full URL (scheme/path stripped); empty or duplicate +// entries are dropped. The single HTTPRoute lists all hostnames, so a second +// domain serves `/` without displacing the first. +func CreateStorefront(cfg *config.Config, hostnames ...string) error { + hosts := normalizeHostnames(hostnames) + if len(hosts) == 0 { + return errors.New("CreateStorefront requires at least one hostname") + } kubectlPath := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -1174,7 +1331,7 @@ func CreateStorefront(cfg *config.Config, tunnelURL string) error { "namespace": storefrontNamespace, }, "spec": map[string]any{ - "hostnames": []string{hostname}, + "hostnames": hosts, "parentRefs": []map[string]any{ { "name": "traefik-gateway", diff --git a/internal/tunnel/tunnel_test.go b/internal/tunnel/tunnel_test.go index b9c0dda9..b00d4df8 100644 --- a/internal/tunnel/tunnel_test.go +++ b/internal/tunnel/tunnel_test.go @@ -87,7 +87,7 @@ func TestParseQuickTunnelURL_PicksLatest(t *testing.T) { } func TestBuildLocalManagedConfigYAMLRoutesOnlyRequestedHostname(t *testing.T) { - out := string(buildLocalManagedConfigYAML("stack.example.com", "00000000-0000-0000-0000-000000000000")) + out := string(buildLocalManagedConfigYAML([]string{"stack.example.com"}, "00000000-0000-0000-0000-000000000000")) for _, want := range []string{ "tunnel: 00000000-0000-0000-0000-000000000000", @@ -110,6 +110,51 @@ func TestBuildLocalManagedConfigYAMLRoutesOnlyRequestedHostname(t *testing.T) { } } +// TestBuildLocalManagedConfigYAMLMultiHostname proves two domains coexist in a +// single connector ingress: one rule per hostname, both to the same Traefik +// service, terminated by exactly one catch-all (last). This is the core "deploy +// one domain, then another" invariant for local-managed tunnels. +func TestBuildLocalManagedConfigYAMLMultiHostname(t *testing.T) { + out := string(buildLocalManagedConfigYAML( + []string{"a.example.com", "b.example.com"}, + "00000000-0000-0000-0000-000000000000", + )) + + for _, want := range []string{"- hostname: a.example.com", "- hostname: b.example.com", "- service: http_status:404"} { + if !strings.Contains(out, want) { + t.Fatalf("multi-hostname config missing %q:\n%s", want, out) + } + } + if got := strings.Count(out, "hostname:"); got != 2 { + t.Fatalf("expected exactly 2 hostname rules, got %d:\n%s", got, out) + } + if got := strings.Count(out, "http_status:404"); got != 1 { + t.Fatalf("expected exactly one catch-all rule, got %d:\n%s", got, out) + } + if !strings.HasSuffix(strings.TrimRight(out, "\n"), "service: http_status:404") { + t.Fatalf("catch-all rule must be last:\n%s", out) + } + if got := strings.Count(out, "service: http://traefik.traefik.svc.cluster.local:80"); got != 2 { + t.Fatalf("expected both hostnames to route to Traefik, got %d service rules:\n%s", got, out) + } +} + +// TestBuildLocalManagedConfigYAMLDeduplicates ensures duplicate/empty/mixed-case +// hostnames are normalized so a re-add or casing slip cannot produce two +// conflicting ingress rules for the same name. +func TestBuildLocalManagedConfigYAMLDeduplicates(t *testing.T) { + out := string(buildLocalManagedConfigYAML( + []string{"A.Example.com", "", "a.example.com", "https://a.example.com/path"}, + "00000000-0000-0000-0000-000000000000", + )) + if got := strings.Count(out, "hostname:"); got != 1 { + t.Fatalf("expected duplicates/empties collapsed to 1 hostname rule, got %d:\n%s", got, out) + } + if !strings.Contains(out, "- hostname: a.example.com") { + t.Fatalf("expected normalized lowercase hostname:\n%s", out) + } +} + func TestPatchAgentBaseURL_Insert(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "values-obol.yaml")