From bfd16184030ca2efc0d0d69ad27010c8c3c02102 Mon Sep 17 00:00:00 2001 From: eding Date: Tue, 4 Aug 2026 00:04:29 +0200 Subject: [PATCH] feat: manage Cloudflare tunnels --- cmd/cloudflare_tunnel.go | 144 +++++++++++++++++++++ cmd/cloudflare_tunnel_test.go | 182 +++++++++++++++++++++++++++ docs/operations.md | 24 +++- docs/secrets.md | 14 ++- internal/bootstrap/bootstrap.go | 63 ++++++++++ internal/bootstrap/bootstrap_test.go | 116 +++++++++++++++++ internal/cloudflare/client.go | 160 +++++++++++++++++++++++ internal/cloudflare/client_test.go | 118 +++++++++++++++++ internal/cloudflare/tunnel.go | 158 +++++++++++++++++++++++ internal/cloudflare/tunnel_test.go | 50 ++++++++ internal/ui/wizard.go | 8 +- 11 files changed, 1023 insertions(+), 14 deletions(-) create mode 100644 cmd/cloudflare_tunnel.go create mode 100644 cmd/cloudflare_tunnel_test.go create mode 100644 internal/cloudflare/client.go create mode 100644 internal/cloudflare/client_test.go create mode 100644 internal/cloudflare/tunnel.go create mode 100644 internal/cloudflare/tunnel_test.go diff --git a/cmd/cloudflare_tunnel.go b/cmd/cloudflare_tunnel.go new file mode 100644 index 0000000..8b04eab --- /dev/null +++ b/cmd/cloudflare_tunnel.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" + "github.com/Pingu-Studio/MaidnCLI/internal/cloudflare" + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/spf13/cobra" +) + +var cloudflareTunnelConfigPath string +var cloudflareTunnelHostname string +var cloudflareTunnelService string +var cloudflareTunnelZoneID string + +var loadCloudflareTunnelConfig = config.Load +var readCloudflareTunnelSecrets = bootstrap.ReadOperationalSecrets +var writeCloudflareTunnelSecrets = bootstrap.WriteOperationalSecrets +var newCloudflareRouteClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) } +var initializeCloudflareTunnelOpenBao = bootstrap.InitializeOpenBao + +var cloudflareTunnelCmd = &cobra.Command{ + Use: "cloudflare-tunnel", + Short: "Manage CLI-created Cloudflare Tunnel routes.", +} + +var cloudflareTunnelRouteCmd = &cobra.Command{Use: "route"} + +var cloudflareTunnelRouteAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a proxied public route to the managed tunnel.", + RunE: func(cmd *cobra.Command, _ []string) error { + return runCloudflareTunnelRoute(cmd, true) + }, +} + +var cloudflareTunnelRouteRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a proxied public route from the managed tunnel.", + RunE: func(cmd *cobra.Command, _ []string) error { + return runCloudflareTunnelRoute(cmd, false) + }, +} + +func init() { + rootCmd.AddCommand(cloudflareTunnelCmd) + cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd) + cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd) + cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML") + for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} { + command.Flags().StringVar(&cloudflareTunnelHostname, "hostname", "", "Public DNS hostname") + command.Flags().StringVar(&cloudflareTunnelService, "service", "", "Upstream http or https service URL") + command.Flags().StringVar(&cloudflareTunnelZoneID, "zone-id", "", "Cloudflare zone ID") + _ = command.MarkFlagRequired("hostname") + _ = command.MarkFlagRequired("service") + _ = command.MarkFlagRequired("zone-id") + } +} + +func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error { + if cloudflareTunnelConfigPath == "" { + return errors.New("--config is required") + } + if strings.TrimSpace(cloudflareTunnelZoneID) == "" { + return errors.New("--zone-id is required") + } + route, err := cloudflare.NewRoute(cloudflareTunnelHostname, cloudflareTunnelService) + if err != nil { + return err + } + cfg, err := loadCloudflareTunnelConfig(cloudflareTunnelConfigPath) + if err != nil { + return err + } + secrets, err := readCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath) + if err != nil { + return fmt.Errorf("read encrypted Cloudflare operational state: %w", err) + } + token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"]) + if token == "" { + return errors.New("encrypted operational secrets requires platform/cloudflare.api-token") + } + stored, present, err := cloudflare.ParseStoredTunnel(secrets["platform/cloudflare-tunnel"]) + if err != nil { + return err + } + if !present { + return errors.New("Cloudflare tunnel credentials and config are not generated; run bootstrap reconcile first") + } + ctx := context.Background() + if cmd != nil && cmd.Context() != nil { + ctx = cmd.Context() + } + client := newCloudflareRouteClient(token) + if add { + changed, err := stored.Config.AddRoute(route) + if err != nil { + return err + } + if err := client.EnsureCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil { + return fmt.Errorf("ensure Cloudflare route DNS record: %w", err) + } + if !changed { + return nil + } + values, err := stored.Values() + if err != nil { + return err + } + secrets["platform/cloudflare-tunnel"] = values + if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil { + return errors.New("save encrypted Cloudflare tunnel route") + } + if err := initializeCloudflareTunnelOpenBao(cfg); err != nil { + return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config --initialize-openbao") + } + return nil + } + changed, err := stored.Config.RemoveRoute(route) + if err != nil { + return err + } + if changed { + values, err := stored.Values() + if err != nil { + return err + } + secrets["platform/cloudflare-tunnel"] = values + if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil { + return errors.New("save encrypted Cloudflare tunnel route") + } + if err := initializeCloudflareTunnelOpenBao(cfg); err != nil { + return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config --initialize-openbao") + } + } + if err := client.DeleteCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil { + return fmt.Errorf("delete Cloudflare route DNS record: %w", err) + } + return nil +} diff --git a/cmd/cloudflare_tunnel_test.go b/cmd/cloudflare_tunnel_test.go new file mode 100644 index 0000000..5466a4d --- /dev/null +++ b/cmd/cloudflare_tunnel_test.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/cloudflare" + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +type fakeCloudflareRouteAPI struct { + ensured bool + deleted bool + ensureErr error + deleteCalls int +} + +func (*fakeCloudflareRouteAPI) ListAccounts(context.Context) ([]cloudflare.Account, error) { + return nil, nil +} + +func (*fakeCloudflareRouteAPI) ListTunnels(context.Context, string, string) ([]cloudflare.Tunnel, error) { + return nil, nil +} + +func (*fakeCloudflareRouteAPI) CreateTunnel(context.Context, string, string) (cloudflare.Tunnel, error) { + return cloudflare.Tunnel{}, nil +} + +func (f *fakeCloudflareRouteAPI) EnsureCNAME(_ context.Context, zoneID, hostname, tunnelID string) error { + if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" { + return &routeTestError{} + } + f.ensured = true + return f.ensureErr +} + +func (f *fakeCloudflareRouteAPI) DeleteCNAME(_ context.Context, zoneID, hostname, tunnelID string) error { + if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" { + return &routeTestError{} + } + f.deleted = true + f.deleteCalls++ + return nil +} + +type routeTestError struct{} + +func (*routeTestError) Error() string { return "unexpected route request" } + +func TestCloudflareTunnelRouteCommandsUpdateEncryptedStateAndDNS(t *testing.T) { + originalLoad := loadCloudflareTunnelConfig + originalRead := readCloudflareTunnelSecrets + originalWrite := writeCloudflareTunnelSecrets + originalClient := newCloudflareRouteClient + originalInitialize := initializeCloudflareTunnelOpenBao + originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID + t.Cleanup(func() { + loadCloudflareTunnelConfig = originalLoad + readCloudflareTunnelSecrets = originalRead + writeCloudflareTunnelSecrets = originalWrite + newCloudflareRouteClient = originalClient + initializeCloudflareTunnelOpenBao = originalInitialize + cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID + }) + values, err := (cloudflare.StoredTunnel{ + Credentials: cloudflare.Credentials{AccountTag: "account-id", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel-id"}, + Config: cloudflare.NewConfig("tunnel-id"), + }).Values() + if err != nil { + t.Fatal(err) + } + state := map[string]map[string]string{ + "platform/cloudflare": {"api-token": "test-api-token"}, + "platform/cloudflare-tunnel": values, + } + client := &fakeCloudflareRouteAPI{} + loadCloudflareTunnelConfig = func(string) (config.Config, error) { + return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil + } + readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil } + writes := 0 + writeCloudflareTunnelSecrets = func(_ string, _ string, secrets map[string]map[string]string) error { + if writes == 0 && !client.ensured { + t.Fatal("route add saved config before ensuring the CNAME") + } + if writes == 1 && client.deleted { + t.Fatal("route remove deleted the CNAME before saving config") + } + state = secrets + writes++ + return nil + } + newCloudflareRouteClient = func(token string) cloudflare.API { + if token != "test-api-token" { + t.Fatal("Cloudflare route command leaked the token beyond the API boundary") + } + return client + } + seeds := 0 + var seedErr error + initializeCloudflareTunnelOpenBao = func(config.Config) error { + stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]) + wantIngress := 2 + if seeds%2 == 1 { + wantIngress = 1 + } + if err != nil || !present || len(stored.Config.Ingress) != wantIngress { + t.Fatal("OpenBao was not seeded with the saved tunnel config") + } + seeds++ + return seedErr + } + cloudflareTunnelConfigPath = "bootstrap.yaml" + cloudflareTunnelHostname = "app.example.test" + cloudflareTunnelService = "http://service.default.svc:8080" + cloudflareTunnelZoneID = "zone-id" + if err := runCloudflareTunnelRoute(nil, true); err != nil { + t.Fatal(err) + } + stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]) + if err != nil || !present || len(stored.Config.Ingress) != 2 || !client.ensured || seeds != 1 { + t.Fatal("route add did not save config and ensure the CNAME") + } + if err := runCloudflareTunnelRoute(nil, false); err != nil { + t.Fatal(err) + } + stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]) + if err != nil || !present || len(stored.Config.Ingress) != 1 || !client.deleted || seeds != 2 { + t.Fatal("route remove did not save config and delete the CNAME") + } + client.ensureErr = errors.New("unavailable") + if err := runCloudflareTunnelRoute(nil, true); err == nil { + t.Fatal("route add accepted a CNAME failure") + } + stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]) + if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 2 || seeds != 2 { + t.Fatal("route add saved ingress config after a CNAME failure") + } + client.ensureErr = nil + seedErr = errors.New("unavailable") + if err := runCloudflareTunnelRoute(nil, true); err == nil || strings.Contains(err.Error(), "unavailable") { + t.Fatal("route add did not return a safe OpenBao seeding error") + } + stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]) + if err != nil || !present || len(stored.Config.Ingress) != 2 || writes != 3 || seeds != 3 { + t.Fatal("route add did not retain DNS-safe state before OpenBao seeding failed") + } + deletes := client.deleteCalls + if err := runCloudflareTunnelRoute(nil, false); err == nil || strings.Contains(err.Error(), "unavailable") { + t.Fatal("route remove did not return a safe OpenBao seeding error") + } + stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]) + if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 4 || seeds != 4 || client.deleteCalls != deletes { + t.Fatal("route remove deleted DNS before OpenBao seeding completed") + } +} + +func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) { + originalLoad := loadCloudflareTunnelConfig + originalRead := readCloudflareTunnelSecrets + originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID + t.Cleanup(func() { + loadCloudflareTunnelConfig = originalLoad + readCloudflareTunnelSecrets = originalRead + cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID + }) + loadCloudflareTunnelConfig = func(string) (config.Config, error) { return config.Config{}, nil } + readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { + return map[string]map[string]string{"platform/cloudflare": {"api-token": "test-api-token"}}, nil + } + cloudflareTunnelConfigPath = "bootstrap.yaml" + cloudflareTunnelHostname = "app.example.test" + cloudflareTunnelService = "http://service.default.svc:8080" + cloudflareTunnelZoneID = "zone-id" + err := runCloudflareTunnelRoute(nil, true) + if err == nil || strings.Contains(err.Error(), "test-api-token") { + t.Fatal("route command error revealed the Cloudflare token") + } +} diff --git a/docs/operations.md b/docs/operations.md index d177efd..376e939 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -102,8 +102,8 @@ provider Secret directly. ## Webhook TLS -The public Gateway terminates HTTPS with a cert-manager certificate. Its -Cloudflare DNS-01 token is used only to issue the `nid3.com` certificate; +The public Gateway terminates HTTPS with a cert-manager certificate. The +Cloudflare API token issues the `nid3.com` certificate and manages the tunnel; Pi-hole remains the ExternalDNS provider. Check certificate readiness with: ```powershell @@ -111,9 +111,23 @@ kubectl -n cert-manager get externalsecret cloudflare-api-token kubectl -n gateway-system get certificate webhook-tls ``` -Enter the Pi-hole values, Cloudflare DNS-01 token, and Tunnel token through -`--prompt-operational-secrets`, then run `--initialize-openbao`. Do not put the -Cloudflare token in the cluster repository. +Enter the Pi-hole values and Cloudflare API token through +`--prompt-operational-secrets`, then run `bootstrap --mode=reconcile` to create +the managed tunnel state. Do not put the Cloudflare token or tunnel credentials +in the cluster repository. + +## Cloudflare Tunnel Routes + +After reconcile has created the tunnel, manage one explicit proxied CNAME and +ingress rule at a time: + +```powershell +go run . cloudflare-tunnel route add --config --hostname --service --zone-id +go run . cloudflare-tunnel route remove --config --hostname --service --zone-id +``` + +Both commands require all three route values, preserve the terminal 404 rule, +and refuse an existing CNAME that does not point to the managed tunnel. ## Internal Platform UIs diff --git a/docs/secrets.md b/docs/secrets.md index df40045..dd91513 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -23,7 +23,8 @@ secrets: platform/cloudflare: api-token: encrypted-value platform/cloudflare-tunnel: - token: encrypted-value + credentials.json: encrypted-value + config.yml: encrypted-value ``` Keys are written to OpenBao KV v2 under `secret/`. Additional paths are @@ -38,9 +39,14 @@ For an existing configuration, run `bootstrap --config For a new operational-secret input, run `bootstrap --config --prompt-operational-secrets`. It derives Forgejo Git and registry credentials from the configured Forgejo account, prompts for the Pi-hole server and masked -password, masked Cloudflare DNS-01 and Tunnel tokens, and generates the webhook -authorization value. The DNS-01 token issues the Gateway certificate; it is not -used by ExternalDNS. +password and Cloudflare API token, and generates the webhook authorization +value. The Cloudflare token issues the Gateway certificate and creates the +CLI-managed tunnel; it is not used by ExternalDNS. + +`bootstrap` creates a named Cloudflare Tunnel only when +`platform/cloudflare-tunnel` has no generated state. It stores only the +credentials JSON and local config YAML shown above; it never saves a Tunnel run +token. The Cloudflare API token must have access to exactly one account. `cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The CLI supplies it as the Forgejo webhook Authorization header and Tekton compares diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 72b3093..dec1cb0 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/Pingu-Studio/MaidnCLI/internal/cloudflare" "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github" @@ -51,6 +52,12 @@ var decryptGeneratedSOPS = decryptSOPSFile var writeGeneratedSOPS = writeSOPSEncryptedFile +var readCloudflareOperationalSecrets = ReadOperationalSecrets + +var writeCloudflareOperationalSecrets = WriteOperationalSecrets + +var newCloudflareClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) } + var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorization string) error { manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, cfg.Flux.ManifestsRepo, cfg.Flux.RepoName, cfg.Flux.Branch, "maidn/bootstrap-"+cfg.ClusterID) return manager.EnsureWebhook(repo, webhookURL, authorization) @@ -193,6 +200,9 @@ func (r Runner) Run() error { if manager.MigrationPending { return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes") } + if err := r.reconcileCloudflareTunnel(context.Background()); err != nil { + return err + } repoDir := filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName) @@ -248,6 +258,59 @@ func (r Runner) Run() error { return nil } +func (r Runner) reconcileCloudflareTunnel(ctx context.Context) error { + secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath) + if err != nil { + return fmt.Errorf("read encrypted Cloudflare operational state: %w", err) + } + tunnelState := secrets["platform/cloudflare-tunnel"] + legacyRunToken := cloudflare.IsLegacyRunTokenState(tunnelState) + _, present, err := cloudflare.ParseStoredTunnel(tunnelState) + if err != nil && !legacyRunToken { + return err + } + if present { + return nil + } + token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"]) + if token == "" { + return errors.New("encrypted operational secrets requires platform/cloudflare.api-token") + } + client := newCloudflareClient(token) + accounts, err := client.ListAccounts(ctx) + if err != nil { + return fmt.Errorf("list Cloudflare accounts: %w", err) + } + if len(accounts) != 1 || accounts[0].ID == "" { + return errors.New("Cloudflare API token must access exactly one account; narrow its account access and rerun bootstrap") + } + name := cloudflare.TunnelName(r.Config.ClusterID) + if tunnels, err := client.ListTunnels(ctx, accounts[0].ID, name); err != nil { + return fmt.Errorf("check existing Cloudflare tunnel: %w", err) + } else if len(tunnels) != 0 { + return errors.New("a Cloudflare tunnel with the managed name already exists without encrypted operational state; remove it or restore the matching state before rerunning bootstrap") + } + tunnel, err := client.CreateTunnel(ctx, accounts[0].ID, name) + if err != nil { + return fmt.Errorf("create Cloudflare tunnel: %w", err) + } + if tunnel.ID == "" || tunnel.TunnelSecret == "" { + return errors.New("Cloudflare returned incomplete tunnel credentials") + } + values, err := (cloudflare.StoredTunnel{ + Credentials: cloudflare.Credentials{AccountTag: accounts[0].ID, TunnelSecret: tunnel.TunnelSecret, TunnelID: tunnel.ID}, + Config: cloudflare.NewConfig(tunnel.ID), + }).Values() + if err != nil { + return err + } + secrets["platform/cloudflare-tunnel"] = values + if err := writeCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath, secrets); err != nil { + return errors.New("Cloudflare tunnel was created but its encrypted operational state could not be saved; remove the new managed tunnel in Cloudflare before retrying") + } + return nil +} + func (r Runner) reconcileWebhook(generatedDir string) error { operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath) if err != nil { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 82a258b..98b26f5 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -1,6 +1,7 @@ package bootstrap import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -14,6 +15,7 @@ import ( "time" "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/cloudflare" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/openbao" "gopkg.in/yaml.v3" @@ -792,3 +794,117 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) { t.Fatalf("Terraform plan path is not absolute: %q, %v", path, err) } } + +type fakeCloudflareAPI struct { + accounts []cloudflare.Account + tunnels []cloudflare.Tunnel + created cloudflare.Tunnel + err error + creates int +} + +func (f *fakeCloudflareAPI) ListAccounts(context.Context) ([]cloudflare.Account, error) { + return f.accounts, f.err +} + +func (f *fakeCloudflareAPI) ListTunnels(context.Context, string, string) ([]cloudflare.Tunnel, error) { + return f.tunnels, f.err +} + +func (f *fakeCloudflareAPI) CreateTunnel(context.Context, string, string) (cloudflare.Tunnel, error) { + f.creates++ + return f.created, f.err +} + +func (*fakeCloudflareAPI) EnsureCNAME(context.Context, string, string, string) error { return nil } + +func (*fakeCloudflareAPI) DeleteCNAME(context.Context, string, string, string) error { return nil } + +func TestReconcileCloudflareTunnelCreatesEncryptedStateOnce(t *testing.T) { + originalRead := readCloudflareOperationalSecrets + originalWrite := writeCloudflareOperationalSecrets + originalClient := newCloudflareClient + t.Cleanup(func() { + readCloudflareOperationalSecrets = originalRead + writeCloudflareOperationalSecrets = originalWrite + newCloudflareClient = originalClient + }) + state := map[string]map[string]string{ + "platform/cloudflare": {"api-token": "test-api-token"}, + "platform/cloudflare-tunnel": {"token": "legacy-run-token"}, + "platform/pihole": {"password": "preserved"}, + } + readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil } + var saved map[string]map[string]string + writeCloudflareOperationalSecrets = func(_, _ string, values map[string]map[string]string) error { + saved = values + return nil + } + client := &fakeCloudflareAPI{accounts: []cloudflare.Account{{ID: "account-id"}}, created: cloudflare.Tunnel{ID: "tunnel-id", TunnelSecret: "test-tunnel-secret"}} + newCloudflareClient = func(token string) cloudflare.API { + if token != "test-api-token" { + t.Fatal("Cloudflare API token did not stay at the client boundary") + } + return client + } + runner := Runner{Config: config.Config{ClusterID: "demo", SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}} + if err := runner.reconcileCloudflareTunnel(context.Background()); err != nil { + t.Fatal(err) + } + stored, present, err := cloudflare.ParseStoredTunnel(saved["platform/cloudflare-tunnel"]) + if err != nil || !present || stored.Credentials.AccountTag != "account-id" || len(stored.Config.Ingress) != 1 || len(saved["platform/cloudflare-tunnel"]) != 2 || saved["platform/pihole"]["password"] != "preserved" { + t.Fatal("Cloudflare tunnel state was not saved with the expected contract") + } + if client.creates != 1 { + t.Fatal("Cloudflare tunnel was not created once") + } + state = saved + if err := runner.reconcileCloudflareTunnel(context.Background()); err != nil { + t.Fatal(err) + } + if client.creates != 1 { + t.Fatal("existing Cloudflare tunnel state was created again") + } +} + +func TestReconcileCloudflareTunnelRejectsAmbiguousStateWithoutAPICall(t *testing.T) { + originalRead := readCloudflareOperationalSecrets + originalClient := newCloudflareClient + t.Cleanup(func() { + readCloudflareOperationalSecrets = originalRead + newCloudflareClient = originalClient + }) + readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { + return map[string]map[string]string{ + "platform/cloudflare": {"api-token": "test-api-token"}, + "platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"}, + }, nil + } + called := false + newCloudflareClient = func(string) cloudflare.API { + called = true + return &fakeCloudflareAPI{} + } + err := (Runner{Config: config.Config{ClusterID: "demo"}}).reconcileCloudflareTunnel(context.Background()) + if err == nil || called { + t.Fatal("ambiguous Cloudflare tunnel state was not rejected before API access") + } +} + +func TestReconcileCloudflareTunnelRequiresOneAccessibleAccount(t *testing.T) { + originalRead := readCloudflareOperationalSecrets + originalClient := newCloudflareClient + t.Cleanup(func() { + readCloudflareOperationalSecrets = originalRead + newCloudflareClient = originalClient + }) + readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { + return map[string]map[string]string{"platform/cloudflare": {"api-token": "test-api-token"}}, nil + } + client := &fakeCloudflareAPI{accounts: []cloudflare.Account{{ID: "first"}, {ID: "second"}}} + newCloudflareClient = func(string) cloudflare.API { return client } + err := (Runner{Config: config.Config{ClusterID: "demo"}}).reconcileCloudflareTunnel(context.Background()) + if err == nil || !strings.Contains(err.Error(), "exactly one account") || strings.Contains(err.Error(), "test-api-token") || client.creates != 0 { + t.Fatal("multiple Cloudflare accounts were not rejected safely before tunnel creation") + } +} diff --git a/internal/cloudflare/client.go b/internal/cloudflare/client.go new file mode 100644 index 0000000..e1738b2 --- /dev/null +++ b/internal/cloudflare/client.go @@ -0,0 +1,160 @@ +package cloudflare + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const apiURL = "https://api.cloudflare.com/client/v4" + +// API is the narrow Cloudflare boundary used by the bootstrap and route commands. +type API interface { + ListAccounts(context.Context) ([]Account, error) + ListTunnels(context.Context, string, string) ([]Tunnel, error) + CreateTunnel(context.Context, string, string) (Tunnel, error) + EnsureCNAME(context.Context, string, string, string) error + DeleteCNAME(context.Context, string, string, string) error +} + +type Account struct { + ID string `json:"id"` +} + +type Tunnel struct { + ID string `json:"id"` + TunnelSecret string `json:"tunnel_secret"` +} + +type dnsRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Proxied bool `json:"proxied"` +} + +type Client struct { + baseURL string + httpClient *http.Client + token string +} + +func NewClient(token string) *Client { + return &Client{baseURL: apiURL, httpClient: &http.Client{Timeout: 15 * time.Second}, token: token} +} + +func (c *Client) ListAccounts(ctx context.Context) ([]Account, error) { + var accounts []Account + err := c.request(ctx, http.MethodGet, "/accounts", nil, &accounts) + return accounts, err +} + +func (c *Client) ListTunnels(ctx context.Context, accountID, name string) ([]Tunnel, error) { + var tunnels []Tunnel + path := "/accounts/" + url.PathEscape(accountID) + "/cfd_tunnel?" + url.Values{"name": {name}}.Encode() + err := c.request(ctx, http.MethodGet, path, nil, &tunnels) + return tunnels, err +} + +func (c *Client) CreateTunnel(ctx context.Context, accountID, name string) (Tunnel, error) { + var tunnel Tunnel + body, err := json.Marshal(struct { + Name string `json:"name"` + ConfigSrc string `json:"config_src"` + }{Name: name, ConfigSrc: "local"}) + if err != nil { + return tunnel, errors.New("encode Cloudflare tunnel request") + } + err = c.request(ctx, http.MethodPost, "/accounts/"+url.PathEscape(accountID)+"/cfd_tunnel", body, &tunnel) + return tunnel, err +} + +func (c *Client) EnsureCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error { + records, err := c.cnameRecords(ctx, zoneID, hostname) + if err != nil { + return err + } + target := tunnelTarget(tunnelID) + if len(records) == 0 { + body, err := json.Marshal(struct { + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Proxied bool `json:"proxied"` + }{Type: "CNAME", Name: hostname, Content: target, Proxied: true}) + if err != nil { + return errors.New("encode Cloudflare DNS record") + } + var created dnsRecord + return c.request(ctx, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/dns_records", body, &created) + } + if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied { + return errors.New("existing Cloudflare DNS record does not match the managed tunnel route") + } + return nil +} + +func (c *Client) DeleteCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error { + records, err := c.cnameRecords(ctx, zoneID, hostname) + if err != nil { + return err + } + if len(records) == 0 { + return nil + } + target := tunnelTarget(tunnelID) + if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied { + return errors.New("existing Cloudflare DNS record does not match the managed tunnel route") + } + var deleted dnsRecord + return c.request(ctx, http.MethodDelete, "/zones/"+url.PathEscape(zoneID)+"/dns_records/"+url.PathEscape(records[0].ID), nil, &deleted) +} + +func (c *Client) cnameRecords(ctx context.Context, zoneID, hostname string) ([]dnsRecord, error) { + var records []dnsRecord + path := "/zones/" + url.PathEscape(zoneID) + "/dns_records?" + url.Values{"type": {"CNAME"}, "name": {hostname}}.Encode() + err := c.request(ctx, http.MethodGet, path, nil, &records) + return records, err +} + +func (c *Client) request(ctx context.Context, method, path string, body []byte, result any) error { + request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return errors.New("create Cloudflare API request") + } + request.Header.Set("Authorization", "Bearer "+c.token) + request.Header.Set("Content-Type", "application/json") + client := c.httpClient + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + response, err := client.Do(request) + if err != nil { + return errors.New("call Cloudflare API") + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("Cloudflare API returned HTTP %d", response.StatusCode) + } + var envelope struct { + Success bool `json:"success"` + Result json.RawMessage `json:"result"` + } + if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil || !envelope.Success { + return errors.New("Cloudflare API returned an invalid response") + } + if err := json.Unmarshal(envelope.Result, result); err != nil { + return errors.New("Cloudflare API returned an invalid result") + } + return nil +} + +func tunnelTarget(tunnelID string) string { + return tunnelID + ".cfargotunnel.com" +} diff --git a/internal/cloudflare/client_test.go b/internal/cloudflare/client_test.go new file mode 100644 index 0000000..1c3e8a6 --- /dev/null +++ b/internal/cloudflare/client_test.go @@ -0,0 +1,118 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestClientTunnelAndDNSLifecycle(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + if request.Header.Get("Authorization") != "Bearer test-api-token" { + t.Error("Cloudflare request did not use the API token header") + writer.WriteHeader(http.StatusUnauthorized) + return + } + switch request.Method + " " + request.URL.Path { + case "GET /accounts": + _ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []map[string]string{{"id": "account-id"}}}) + case "GET /accounts/account-id/cfd_tunnel": + if request.URL.Query().Get("name") != "maidn-demo" { + t.Error("tunnel lookup used the wrong name") + } + _ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []any{}}) + case "POST /accounts/account-id/cfd_tunnel": + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body["name"] != "maidn-demo" || body["config_src"] != "local" { + t.Error("tunnel create request was invalid") + } + _ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "tunnel-id", "tunnel_secret": "test-tunnel-secret"}}) + case "GET /zones/zone-id/dns_records": + if request.URL.Query().Get("type") != "CNAME" || request.URL.Query().Get("name") != "app.example.test" { + t.Error("DNS lookup was invalid") + } + _ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []any{}}) + case "POST /zones/zone-id/dns_records": + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body["content"] != "tunnel-id.cfargotunnel.com" || body["proxied"] != true { + t.Error("DNS create request was invalid") + } + _ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}}) + default: + t.Errorf("unexpected request %s %s", request.Method, request.URL.Path) + writer.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + client := NewClient("test-api-token") + client.baseURL = server.URL + client.httpClient = server.Client() + accounts, err := client.ListAccounts(context.Background()) + if err != nil || len(accounts) != 1 || accounts[0].ID != "account-id" { + t.Fatal("account lookup failed") + } + tunnels, err := client.ListTunnels(context.Background(), accounts[0].ID, "maidn-demo") + if err != nil || len(tunnels) != 0 { + t.Fatal("tunnel lookup failed") + } + tunnel, err := client.CreateTunnel(context.Background(), accounts[0].ID, "maidn-demo") + if err != nil || tunnel.ID != "tunnel-id" || tunnel.TunnelSecret != "test-tunnel-secret" { + t.Fatal("tunnel create failed") + } + if err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", tunnel.ID); err != nil { + t.Fatal(err) + } + if requests != 5 { + t.Fatalf("expected five Cloudflare requests, got %d", requests) + } +} + +func TestClientFailureDoesNotRevealToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := NewClient("test-api-token") + client.baseURL = server.URL + client.httpClient = server.Client() + _, err := client.ListAccounts(context.Background()) + if err == nil || strings.Contains(err.Error(), "test-api-token") { + t.Fatal("Cloudflare API failure exposed the token") + } +} + +func TestClientDeletesOnlyMatchingTunnelCNAME(t *testing.T) { + deleted := false + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer test-api-token" { + t.Error("Cloudflare request did not use the API token header") + writer.WriteHeader(http.StatusUnauthorized) + return + } + switch request.Method + " " + request.URL.Path { + case "GET /zones/zone-id/dns_records": + _ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []map[string]any{{"id": "record-id", "name": "app.example.test", "content": "tunnel-id.cfargotunnel.com", "proxied": true}}}) + case "DELETE /zones/zone-id/dns_records/record-id": + deleted = true + _ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}}) + default: + t.Errorf("unexpected request %s %s", request.Method, request.URL.Path) + writer.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + client := NewClient("test-api-token") + client.baseURL = server.URL + client.httpClient = server.Client() + if err := client.DeleteCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil || !deleted { + t.Fatal("matching managed CNAME was not deleted") + } +} diff --git a/internal/cloudflare/tunnel.go b/internal/cloudflare/tunnel.go new file mode 100644 index 0000000..9908792 --- /dev/null +++ b/internal/cloudflare/tunnel.go @@ -0,0 +1,158 @@ +package cloudflare + +import ( + "encoding/json" + "errors" + "io" + "net/url" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + credentialsKey = "credentials.json" + configKey = "config.yml" + credentialsFile = "/etc/cloudflared/credentials.json" +) + +var hostnamePattern = regexp.MustCompile(`(?i)^(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) + +type Credentials struct { + AccountTag string `json:"AccountTag"` + TunnelSecret string `json:"TunnelSecret"` + TunnelID string `json:"TunnelID"` +} + +type Route struct { + Hostname string `yaml:"hostname,omitempty"` + Service string `yaml:"service"` +} + +type Config struct { + Tunnel string `yaml:"tunnel"` + CredentialsFile string `yaml:"credentials-file"` + Ingress []Route `yaml:"ingress"` +} + +type StoredTunnel struct { + Credentials Credentials + Config Config +} + +func TunnelName(clusterID string) string { + return "maidn-" + clusterID +} + +func NewConfig(tunnelID string) Config { + return Config{Tunnel: tunnelID, CredentialsFile: credentialsFile, Ingress: []Route{{Service: "http_status:404"}}} +} + +func IsLegacyRunTokenState(values map[string]string) bool { + return len(values) == 1 && strings.TrimSpace(values["token"]) != "" +} + +func NewRoute(hostname, service string) (Route, error) { + hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), ".")) + if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) { + return Route{}, errors.New("hostname must be a valid public DNS hostname") + } + parsed, err := url.ParseRequestURI(service) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return Route{}, errors.New("service must be an absolute http or https URL") + } + return Route{Hostname: hostname, Service: service}, nil +} + +func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) { + credentialsJSON, hasCredentials := values[credentialsKey] + configYAML, hasConfig := values[configKey] + if !hasCredentials && !hasConfig { + if len(values) == 0 { + return StoredTunnel{}, false, nil + } + return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials.json and config.yml") + } + if !hasCredentials || !hasConfig || len(values) != 2 { + return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml") + } + var credentials Credentials + decoder := json.NewDecoder(strings.NewReader(credentialsJSON)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" { + return StoredTunnel{}, false, errors.New("Cloudflare tunnel credentials are invalid") + } + var config Config + yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML)) + yamlDecoder.KnownFields(true) + if err := yamlDecoder.Decode(&config); err != nil || yamlDecoder.Decode(&struct{}{}) != io.EOF || !validConfig(config) || config.Tunnel != credentials.TunnelID { + return StoredTunnel{}, false, errors.New("Cloudflare tunnel config is invalid") + } + return StoredTunnel{Credentials: credentials, Config: config}, true, nil +} + +func (s StoredTunnel) Values() (map[string]string, error) { + credentials, err := json.Marshal(s.Credentials) + if err != nil { + return nil, errors.New("encode Cloudflare tunnel credentials") + } + config, err := yaml.Marshal(s.Config) + if err != nil { + return nil, errors.New("encode Cloudflare tunnel config") + } + return map[string]string{credentialsKey: string(credentials), configKey: string(config)}, nil +} + +func (c *Config) AddRoute(route Route) (bool, error) { + if !validConfig(*c) { + return false, errors.New("Cloudflare tunnel config is invalid") + } + for _, existing := range c.Ingress[:len(c.Ingress)-1] { + if existing.Hostname == route.Hostname { + if existing.Service == route.Service { + return false, nil + } + return false, errors.New("hostname already has a different Cloudflare tunnel route") + } + } + terminal := c.Ingress[len(c.Ingress)-1] + c.Ingress = append(c.Ingress[:len(c.Ingress)-1], route, terminal) + return true, nil +} + +func (c *Config) RemoveRoute(route Route) (bool, error) { + if !validConfig(*c) { + return false, errors.New("Cloudflare tunnel config is invalid") + } + for index, existing := range c.Ingress[:len(c.Ingress)-1] { + if existing.Hostname != route.Hostname { + continue + } + if existing.Service != route.Service { + return false, errors.New("hostname does not match the requested Cloudflare tunnel service") + } + c.Ingress = append(c.Ingress[:index], c.Ingress[index+1:]...) + return true, nil + } + return false, nil +} + +func validConfig(config Config) bool { + if config.Tunnel == "" || config.CredentialsFile != credentialsFile || len(config.Ingress) == 0 { + return false + } + last := len(config.Ingress) - 1 + if config.Ingress[last].Hostname != "" || config.Ingress[last].Service != "http_status:404" { + return false + } + seen := map[string]bool{} + for _, route := range config.Ingress[:last] { + normalized, err := NewRoute(route.Hostname, route.Service) + if err != nil || normalized != route || seen[route.Hostname] { + return false + } + seen[route.Hostname] = true + } + return true +} diff --git a/internal/cloudflare/tunnel_test.go b/internal/cloudflare/tunnel_test.go new file mode 100644 index 0000000..7110f7f --- /dev/null +++ b/internal/cloudflare/tunnel_test.go @@ -0,0 +1,50 @@ +package cloudflare + +import "testing" + +func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) { + stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")} + values, err := stored.Values() + if err != nil { + t.Fatal(err) + } + parsed, present, err := ParseStoredTunnel(values) + if err != nil || !present || len(parsed.Config.Ingress) != 1 || parsed.Config.Ingress[0].Service != "http_status:404" { + t.Fatal("new tunnel state must contain only the terminal ingress rule") + } +} + +func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) { + for _, values := range []map[string]string{ + {"token": "legacy-run-token"}, + {"credentials.json": "{}"}, + } { + if _, _, err := ParseStoredTunnel(values); err == nil { + t.Fatal("ambiguous tunnel state was accepted") + } + } +} + +func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) { + config := NewConfig("tunnel") + route, err := NewRoute("app.example.test", "http://service.default.svc:8080") + if err != nil { + t.Fatal(err) + } + changed, err := config.AddRoute(route) + if err != nil || !changed || len(config.Ingress) != 2 { + t.Fatal("route was not added before the terminal ingress") + } + changed, err = config.AddRoute(route) + if err != nil || changed { + t.Fatal("matching route add was not idempotent") + } + changed, err = config.RemoveRoute(route) + if err != nil || !changed || len(config.Ingress) != 1 || config.Ingress[0].Service != "http_status:404" { + t.Fatal("route removal did not preserve the terminal ingress") + } + changed, err = config.RemoveRoute(route) + if err != nil || changed { + t.Fatal("matching route remove was not idempotent") + } +} diff --git a/internal/ui/wizard.go b/internal/ui/wizard.go index a4a4ce2..44d16c5 100644 --- a/internal/ui/wizard.go +++ b/internal/ui/wizard.go @@ -132,10 +132,9 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string, reader := bufio.NewReader(os.Stdin) piholeServer := prompt(reader, "Pi-hole server", "") piholePassword := promptSecret(reader, "Pi-hole password", "") - cloudflareAPIToken := promptSecret(reader, "Cloudflare DNS-01 API token", "") - cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "") - if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" || cloudflareTunnelToken == "" { - return nil, errors.New("Pi-hole server, password, Cloudflare DNS-01 API token, and Cloudflare Tunnel token are required") + cloudflareAPIToken := promptSecret(reader, "Cloudflare API token", "") + if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" { + return nil, errors.New("Pi-hole server, password, and Cloudflare API token are required") } registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0] dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{ @@ -151,7 +150,6 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string, "cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)}, "platform/pihole": {"server": piholeServer, "password": piholePassword}, "platform/cloudflare": {"api-token": cloudflareAPIToken}, - "platform/cloudflare-tunnel": {"token": cloudflareTunnelToken}, }, nil }