Compare commits
2 commits
92ae57121d
...
6437318f91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6437318f91 | ||
|
|
58478b50ae |
|
|
@ -23,8 +23,8 @@ secrets:
|
|||
platform/cloudflare:
|
||||
api-token: encrypted-value
|
||||
platform/cloudflare-tunnel:
|
||||
credentials.json: encrypted-value
|
||||
config.yml: encrypted-value
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
credentialsKey = "credentials.json"
|
||||
configKey = "config.yml"
|
||||
credentialsKey = "credentials"
|
||||
configKey = "config"
|
||||
legacyCredentialsKey = "credentials.json"
|
||||
legacyConfigKey = "config.yml"
|
||||
credentialsFile = "/etc/cloudflared/credentials.json"
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials.json and config.yml")
|
||||
}
|
||||
legacyCredentialsJSON, hasLegacyCredentials := values[legacyCredentialsKey]
|
||||
legacyConfigYAML, hasLegacyConfig := values[legacyConfigKey]
|
||||
if hasCredentials || hasConfig {
|
||||
if !hasCredentials || !hasConfig || len(values) != 2 {
|
||||
return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml")
|
||||
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config")
|
||||
}
|
||||
return parseStoredTunnel(credentialsJSON, configYAML, false)
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue