From 58478b50aed9a7072c9000554dd0caaafe836082 Mon Sep 17 00:00:00 2001 From: eding Date: Tue, 4 Aug 2026 22:06:37 +0200 Subject: [PATCH] fix: normalize Cloudflare tunnel properties --- docs/secrets.md | 8 +++-- internal/bootstrap/bootstrap.go | 15 ++++++++- internal/bootstrap/bootstrap_test.go | 37 +++++++++++++++++++++ internal/cloudflare/tunnel.go | 48 ++++++++++++++++++++-------- internal/cloudflare/tunnel_test.go | 8 +++++ 5 files changed, 98 insertions(+), 18 deletions(-) diff --git a/docs/secrets.md b/docs/secrets.md index d41eb1d..ad57fe6 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -22,9 +22,9 @@ secrets: password: encrypted-value platform/cloudflare: api-token: encrypted-value - platform/cloudflare-tunnel: - credentials.json: encrypted-value - config.yml: encrypted-value + platform/cloudflare-tunnel: + credentials: encrypted-value + config: encrypted-value ``` Keys are written to OpenBao KV v2 under `secret/`. Additional paths are @@ -53,6 +53,8 @@ go run . cloudflare-tunnel import --config --credenti 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. +The template maps these simple OpenBao properties to the `credentials.json` and +`config.yml` Kubernetes filenames. `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 6a8261b..05bd3e3 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -54,6 +54,8 @@ var writeGeneratedSOPS = writeSOPSEncryptedFile var readCloudflareOperationalSecrets = ReadOperationalSecrets +var writeCloudflareOperationalSecrets = WriteOperationalSecrets + 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) @@ -260,8 +262,19 @@ func (r Runner) reconcileCloudflareTunnel() error { return fmt.Errorf("read encrypted Cloudflare operational state: %w", err) } tunnelState := secrets["platform/cloudflare-tunnel"] - _, present, err := cloudflare.ParseStoredTunnel(tunnelState) + stored, present, legacy, err := cloudflare.ParseStoredTunnelState(tunnelState) if present { + if !legacy { + return nil + } + values, err := stored.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("save normalized encrypted Cloudflare tunnel state") + } return nil } if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index c665b6b..d560c5d 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -812,6 +812,43 @@ func TestReconcileCloudflareTunnelValidatesManagedState(t *testing.T) { } } +func TestReconcileCloudflareTunnelMigratesDottedStateAtomically(t *testing.T) { + originalRead := readCloudflareOperationalSecrets + originalWrite := writeCloudflareOperationalSecrets + t.Cleanup(func() { + readCloudflareOperationalSecrets = originalRead + writeCloudflareOperationalSecrets = originalWrite + }) + values, err := (cloudflare.StoredTunnel{ + Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, + Config: cloudflare.NewConfig("tunnel"), + }).Values() + if err != nil { + t.Fatal(err) + } + secrets := map[string]map[string]string{ + "platform/cloudflare-tunnel": {"credentials.json": values["credentials"], "config.yml": values["config"]}, + "platform/pihole": {"password": "preserved"}, + } + readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return secrets, nil } + writes := 0 + writeCloudflareOperationalSecrets = func(path, ageKeyPath string, updated map[string]map[string]string) error { + if path != "secrets" || ageKeyPath != "age" || updated["platform/pihole"]["password"] != "preserved" { + t.Fatal("dotted-state migration did not use the encrypted operational state boundary") + } + secrets = updated + writes++ + return nil + } + if err := (Runner{Config: config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}}).reconcileCloudflareTunnel(); err != nil { + t.Fatal(err) + } + _, present, legacy, err := cloudflare.ParseStoredTunnelState(secrets["platform/cloudflare-tunnel"]) + if err != nil || !present || legacy || writes != 1 || len(secrets["platform/cloudflare-tunnel"]) != 2 { + t.Fatal("dotted Cloudflare tunnel state was not rewritten to simple keys") + } +} + func TestReconcileCloudflareTunnelRequiresImportForAbsentOrLegacyState(t *testing.T) { originalRead := readCloudflareOperationalSecrets t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead }) diff --git a/internal/cloudflare/tunnel.go b/internal/cloudflare/tunnel.go index 918e1f3..cb13c91 100644 --- a/internal/cloudflare/tunnel.go +++ b/internal/cloudflare/tunnel.go @@ -14,9 +14,11 @@ import ( ) const ( - credentialsKey = "credentials.json" - configKey = "config.yml" - credentialsFile = "/etc/cloudflared/credentials.json" + credentialsKey = "credentials" + configKey = "config" + legacyCredentialsKey = "credentials.json" + legacyConfigKey = "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])?$`) @@ -77,8 +79,8 @@ func credentialsFromSecret(contents []byte) (Credentials, error) { 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] + plaintext, inStringData := secret.StringData[legacyCredentialsKey] + encoded, inData := secret.Data[legacyCredentialsKey] if inStringData == inData { return Credentials{}, errors.New("missing Secret credentials") } @@ -105,28 +107,46 @@ func NewRoute(hostname, service string) (Route, error) { } func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) { + stored, present, _, err := ParseStoredTunnelState(values) + return stored, present, err +} + +// ParseStoredTunnelState reports whether state uses the legacy dotted keys. +func ParseStoredTunnelState(values map[string]string) (StoredTunnel, bool, bool, error) { credentialsJSON, hasCredentials := values[credentialsKey] configYAML, hasConfig := values[configKey] - if !hasCredentials && !hasConfig { - if len(values) == 0 { - return StoredTunnel{}, false, nil + legacyCredentialsJSON, hasLegacyCredentials := values[legacyCredentialsKey] + legacyConfigYAML, hasLegacyConfig := values[legacyConfigKey] + if hasCredentials || hasConfig { + if !hasCredentials || !hasConfig || len(values) != 2 { + return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config") } - return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials.json and config.yml") + return parseStoredTunnel(credentialsJSON, configYAML, false) } - if !hasCredentials || !hasConfig || len(values) != 2 { - return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml") + if hasLegacyCredentials || hasLegacyConfig { + if !hasLegacyCredentials || !hasLegacyConfig || len(values) != 2 { + return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config") + } + return parseStoredTunnel(legacyCredentialsJSON, legacyConfigYAML, true) } + if len(values) == 0 { + return StoredTunnel{}, false, false, nil + } + return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials and config") +} + +func parseStoredTunnel(credentialsJSON, configYAML string, legacy bool) (StoredTunnel, bool, bool, error) { credentials, err := parseCredentials([]byte(credentialsJSON)) if err != nil { - return StoredTunnel{}, false, err + return StoredTunnel{}, false, false, err } 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{}, false, false, errors.New("Cloudflare tunnel config is invalid") } - return StoredTunnel{Credentials: credentials, Config: config}, true, nil + return StoredTunnel{Credentials: credentials, Config: config}, true, legacy, nil } func parseCredentials(contents []byte) (Credentials, error) { diff --git a/internal/cloudflare/tunnel_test.go b/internal/cloudflare/tunnel_test.go index 59e299a..cdc50b7 100644 --- a/internal/cloudflare/tunnel_test.go +++ b/internal/cloudflare/tunnel_test.go @@ -13,10 +13,18 @@ func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) { if err != nil { t.Fatal(err) } + if _, hasCredentials := values["credentials"]; !hasCredentials { + t.Fatal("new tunnel state did not use a simple credentials key") + } 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") } + legacy := map[string]string{"credentials.json": values["credentials"], "config.yml": values["config"]} + _, present, isLegacy, err := ParseStoredTunnelState(legacy) + if err != nil || !present || !isLegacy { + t.Fatal("valid dotted tunnel state was not recognized for migration") + } } func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) {