fix: normalize Cloudflare tunnel properties

This commit is contained in:
eding 2026-08-04 22:06:37 +02:00
parent a08eb8616d
commit 58478b50ae
5 changed files with 98 additions and 18 deletions

View file

@ -22,9 +22,9 @@ secrets:
password: encrypted-value password: encrypted-value
platform/cloudflare: platform/cloudflare:
api-token: encrypted-value api-token: encrypted-value
platform/cloudflare-tunnel: platform/cloudflare-tunnel:
credentials.json: encrypted-value credentials: encrypted-value
config.yml: encrypted-value config: encrypted-value
``` ```
Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are
@ -53,6 +53,8 @@ go run . cloudflare-tunnel import --config <private-bootstrap-config> --credenti
The import command reads the file only in memory, validates its credential The import command reads the file only in memory, validates its credential
shape, stores only the credentials JSON and terminal-404 local config shown shape, stores only the credentials JSON and terminal-404 local config shown
above, and seeds OpenBao. It never saves a Tunnel run token. 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 `cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares CLI supplies it as the Forgejo webhook Authorization header and Tekton compares

View file

@ -54,6 +54,8 @@ var writeGeneratedSOPS = writeSOPSEncryptedFile
var readCloudflareOperationalSecrets = ReadOperationalSecrets var readCloudflareOperationalSecrets = ReadOperationalSecrets
var writeCloudflareOperationalSecrets = WriteOperationalSecrets
var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorization string) error { 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) 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) 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) return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
} }
tunnelState := secrets["platform/cloudflare-tunnel"] tunnelState := secrets["platform/cloudflare-tunnel"]
_, present, err := cloudflare.ParseStoredTunnel(tunnelState) stored, present, legacy, err := cloudflare.ParseStoredTunnelState(tunnelState)
if present { 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 return nil
} }
if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) { if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) {

View file

@ -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) { func TestReconcileCloudflareTunnelRequiresImportForAbsentOrLegacyState(t *testing.T) {
originalRead := readCloudflareOperationalSecrets originalRead := readCloudflareOperationalSecrets
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead }) t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })

View file

@ -14,9 +14,11 @@ import (
) )
const ( const (
credentialsKey = "credentials.json" credentialsKey = "credentials"
configKey = "config.yml" configKey = "config"
credentialsFile = "/etc/cloudflared/credentials.json" 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])?$`) 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" { if err := decoder.Decode(&secret); err != nil || decoder.Decode(&struct{}{}) != io.EOF || secret.APIVersion != "v1" || secret.Kind != "Secret" {
return Credentials{}, errors.New("invalid Secret") return Credentials{}, errors.New("invalid Secret")
} }
plaintext, inStringData := secret.StringData[credentialsKey] plaintext, inStringData := secret.StringData[legacyCredentialsKey]
encoded, inData := secret.Data[credentialsKey] encoded, inData := secret.Data[legacyCredentialsKey]
if inStringData == inData { if inStringData == inData {
return Credentials{}, errors.New("missing Secret credentials") 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) { 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] credentialsJSON, hasCredentials := values[credentialsKey]
configYAML, hasConfig := values[configKey] configYAML, hasConfig := values[configKey]
if !hasCredentials && !hasConfig { legacyCredentialsJSON, hasLegacyCredentials := values[legacyCredentialsKey]
if len(values) == 0 { legacyConfigYAML, hasLegacyConfig := values[legacyConfigKey]
return StoredTunnel{}, false, nil 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 { if hasLegacyCredentials || hasLegacyConfig {
return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml") 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)) credentials, err := parseCredentials([]byte(credentialsJSON))
if err != nil { if err != nil {
return StoredTunnel{}, false, err return StoredTunnel{}, false, false, err
} }
var config Config var config Config
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML)) yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML))
yamlDecoder.KnownFields(true) yamlDecoder.KnownFields(true)
if err := yamlDecoder.Decode(&config); err != nil || yamlDecoder.Decode(&struct{}{}) != io.EOF || !validConfig(config) || config.Tunnel != credentials.TunnelID { 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) { func parseCredentials(contents []byte) (Credentials, error) {

View file

@ -13,10 +13,18 @@ func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) parsed, present, err := ParseStoredTunnel(values)
if err != nil || !present || len(parsed.Config.Ingress) != 1 || parsed.Config.Ingress[0].Service != "http_status:404" { 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") 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) { func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) {