Merge pull request 'fix: normalize Cloudflare tunnel properties' (#12) from fix/normalize-cloudflare-properties into main

Reviewed-on: #12
This commit is contained in:
eding 2026-08-04 22:23:51 +02:00
commit 6437318f91
5 changed files with 98 additions and 18 deletions

View file

@ -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/<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
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

View file

@ -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) {

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

View file

@ -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) {

View file

@ -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) {