feat: import Cloudflare tunnel credentials

This commit is contained in:
eding 2026-08-04 00:51:03 +02:00
parent bfd1618403
commit f17097ecae
10 changed files with 287 additions and 254 deletions

View file

@ -16,12 +16,14 @@ var cloudflareTunnelConfigPath string
var cloudflareTunnelHostname string var cloudflareTunnelHostname string
var cloudflareTunnelService string var cloudflareTunnelService string
var cloudflareTunnelZoneID string var cloudflareTunnelZoneID string
var cloudflareTunnelCredentialsFile string
var loadCloudflareTunnelConfig = config.Load var loadCloudflareTunnelConfig = config.Load
var readCloudflareTunnelSecrets = bootstrap.ReadOperationalSecrets var readCloudflareTunnelSecrets = bootstrap.ReadOperationalSecrets
var writeCloudflareTunnelSecrets = bootstrap.WriteOperationalSecrets var writeCloudflareTunnelSecrets = bootstrap.WriteOperationalSecrets
var newCloudflareRouteClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) } var newCloudflareRouteClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) }
var initializeCloudflareTunnelOpenBao = bootstrap.InitializeOpenBao var initializeCloudflareTunnelOpenBao = bootstrap.InitializeOpenBao
var readCloudflareTunnelCredentials = cloudflare.ReadCredentialsFile
var cloudflareTunnelCmd = &cobra.Command{ var cloudflareTunnelCmd = &cobra.Command{
Use: "cloudflare-tunnel", 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() { func init() {
rootCmd.AddCommand(cloudflareTunnelCmd) rootCmd.AddCommand(cloudflareTunnelCmd)
cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd) cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd, cloudflareTunnelImportCmd)
cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd) cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd)
cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML") cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML")
for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} { for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} {
@ -59,6 +69,49 @@ func init() {
_ = command.MarkFlagRequired("service") _ = command.MarkFlagRequired("service")
_ = command.MarkFlagRequired("zone-id") _ = 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 <config> --initialize-openbao")
}
return nil
} }
func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error { func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
@ -89,7 +142,7 @@ func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
return err return err
} }
if !present { 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() ctx := context.Background()
if cmd != nil && cmd.Context() != nil { if cmd != nil && cmd.Context() != nil {

View file

@ -17,18 +17,6 @@ type fakeCloudflareRouteAPI struct {
deleteCalls int 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 { func (f *fakeCloudflareRouteAPI) EnsureCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" { if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
return &routeTestError{} return &routeTestError{}
@ -180,3 +168,123 @@ func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) {
t.Fatal("route command error revealed the Cloudflare token") 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")
}
}

View file

@ -103,8 +103,9 @@ provider Secret directly.
## Webhook TLS ## Webhook TLS
The public Gateway terminates HTTPS with a cert-manager certificate. The The public Gateway terminates HTTPS with a cert-manager certificate. The
Cloudflare API token issues the `nid3.com` certificate and manages the tunnel; Cloudflare API token issues the `nid3.com` certificate and manages explicit
Pi-hole remains the ExternalDNS provider. Check certificate readiness with: Tunnel CNAME records. Pi-hole remains the ExternalDNS provider. Check
certificate readiness with:
```powershell ```powershell
kubectl -n cert-manager get externalsecret cloudflare-api-token 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 Enter the Pi-hole values and Cloudflare API token through
`--prompt-operational-secrets`, then run `bootstrap --mode=reconcile` to create `--prompt-operational-secrets`, then import the user-approved local credentials
the managed tunnel state. Do not put the Cloudflare token or tunnel credentials file before bootstrap:
in the cluster repository.
```powershell
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
```
Do not put the Cloudflare token or tunnel credentials in the cluster repository.
## Cloudflare Tunnel Routes ## Cloudflare Tunnel Routes
After reconcile has created the tunnel, manage one explicit proxied CNAME and After importing the credentials, manage one explicit proxied CNAME and ingress
ingress rule at a time: rule at a time:
```powershell ```powershell
go run . cloudflare-tunnel route add --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id> go run . cloudflare-tunnel route add --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>

View file

@ -40,13 +40,19 @@ For a new operational-secret input, run `bootstrap --config <path>
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials --prompt-operational-secrets`. It derives Forgejo Git and registry credentials
from the configured Forgejo account, prompts for the Pi-hole server and masked from the configured Forgejo account, prompts for the Pi-hole server and masked
password and Cloudflare API token, and generates the webhook authorization password and Cloudflare API token, and generates the webhook authorization
value. The Cloudflare token issues the Gateway certificate and creates the value. The Cloudflare token issues the Gateway certificate and manages explicit
CLI-managed tunnel; it is not used by ExternalDNS. Tunnel CNAME records; it is not used by ExternalDNS.
`bootstrap` creates a named Cloudflare Tunnel only when Import the user-approved local credential file before bootstrap validates the
`platform/cloudflare-tunnel` has no generated state. It stores only the tunnel state:
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. ```powershell
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
```
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 `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,10 +54,6 @@ var writeGeneratedSOPS = writeSOPSEncryptedFile
var readCloudflareOperationalSecrets = ReadOperationalSecrets 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 { 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)
@ -200,7 +196,7 @@ func (r Runner) Run() error {
if manager.MigrationPending { if manager.MigrationPending {
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes") 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 return err
} }
@ -258,57 +254,20 @@ func (r Runner) Run() error {
return nil 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) secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
if err != nil { if err != nil {
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"]
legacyRunToken := cloudflare.IsLegacyRunTokenState(tunnelState)
_, present, err := cloudflare.ParseStoredTunnel(tunnelState) _, present, err := cloudflare.ParseStoredTunnel(tunnelState)
if err != nil && !legacyRunToken {
return err
}
if present { if present {
return nil return nil
} }
token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"]) if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) {
if token == "" { return errors.New("Cloudflare tunnel credentials and config are not generated; run cicd-tool cloudflare-tunnel import --config <config> --credentials-file <local-file>")
return errors.New("encrypted operational secrets requires platform/cloudflare.api-token")
} }
client := newCloudflareClient(token) return err
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
} }
func (r Runner) reconcileWebhook(generatedDir string) error { func (r Runner) reconcileWebhook(generatedDir string) error {

View file

@ -1,7 +1,6 @@
package bootstrap package bootstrap
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
@ -795,116 +794,47 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) {
} }
} }
type fakeCloudflareAPI struct { func TestReconcileCloudflareTunnelValidatesManagedState(t *testing.T) {
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) {
originalRead := readCloudflareOperationalSecrets originalRead := readCloudflareOperationalSecrets
originalWrite := writeCloudflareOperationalSecrets t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
originalClient := newCloudflareClient values, err := (cloudflare.StoredTunnel{
t.Cleanup(func() { Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"},
readCloudflareOperationalSecrets = originalRead Config: cloudflare.NewConfig("tunnel"),
writeCloudflareOperationalSecrets = originalWrite }).Values()
newCloudflareClient = originalClient if err != nil {
}) t.Fatal(err)
state := map[string]map[string]string{
"platform/cloudflare": {"api-token": "test-api-token"},
"platform/cloudflare-tunnel": {"token": "legacy-run-token"},
"platform/pihole": {"password": "preserved"},
} }
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil } readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
var saved map[string]map[string]string return map[string]map[string]string{"platform/cloudflare-tunnel": values}, nil
writeCloudflareOperationalSecrets = func(_, _ string, values map[string]map[string]string) error {
saved = values
return nil
} }
client := &fakeCloudflareAPI{accounts: []cloudflare.Account{{ID: "account-id"}}, created: cloudflare.Tunnel{ID: "tunnel-id", TunnelSecret: "test-tunnel-secret"}} if err := (Runner{Config: config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}}).reconcileCloudflareTunnel(); err != nil {
newCloudflareClient = func(token string) cloudflare.API { t.Fatal(err)
if token != "test-api-token" { }
t.Fatal("Cloudflare API token did not stay at the client boundary") }
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 originalRead := readCloudflareOperationalSecrets
originalClient := newCloudflareClient t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
t.Cleanup(func() {
readCloudflareOperationalSecrets = originalRead
newCloudflareClient = originalClient
})
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{ return map[string]map[string]string{"platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"}}, nil
"platform/cloudflare": {"api-token": "test-api-token"},
"platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"},
}, nil
} }
called := false err := (Runner{}).reconcileCloudflareTunnel()
newCloudflareClient = func(string) cloudflare.API { if err == nil || strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") {
called = true t.Fatal("malformed Cloudflare tunnel state was not rejected safely")
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")
} }
} }

View file

@ -14,24 +14,12 @@ import (
const apiURL = "https://api.cloudflare.com/client/v4" 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 { 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 EnsureCNAME(context.Context, string, string, string) error
DeleteCNAME(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 { type dnsRecord struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` 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} 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 { func (c *Client) EnsureCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
records, err := c.cnameRecords(ctx, zoneID, hostname) records, err := c.cnameRecords(ctx, zoneID, hostname)
if err != nil { if err != nil {

View file

@ -9,7 +9,7 @@ import (
"testing" "testing"
) )
func TestClientTunnelAndDNSLifecycle(t *testing.T) { func TestClientCreatesTunnelCNAME(t *testing.T) {
requests := 0 requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++ requests++
@ -19,19 +19,6 @@ func TestClientTunnelAndDNSLifecycle(t *testing.T) {
return return
} }
switch request.Method + " " + request.URL.Path { 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": case "GET /zones/zone-id/dns_records":
if request.URL.Query().Get("type") != "CNAME" || request.URL.Query().Get("name") != "app.example.test" { if request.URL.Query().Get("type") != "CNAME" || request.URL.Query().Get("name") != "app.example.test" {
t.Error("DNS lookup was invalid") t.Error("DNS lookup was invalid")
@ -53,23 +40,11 @@ func TestClientTunnelAndDNSLifecycle(t *testing.T) {
client := NewClient("test-api-token") client := NewClient("test-api-token")
client.baseURL = server.URL client.baseURL = server.URL
client.httpClient = server.Client() client.httpClient = server.Client()
accounts, err := client.ListAccounts(context.Background()) if err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil {
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 {
t.Fatal(err) t.Fatal(err)
} }
if requests != 5 { if requests != 2 {
t.Fatalf("expected five Cloudflare requests, got %d", requests) t.Fatalf("expected two Cloudflare requests, got %d", requests)
} }
} }
@ -82,7 +57,7 @@ func TestClientFailureDoesNotRevealToken(t *testing.T) {
client := NewClient("test-api-token") client := NewClient("test-api-token")
client.baseURL = server.URL client.baseURL = server.URL
client.httpClient = server.Client() 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") { if err == nil || strings.Contains(err.Error(), "test-api-token") {
t.Fatal("Cloudflare API failure exposed the token") t.Fatal("Cloudflare API failure exposed the token")
} }

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"io" "io"
"net/url" "net/url"
"os"
"regexp" "regexp"
"strings" "strings"
@ -41,10 +42,6 @@ type StoredTunnel struct {
Config Config Config Config
} }
func TunnelName(clusterID string) string {
return "maidn-" + clusterID
}
func NewConfig(tunnelID string) Config { func NewConfig(tunnelID string) Config {
return Config{Tunnel: tunnelID, CredentialsFile: credentialsFile, Ingress: []Route{{Service: "http_status:404"}}} 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"]) != "" 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) { func NewRoute(hostname, service string) (Route, error) {
hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), ".")) hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), "."))
if len(hostname) > 253 || !hostnamePattern.MatchString(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 { 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, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml")
} }
var credentials Credentials credentials, err := parseCredentials([]byte(credentialsJSON))
decoder := json.NewDecoder(strings.NewReader(credentialsJSON)) if err != nil {
decoder.DisallowUnknownFields() return StoredTunnel{}, false, err
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")
} }
var config Config var config Config
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML)) 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 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) { func (s StoredTunnel) Values() (map[string]string, error) {
credentials, err := json.Marshal(s.Credentials) credentials, err := json.Marshal(s.Credentials)
if err != nil { if err != nil {

View file

@ -1,6 +1,10 @@
package cloudflare package cloudflare
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) { func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) {
stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")} 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) { func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) {
config := NewConfig("tunnel") config := NewConfig("tunnel")
route, err := NewRoute("app.example.test", "http://service.default.svc:8080") route, err := NewRoute("app.example.test", "http://service.default.svc:8080")