From f17097ecae6579014cd7b1fe228d983d8e274e79 Mon Sep 17 00:00:00 2001 From: eding Date: Tue, 4 Aug 2026 00:51:03 +0200 Subject: [PATCH 1/2] feat: import Cloudflare tunnel credentials --- cmd/cloudflare_tunnel.go | 57 +++++++++++- cmd/cloudflare_tunnel_test.go | 132 +++++++++++++++++++++++--- docs/operations.md | 20 ++-- docs/secrets.md | 18 ++-- internal/bootstrap/bootstrap.go | 51 +--------- internal/bootstrap/bootstrap_test.go | 134 +++++++-------------------- internal/cloudflare/client.go | 40 +------- internal/cloudflare/client_test.go | 35 +------ internal/cloudflare/tunnel.go | 31 +++++-- internal/cloudflare/tunnel_test.go | 23 ++++- 10 files changed, 287 insertions(+), 254 deletions(-) diff --git a/cmd/cloudflare_tunnel.go b/cmd/cloudflare_tunnel.go index 8b04eab..a953a66 100644 --- a/cmd/cloudflare_tunnel.go +++ b/cmd/cloudflare_tunnel.go @@ -16,12 +16,14 @@ var cloudflareTunnelConfigPath string var cloudflareTunnelHostname string var cloudflareTunnelService string var cloudflareTunnelZoneID string +var cloudflareTunnelCredentialsFile 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 readCloudflareTunnelCredentials = cloudflare.ReadCredentialsFile var cloudflareTunnelCmd = &cobra.Command{ Use: "cloudflare-tunnel", @@ -46,9 +48,17 @@ var cloudflareTunnelRouteRemoveCmd = &cobra.Command{ }, } +var cloudflareTunnelImportCmd = &cobra.Command{ + Use: "import", + Short: "Import local Cloudflare Tunnel credentials into encrypted operational state.", + RunE: func(_ *cobra.Command, _ []string) error { + return runCloudflareTunnelImport() + }, +} + func init() { rootCmd.AddCommand(cloudflareTunnelCmd) - cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd) + cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd, cloudflareTunnelImportCmd) cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd) cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML") for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} { @@ -59,6 +69,49 @@ func init() { _ = command.MarkFlagRequired("service") _ = command.MarkFlagRequired("zone-id") } + cloudflareTunnelImportCmd.Flags().StringVar(&cloudflareTunnelCredentialsFile, "credentials-file", "", "Path to local Cloudflare Tunnel credentials JSON") + _ = cloudflareTunnelImportCmd.MarkFlagRequired("credentials-file") +} + +func runCloudflareTunnelImport() error { + if cloudflareTunnelConfigPath == "" { + return errors.New("--config is required") + } + if cloudflareTunnelCredentialsFile == "" { + return errors.New("--credentials-file is required") + } + 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) + } + state := secrets["platform/cloudflare-tunnel"] + _, present, stateErr := cloudflare.ParseStoredTunnel(state) + if present { + return errors.New("Cloudflare tunnel state is already managed; refusing to overwrite it") + } + if stateErr != nil && !cloudflare.IsLegacyRunTokenState(state) { + return stateErr + } + credentials, err := readCloudflareTunnelCredentials(cloudflareTunnelCredentialsFile) + if err != nil { + return err + } + values, err := (cloudflare.StoredTunnel{Credentials: credentials, Config: cloudflare.NewConfig(credentials.TunnelID)}).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 credentials") + } + if err := initializeCloudflareTunnelOpenBao(cfg); err != nil { + return errors.New("Cloudflare tunnel credentials were saved but OpenBao could not be seeded; rerun bootstrap --config --initialize-openbao") + } + return nil } func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error { @@ -89,7 +142,7 @@ func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error { return err } if !present { - return errors.New("Cloudflare tunnel credentials and config are not generated; run bootstrap reconcile first") + return errors.New("Cloudflare tunnel credentials and config are not generated; run cloudflare-tunnel import with a local credentials file") } ctx := context.Background() if cmd != nil && cmd.Context() != nil { diff --git a/cmd/cloudflare_tunnel_test.go b/cmd/cloudflare_tunnel_test.go index 5466a4d..769d34b 100644 --- a/cmd/cloudflare_tunnel_test.go +++ b/cmd/cloudflare_tunnel_test.go @@ -17,18 +17,6 @@ type fakeCloudflareRouteAPI struct { 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{} @@ -180,3 +168,123 @@ func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) { t.Fatal("route command error revealed the Cloudflare token") } } + +func TestCloudflareTunnelImportAcceptsAbsentOrLegacyState(t *testing.T) { + originalLoad := loadCloudflareTunnelConfig + originalRead := readCloudflareTunnelSecrets + originalWrite := writeCloudflareTunnelSecrets + originalInitialize := initializeCloudflareTunnelOpenBao + originalCredentials := readCloudflareTunnelCredentials + originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile + t.Cleanup(func() { + loadCloudflareTunnelConfig = originalLoad + readCloudflareTunnelSecrets = originalRead + writeCloudflareTunnelSecrets = originalWrite + initializeCloudflareTunnelOpenBao = originalInitialize + readCloudflareTunnelCredentials = originalCredentials + cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile + }) + loadCloudflareTunnelConfig = func(string) (config.Config, error) { + return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil + } + cloudflareTunnelConfigPath = "bootstrap.yaml" + cloudflareTunnelCredentialsFile = "local-credentials.json" + for _, state := range []map[string]map[string]string{ + {}, + {"platform/cloudflare-tunnel": {"token": "legacy-run-token"}}, + } { + readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil } + readCloudflareTunnelCredentials = func(path string) (cloudflare.Credentials, error) { + if path != cloudflareTunnelCredentialsFile { + t.Fatal("credentials were not read from the explicit file path") + } + return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil + } + written := false + writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error { + state = values + written = true + return nil + } + initializeCloudflareTunnelOpenBao = func(config.Config) error { + stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]) + if !written || err != nil || !present || len(stored.Config.Ingress) != 1 { + t.Fatal("OpenBao was not seeded after encrypted tunnel state was saved") + } + return nil + } + if err := runCloudflareTunnelImport(); err != nil { + t.Fatal(err) + } + if _, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); err != nil || !present || len(state["platform/cloudflare-tunnel"]) != 2 { + t.Fatal("credentials file was not imported as managed tunnel state") + } + } +} + +func TestCloudflareTunnelImportRefusesManagedStateAndReturnsSafeSeedFailure(t *testing.T) { + originalLoad := loadCloudflareTunnelConfig + originalRead := readCloudflareTunnelSecrets + originalWrite := writeCloudflareTunnelSecrets + originalInitialize := initializeCloudflareTunnelOpenBao + originalCredentials := readCloudflareTunnelCredentials + originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile + t.Cleanup(func() { + loadCloudflareTunnelConfig = originalLoad + readCloudflareTunnelSecrets = originalRead + writeCloudflareTunnelSecrets = originalWrite + initializeCloudflareTunnelOpenBao = originalInitialize + readCloudflareTunnelCredentials = originalCredentials + cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile + }) + loadCloudflareTunnelConfig = func(string) (config.Config, error) { + return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil + } + cloudflareTunnelConfigPath = "bootstrap.yaml" + cloudflareTunnelCredentialsFile = "local-credentials.json" + managed, err := (cloudflare.StoredTunnel{Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, Config: cloudflare.NewConfig("tunnel")}).Values() + if err != nil { + t.Fatal(err) + } + state := map[string]map[string]string{"platform/cloudflare-tunnel": managed} + readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil } + readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) { + t.Fatal("managed state import read the credential file") + return cloudflare.Credentials{}, nil + } + writeCloudflareTunnelSecrets = func(string, string, map[string]map[string]string) error { + t.Fatal("managed state import overwrote encrypted state") + return nil + } + initializeCloudflareTunnelOpenBao = func(config.Config) error { + t.Fatal("managed state import seeded OpenBao") + return nil + } + if err := runCloudflareTunnelImport(); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { + t.Fatal("managed state import was not refused") + } + + state = map[string]map[string]string{} + readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) { + return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil + } + written := false + writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error { + written = true + state = values + return nil + } + initializeCloudflareTunnelOpenBao = func(config.Config) error { + if !written { + t.Fatal("OpenBao seeding ran before encrypted state was saved") + } + return errors.New("unavailable") + } + err = runCloudflareTunnelImport() + if err == nil || strings.Contains(err.Error(), "unavailable") { + t.Fatal("import did not return a safe OpenBao seeding error") + } + if _, present, parseErr := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); parseErr != nil || !present { + t.Fatal("import did not retain encrypted state after OpenBao seeding failed") + } +} diff --git a/docs/operations.md b/docs/operations.md index 376e939..8eddc6e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -103,8 +103,9 @@ provider Secret directly. ## Webhook TLS 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: +Cloudflare API token issues the `nid3.com` certificate and manages explicit +Tunnel CNAME records. Pi-hole remains the ExternalDNS provider. Check +certificate readiness with: ```powershell kubectl -n cert-manager get externalsecret cloudflare-api-token @@ -112,14 +113,19 @@ kubectl -n gateway-system get certificate webhook-tls ``` 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. +`--prompt-operational-secrets`, then import the user-approved local credentials +file before bootstrap: + +```powershell +go run . cloudflare-tunnel import --config --credentials-file +``` + +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: +After importing the credentials, manage one explicit proxied CNAME and ingress +rule at a time: ```powershell go run . cloudflare-tunnel route add --config --hostname --service --zone-id diff --git a/docs/secrets.md b/docs/secrets.md index dd91513..d41eb1d 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -40,13 +40,19 @@ 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 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. +value. The Cloudflare token issues the Gateway certificate and manages explicit +Tunnel CNAME records; 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. +Import the user-approved local credential file before bootstrap validates the +tunnel state: + +```powershell +go run . cloudflare-tunnel import --config --credentials-file +``` + +The import command reads the file only in memory, validates its credential +shape, stores only the credentials JSON and terminal-404 local config shown +above, and seeds OpenBao. It never saves a Tunnel run token. `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 dec1cb0..6a8261b 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -54,10 +54,6 @@ 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) @@ -200,7 +196,7 @@ 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 { + if err := r.reconcileCloudflareTunnel(); err != nil { return err } @@ -258,57 +254,20 @@ func (r Runner) Run() error { return nil } -func (r Runner) reconcileCloudflareTunnel(ctx context.Context) error { +func (r Runner) reconcileCloudflareTunnel() 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") + if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) { + return errors.New("Cloudflare tunnel credentials and config are not generated; run cicd-tool cloudflare-tunnel import --config --credentials-file ") } - 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 + return err } func (r Runner) reconcileWebhook(generatedDir string) error { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 98b26f5..c665b6b 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -1,7 +1,6 @@ package bootstrap import ( - "context" "encoding/base64" "encoding/json" "errors" @@ -795,116 +794,47 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) { } } -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) { +func TestReconcileCloudflareTunnelValidatesManagedState(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"}, + t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead }) + values, err := (cloudflare.StoredTunnel{ + Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, + Config: cloudflare.NewConfig("tunnel"), + }).Values() + if err != nil { + t.Fatal(err) } - 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 + readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { + return map[string]map[string]string{"platform/cloudflare-tunnel": values}, 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") + if err := (Runner{Config: config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}}).reconcileCloudflareTunnel(); err != nil { + t.Fatal(err) + } +} + +func TestReconcileCloudflareTunnelRequiresImportForAbsentOrLegacyState(t *testing.T) { + originalRead := readCloudflareOperationalSecrets + t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead }) + for _, state := range []map[string]map[string]string{ + nil, + {"platform/cloudflare-tunnel": {"token": "legacy-run-token"}}, + } { + readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil } + err := (Runner{}).reconcileCloudflareTunnel() + if err == nil || !strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") { + t.Fatal("missing Cloudflare tunnel state did not return a safe import instruction") } - 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) { +func TestReconcileCloudflareTunnelRejectsMalformedState(t *testing.T) { originalRead := readCloudflareOperationalSecrets - originalClient := newCloudflareClient - t.Cleanup(func() { - readCloudflareOperationalSecrets = originalRead - newCloudflareClient = originalClient - }) + t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead }) 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 + return map[string]map[string]string{"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") + err := (Runner{}).reconcileCloudflareTunnel() + if err == nil || strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") { + t.Fatal("malformed Cloudflare tunnel state was not rejected safely") } } diff --git a/internal/cloudflare/client.go b/internal/cloudflare/client.go index e1738b2..46df090 100644 --- a/internal/cloudflare/client.go +++ b/internal/cloudflare/client.go @@ -14,24 +14,12 @@ import ( const apiURL = "https://api.cloudflare.com/client/v4" -// API is the narrow Cloudflare boundary used by the bootstrap and route commands. +// API is the narrow Cloudflare DNS boundary used by 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"` @@ -49,32 +37,6 @@ 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 { diff --git a/internal/cloudflare/client_test.go b/internal/cloudflare/client_test.go index 1c3e8a6..f3b7672 100644 --- a/internal/cloudflare/client_test.go +++ b/internal/cloudflare/client_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func TestClientTunnelAndDNSLifecycle(t *testing.T) { +func TestClientCreatesTunnelCNAME(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { requests++ @@ -19,19 +19,6 @@ func TestClientTunnelAndDNSLifecycle(t *testing.T) { 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") @@ -53,23 +40,11 @@ func TestClientTunnelAndDNSLifecycle(t *testing.T) { 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 { + 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) + if requests != 2 { + t.Fatalf("expected two Cloudflare requests, got %d", requests) } } @@ -82,7 +57,7 @@ func TestClientFailureDoesNotRevealToken(t *testing.T) { client := NewClient("test-api-token") client.baseURL = server.URL client.httpClient = server.Client() - _, err := client.ListAccounts(context.Background()) + err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id") if err == nil || strings.Contains(err.Error(), "test-api-token") { t.Fatal("Cloudflare API failure exposed the token") } diff --git a/internal/cloudflare/tunnel.go b/internal/cloudflare/tunnel.go index 9908792..e3926d0 100644 --- a/internal/cloudflare/tunnel.go +++ b/internal/cloudflare/tunnel.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/url" + "os" "regexp" "strings" @@ -41,10 +42,6 @@ type StoredTunnel struct { 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"}}} } @@ -53,6 +50,14 @@ func IsLegacyRunTokenState(values map[string]string) bool { return len(values) == 1 && strings.TrimSpace(values["token"]) != "" } +func ReadCredentialsFile(path string) (Credentials, error) { + contents, err := os.ReadFile(path) + if err != nil { + return Credentials{}, errors.New("read Cloudflare credentials file") + } + return parseCredentials(contents) +} + func NewRoute(hostname, service string) (Route, error) { hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), ".")) if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) { @@ -77,11 +82,9 @@ func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) { 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") + credentials, err := parseCredentials([]byte(credentialsJSON)) + if err != nil { + return StoredTunnel{}, false, err } var config Config yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML)) @@ -92,6 +95,16 @@ func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) { return StoredTunnel{Credentials: credentials, Config: config}, true, nil } +func parseCredentials(contents []byte) (Credentials, error) { + var credentials Credentials + decoder := json.NewDecoder(strings.NewReader(string(contents))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" { + return Credentials{}, errors.New("Cloudflare tunnel credentials are invalid") + } + return credentials, nil +} + func (s StoredTunnel) Values() (map[string]string, error) { credentials, err := json.Marshal(s.Credentials) if err != nil { diff --git a/internal/cloudflare/tunnel_test.go b/internal/cloudflare/tunnel_test.go index 7110f7f..2656762 100644 --- a/internal/cloudflare/tunnel_test.go +++ b/internal/cloudflare/tunnel_test.go @@ -1,6 +1,10 @@ package cloudflare -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) { stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")} @@ -25,6 +29,23 @@ func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) { } } +func TestReadCredentialsFileRequiresKnownFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + if err := os.WriteFile(path, []byte(`{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel"}`), 0600); err != nil { + t.Fatal(err) + } + credentials, err := ReadCredentialsFile(path) + if err != nil || credentials.TunnelID != "tunnel" { + t.Fatal("valid credential file was not accepted") + } + if err := os.WriteFile(path, []byte(`{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel","extra":"value"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadCredentialsFile(path); err == nil { + t.Fatal("credential file with unknown fields was accepted") + } +} + func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) { config := NewConfig("tunnel") route, err := NewRoute("app.example.test", "http://service.default.svc:8080") From a08eb8616d618363155837a0f9e2a9e30e4ea577 Mon Sep 17 00:00:00 2001 From: eding Date: Tue, 4 Aug 2026 20:48:18 +0200 Subject: [PATCH 2/2] fix: import Cloudflare Secret credentials --- internal/cloudflare/tunnel.go | 38 ++++++++++++++++++++++++++++-- internal/cloudflare/tunnel_test.go | 30 ++++++++++++++++------- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/internal/cloudflare/tunnel.go b/internal/cloudflare/tunnel.go index e3926d0..918e1f3 100644 --- a/internal/cloudflare/tunnel.go +++ b/internal/cloudflare/tunnel.go @@ -1,6 +1,7 @@ package cloudflare import ( + "encoding/base64" "encoding/json" "errors" "io" @@ -53,9 +54,42 @@ func IsLegacyRunTokenState(values map[string]string) bool { func ReadCredentialsFile(path string) (Credentials, error) { contents, err := os.ReadFile(path) if err != nil { - return Credentials{}, errors.New("read Cloudflare credentials file") + return Credentials{}, errors.New("invalid Cloudflare credentials file") } - return parseCredentials(contents) + if credentials, err := parseCredentials(contents); err == nil { + return credentials, nil + } + credentials, err := credentialsFromSecret(contents) + if err != nil { + return Credentials{}, errors.New("invalid Cloudflare credentials file") + } + return credentials, nil +} + +func credentialsFromSecret(contents []byte) (Credentials, error) { + var secret struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + StringData map[string]string `yaml:"stringData"` + Data map[string]string `yaml:"data"` + } + decoder := yaml.NewDecoder(strings.NewReader(string(contents))) + if err := decoder.Decode(&secret); err != nil || decoder.Decode(&struct{}{}) != io.EOF || secret.APIVersion != "v1" || secret.Kind != "Secret" { + return Credentials{}, errors.New("invalid Secret") + } + plaintext, inStringData := secret.StringData[credentialsKey] + encoded, inData := secret.Data[credentialsKey] + if inStringData == inData { + return Credentials{}, errors.New("missing Secret credentials") + } + if inStringData { + return parseCredentials([]byte(plaintext)) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return Credentials{}, errors.New("invalid Secret credentials") + } + return parseCredentials(decoded) } func NewRoute(hostname, service string) (Route, error) { diff --git a/internal/cloudflare/tunnel_test.go b/internal/cloudflare/tunnel_test.go index 2656762..59e299a 100644 --- a/internal/cloudflare/tunnel_test.go +++ b/internal/cloudflare/tunnel_test.go @@ -1,6 +1,7 @@ package cloudflare import ( + "encoding/base64" "os" "path/filepath" "testing" @@ -31,18 +32,31 @@ func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) { func TestReadCredentialsFileRequiresKnownFields(t *testing.T) { path := filepath.Join(t.TempDir(), "credentials.json") - if err := os.WriteFile(path, []byte(`{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel"}`), 0600); err != nil { - t.Fatal(err) - } - credentials, err := ReadCredentialsFile(path) - if err != nil || credentials.TunnelID != "tunnel" { - t.Fatal("valid credential file was not accepted") + raw := `{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel"}` + for _, contents := range []string{ + raw, + "apiVersion: v1\nkind: Secret\nstringData:\n credentials.json: |\n " + raw + "\n", + "apiVersion: v1\nkind: Secret\ndata:\n credentials.json: " + base64.StdEncoding.EncodeToString([]byte(raw)) + "\n", + } { + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + credentials, err := ReadCredentialsFile(path) + if err != nil || credentials.TunnelID != "tunnel" { + t.Fatal("supported credential file was not accepted") + } } if err := os.WriteFile(path, []byte(`{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel","extra":"value"}`), 0600); err != nil { t.Fatal(err) } - if _, err := ReadCredentialsFile(path); err == nil { - t.Fatal("credential file with unknown fields was accepted") + if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" { + t.Fatal("credential file with unknown fields did not return a safe error") + } + if err := os.WriteFile(path, []byte("apiVersion: v1\nkind: Secret\ndata: {}\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" { + t.Fatal("Secret without credentials did not return a safe error") } }