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

View file

@ -17,18 +17,6 @@ type fakeCloudflareRouteAPI struct {
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 {
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
return &routeTestError{}
@ -180,3 +168,123 @@ func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) {
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
The public Gateway terminates HTTPS with a cert-manager certificate. The
Cloudflare API token issues the `nid3.com` certificate and manages the tunnel;
Pi-hole remains the ExternalDNS provider. Check certificate readiness with:
Cloudflare API token issues the `nid3.com` certificate and manages explicit
Tunnel CNAME records. Pi-hole remains the ExternalDNS provider. Check
certificate readiness with:
```powershell
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
`--prompt-operational-secrets`, then run `bootstrap --mode=reconcile` to create
the managed tunnel state. Do not put the Cloudflare token or tunnel credentials
in the cluster repository.
`--prompt-operational-secrets`, then import the user-approved local credentials
file before bootstrap:
```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
After reconcile has created the tunnel, manage one explicit proxied CNAME and
ingress rule at a time:
After importing the credentials, manage one explicit proxied CNAME and ingress
rule at a time:
```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>

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
from the configured Forgejo account, prompts for the Pi-hole server and masked
password and Cloudflare API token, and generates the webhook authorization
value. The Cloudflare token issues the Gateway certificate and creates the
CLI-managed tunnel; it is not used by ExternalDNS.
value. The Cloudflare token issues the Gateway certificate and manages explicit
Tunnel CNAME records; it is not used by ExternalDNS.
`bootstrap` creates a named Cloudflare Tunnel only when
`platform/cloudflare-tunnel` has no generated state. It stores only the
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.
Import the user-approved local credential file before bootstrap validates the
tunnel state:
```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
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 writeCloudflareOperationalSecrets = WriteOperationalSecrets
var newCloudflareClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) }
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)
@ -200,7 +196,7 @@ func (r Runner) Run() error {
if manager.MigrationPending {
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
}
@ -258,57 +254,20 @@ func (r Runner) Run() error {
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)
if err != nil {
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
}
tunnelState := secrets["platform/cloudflare-tunnel"]
legacyRunToken := cloudflare.IsLegacyRunTokenState(tunnelState)
_, present, err := cloudflare.ParseStoredTunnel(tunnelState)
if err != nil && !legacyRunToken {
return err
}
if present {
return nil
}
token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"])
if token == "" {
return errors.New("encrypted operational secrets requires platform/cloudflare.api-token")
if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) {
return errors.New("Cloudflare tunnel credentials and config are not generated; run cicd-tool cloudflare-tunnel import --config <config> --credentials-file <local-file>")
}
client := newCloudflareClient(token)
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 {

View file

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

View file

@ -14,24 +14,12 @@ import (
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 {
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
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 {
ID string `json:"id"`
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}
}
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 {
records, err := c.cnameRecords(ctx, zoneID, hostname)
if err != nil {

View file

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

View file

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

View file

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