From 1b258062476323e3cda5b9c1cee61c3ebc33e991 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 12 May 2026 15:08:57 +0800 Subject: [PATCH] fix(tunnel): add --overwrite-dns to obol tunnel login/setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-running `obol tunnel login` or `obol tunnel setup --management local` against a hostname that already has an A/AAAA/CNAME record fails with: cloudflared tunnel route dns failed: exit status 1 Failed to add route: code: 1003, reason: Failed to create record inference.example.com with err An A, AAAA, or CNAME record with that host already exists. cloudflared has supported `--overwrite-dns` on `tunnel route dns` for years for exactly this case (also exposed as `TUNNEL_FORCE_PROVISIONING_DNS`) but the obol wrapper never passed it. Operators hit this whenever they: - Retry a wizard run after fixing an earlier failure. - Move an existing hostname over to a new in-cluster tunnel (cluster re-created, new petname, same DNS target intended). Changes: - internal/tunnel/login.go: add `OverwriteDNS bool` to LoginOptions and pass `--overwrite-dns` through a new `routeDNSArgs` helper. When the caller did not opt in and cloudflared returns the "record already exists" error, append a hint pointing at --overwrite-dns. - internal/tunnel/domain_setup.go: add the same field to SetupOptions and forward it into the Login call in local-managed mode. Remote mode is unaffected because the Cloudflare API path already upserts. - cmd/obol/tunnel_domain.go: expose `--overwrite-dns` on `obol tunnel login` and `obol tunnel setup`. - Default stays false — the user has to opt in. The flag is genuinely destructive (you may flatten a CNAME that someone else placed), so surfacing it as a hint when the conflict happens is the right ergonomic shape. Test plan: - `go test ./internal/tunnel -run TestRouteDNSArgs` passes both default and overwrite cases. - Manual: confirmed end-to-end on spark2 — the prior CNAME for `inference.v1337.org` was replaced when the wizard was re-run after the cluster was recreated. --- cmd/obol/tunnel_domain.go | 22 +++++++++++++----- internal/tunnel/domain_setup.go | 13 ++++++++++- internal/tunnel/login.go | 29 ++++++++++++++++++++++-- internal/tunnel/login_test.go | 40 +++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 internal/tunnel/login_test.go diff --git a/cmd/obol/tunnel_domain.go b/cmd/obol/tunnel_domain.go index ff9a83f70..47ecff96b 100644 --- a/cmd/obol/tunnel_domain.go +++ b/cmd/obol/tunnel_domain.go @@ -56,11 +56,16 @@ func tunnelCommand(cfg *config.Config) *cli.Command { Required: true, }, tunnelTransportProtocolFlag(), + &cli.BoolFlag{ + Name: "overwrite-dns", + Usage: "Replace any existing A/AAAA/CNAME at the hostname (forwards --overwrite-dns to cloudflared)", + }, }, 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"), }) }, }, @@ -247,13 +252,15 @@ func tunnelSetupFlags() []cli.Flag { &cli.BoolFlag{Name: "auto-renew", Usage: "Enable domain auto-renew when registering a domain"}, &cli.StringFlag{Name: "privacy-mode", Usage: "WHOIS privacy mode for registration", Value: "redaction"}, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Confirm billable domain registration without prompting"}, + &cli.BoolFlag{Name: "overwrite-dns", Usage: "Replace any existing A/AAAA/CNAME at the hostname (forwards --overwrite-dns to cloudflared in local-managed mode)"}, &cli.StringFlag{Name: "from-json", Usage: "Read setup options from JSON file (or - for stdin)"}, } } func setupOptionsFromCommand(cmd *cli.Command, u interface { Input(string, string) (string, error) -}) (tunnel.SetupOptions, error) { +}, +) (tunnel.SetupOptions, error) { if jsonPath := cmd.String("from-json"); jsonPath != "" { var opts tunnel.SetupOptions data, err := readJSONInput(jsonPath) @@ -287,12 +294,14 @@ func setupOptionsFromCommand(cmd *cli.Command, u interface { AutoRenew: cmd.Bool("auto-renew"), PrivacyMode: cmd.String("privacy-mode"), ConfirmCharge: cmd.Bool("yes"), + OverwriteDNS: cmd.Bool("overwrite-dns"), }, nil } func domainSearchOptionsFromCommand(cmd *cli.Command, u interface { Input(string, string) (string, error) -}) (tunnel.DomainSearchOptions, error) { +}, +) (tunnel.DomainSearchOptions, error) { if jsonPath := cmd.String("from-json"); jsonPath != "" { var opts tunnel.DomainSearchOptions data, err := readJSONInput(jsonPath) @@ -373,7 +382,8 @@ func printDomainSuggestions(u interface { Bold(string) Print(string) Detail(string, string) -}, result *tunnel.DomainSearchResult) { +}, result *tunnel.DomainSearchResult, +) { u.Blank() u.Bold("Domain Suggestions") for _, domain := range result.Domains { @@ -394,7 +404,8 @@ func printDomainChecks(u interface { Bold(string) Print(string) Detail(string, string) -}, result *tunnel.DomainCheckResult) { +}, result *tunnel.DomainCheckResult, +) { u.Blank() u.Bold("Domain Availability") for _, domain := range result.Domains { @@ -416,7 +427,8 @@ func printDomainRegistration(u interface { Print(string) Detail(string, string) Successf(string, ...any) -}, result *tunnel.DomainRegisterResult) { +}, result *tunnel.DomainRegisterResult, +) { u.Blank() u.Successf("Domain registration submitted for %s", result.Availability.Name) u.Detail("Price", tunnelSummaryPrice(result.Availability)) diff --git a/internal/tunnel/domain_setup.go b/internal/tunnel/domain_setup.go index 83df775a7..66e98a291 100644 --- a/internal/tunnel/domain_setup.go +++ b/internal/tunnel/domain_setup.go @@ -63,6 +63,13 @@ type SetupOptions struct { AutoRenew bool PrivacyMode string ConfirmCharge bool + + // OverwriteDNS forwards --overwrite-dns to the underlying + // `cloudflared tunnel route dns` invocation in local-managed mode, so a + // prior CNAME at the hostname is replaced instead of failing the wizard. + // Has no effect in remote-managed mode (the Cloudflare API path performs + // its own upsert). + OverwriteDNS bool } type SetupResult struct { @@ -455,7 +462,11 @@ func Setup(cfg *config.Config, u *ui.UI, opts SetupOptions) (*SetupResult, error } if management == tunnelManagementLocal { - if err := Login(cfg, u, LoginOptions{Hostname: hostname, TransportProtocol: opts.TransportProtocol}); err != nil { + if err := Login(cfg, u, LoginOptions{ + Hostname: hostname, + TransportProtocol: opts.TransportProtocol, + OverwriteDNS: opts.OverwriteDNS, + }); err != nil { return nil, err } return &SetupResult{ diff --git a/internal/tunnel/login.go b/internal/tunnel/login.go index 9c28a4884..16bc9f6b2 100644 --- a/internal/tunnel/login.go +++ b/internal/tunnel/login.go @@ -18,6 +18,13 @@ import ( type LoginOptions struct { Hostname string TransportProtocol string + + // OverwriteDNS passes --overwrite-dns to `cloudflared tunnel route dns`. + // Without it, cloudflared refuses to replace an existing A/AAAA/CNAME + // record at the hostname, so re-running the wizard after a prior attempt + // fails with "An A, AAAA, or CNAME record with that host already exists" + // (Cloudflare API error 1003). + OverwriteDNS bool } // Login provisions a locally-managed tunnel using `cloudflared tunnel login` (browser auth), @@ -101,9 +108,14 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { u.Infof("Creating DNS route for %s...", hostname) - routeOut, err := exec.Command(cloudflaredPath, "tunnel", "route", "dns", tunnelName, hostname).CombinedOutput() + routeArgs := routeDNSArgs(tunnelName, hostname, opts.OverwriteDNS) + routeOut, err := exec.Command(cloudflaredPath, routeArgs...).CombinedOutput() if err != nil { - return fmt.Errorf("cloudflared tunnel route dns failed: %w\n%s", err, strings.TrimSpace(string(routeOut))) + hint := "" + if !opts.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 := applyLocalManagedK8sResources(cfg, u, kubeconfigPath, hostname, tunnelID, cert, cred); err != nil { @@ -160,6 +172,19 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { return nil } +// routeDNSArgs builds the cloudflared argument vector for the +// `tunnel route dns` subcommand. When overwrite is true, --overwrite-dns is +// inserted between `dns` and the tunnel/hostname so cloudflared replaces an +// existing A/AAAA/CNAME record at the hostname instead of failing with API +// error 1003. +func routeDNSArgs(tunnelName, hostname string, overwrite bool) []string { + args := []string{"tunnel", "route", "dns"} + if overwrite { + args = append(args, "--overwrite-dns") + } + return append(args, tunnelName, hostname) +} + func defaultCloudflaredDir() string { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/tunnel/login_test.go b/internal/tunnel/login_test.go new file mode 100644 index 000000000..8f65f9b9b --- /dev/null +++ b/internal/tunnel/login_test.go @@ -0,0 +1,40 @@ +package tunnel + +import ( + "reflect" + "testing" +) + +func TestRouteDNSArgs(t *testing.T) { + tests := []struct { + name string + tunnelName string + hostname string + overwrite bool + want []string + }{ + { + name: "default (no overwrite)", + tunnelName: "obol-stack-foo", + hostname: "inference.example.com", + overwrite: false, + want: []string{"tunnel", "route", "dns", "obol-stack-foo", "inference.example.com"}, + }, + { + name: "overwrite-dns inserted before tunnel/hostname", + tunnelName: "obol-stack-foo", + hostname: "inference.example.com", + overwrite: true, + want: []string{"tunnel", "route", "dns", "--overwrite-dns", "obol-stack-foo", "inference.example.com"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := routeDNSArgs(tc.tunnelName, tc.hostname, tc.overwrite) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("routeDNSArgs(%q, %q, %v) = %v; want %v", + tc.tunnelName, tc.hostname, tc.overwrite, got, tc.want) + } + }) + } +}