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) + } + }) + } +}