feat: manage Cloudflare tunnels

This commit is contained in:
eding 2026-08-04 00:04:29 +02:00
parent eebd36cca1
commit bfd1618403
11 changed files with 1023 additions and 14 deletions

144
cmd/cloudflare_tunnel.go Normal file
View file

@ -0,0 +1,144 @@
package cmd
import (
"context"
"errors"
"fmt"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/spf13/cobra"
)
var cloudflareTunnelConfigPath string
var cloudflareTunnelHostname string
var cloudflareTunnelService string
var cloudflareTunnelZoneID 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 cloudflareTunnelCmd = &cobra.Command{
Use: "cloudflare-tunnel",
Short: "Manage CLI-created Cloudflare Tunnel routes.",
}
var cloudflareTunnelRouteCmd = &cobra.Command{Use: "route"}
var cloudflareTunnelRouteAddCmd = &cobra.Command{
Use: "add",
Short: "Add a proxied public route to the managed tunnel.",
RunE: func(cmd *cobra.Command, _ []string) error {
return runCloudflareTunnelRoute(cmd, true)
},
}
var cloudflareTunnelRouteRemoveCmd = &cobra.Command{
Use: "remove",
Short: "Remove a proxied public route from the managed tunnel.",
RunE: func(cmd *cobra.Command, _ []string) error {
return runCloudflareTunnelRoute(cmd, false)
},
}
func init() {
rootCmd.AddCommand(cloudflareTunnelCmd)
cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd)
cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd)
cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML")
for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} {
command.Flags().StringVar(&cloudflareTunnelHostname, "hostname", "", "Public DNS hostname")
command.Flags().StringVar(&cloudflareTunnelService, "service", "", "Upstream http or https service URL")
command.Flags().StringVar(&cloudflareTunnelZoneID, "zone-id", "", "Cloudflare zone ID")
_ = command.MarkFlagRequired("hostname")
_ = command.MarkFlagRequired("service")
_ = command.MarkFlagRequired("zone-id")
}
}
func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
if cloudflareTunnelConfigPath == "" {
return errors.New("--config is required")
}
if strings.TrimSpace(cloudflareTunnelZoneID) == "" {
return errors.New("--zone-id is required")
}
route, err := cloudflare.NewRoute(cloudflareTunnelHostname, cloudflareTunnelService)
if err != nil {
return err
}
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)
}
token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"])
if token == "" {
return errors.New("encrypted operational secrets requires platform/cloudflare.api-token")
}
stored, present, err := cloudflare.ParseStoredTunnel(secrets["platform/cloudflare-tunnel"])
if err != nil {
return err
}
if !present {
return errors.New("Cloudflare tunnel credentials and config are not generated; run bootstrap reconcile first")
}
ctx := context.Background()
if cmd != nil && cmd.Context() != nil {
ctx = cmd.Context()
}
client := newCloudflareRouteClient(token)
if add {
changed, err := stored.Config.AddRoute(route)
if err != nil {
return err
}
if err := client.EnsureCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil {
return fmt.Errorf("ensure Cloudflare route DNS record: %w", err)
}
if !changed {
return nil
}
values, err := stored.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 route")
}
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
}
return nil
}
changed, err := stored.Config.RemoveRoute(route)
if err != nil {
return err
}
if changed {
values, err := stored.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 route")
}
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
}
}
if err := client.DeleteCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil {
return fmt.Errorf("delete Cloudflare route DNS record: %w", err)
}
return nil
}

View file

@ -0,0 +1,182 @@
package cmd
import (
"context"
"errors"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
type fakeCloudflareRouteAPI struct {
ensured bool
deleted bool
ensureErr error
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{}
}
f.ensured = true
return f.ensureErr
}
func (f *fakeCloudflareRouteAPI) DeleteCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
return &routeTestError{}
}
f.deleted = true
f.deleteCalls++
return nil
}
type routeTestError struct{}
func (*routeTestError) Error() string { return "unexpected route request" }
func TestCloudflareTunnelRouteCommandsUpdateEncryptedStateAndDNS(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalWrite := writeCloudflareTunnelSecrets
originalClient := newCloudflareRouteClient
originalInitialize := initializeCloudflareTunnelOpenBao
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
writeCloudflareTunnelSecrets = originalWrite
newCloudflareRouteClient = originalClient
initializeCloudflareTunnelOpenBao = originalInitialize
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
})
values, err := (cloudflare.StoredTunnel{
Credentials: cloudflare.Credentials{AccountTag: "account-id", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel-id"},
Config: cloudflare.NewConfig("tunnel-id"),
}).Values()
if err != nil {
t.Fatal(err)
}
state := map[string]map[string]string{
"platform/cloudflare": {"api-token": "test-api-token"},
"platform/cloudflare-tunnel": values,
}
client := &fakeCloudflareRouteAPI{}
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
}
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
writes := 0
writeCloudflareTunnelSecrets = func(_ string, _ string, secrets map[string]map[string]string) error {
if writes == 0 && !client.ensured {
t.Fatal("route add saved config before ensuring the CNAME")
}
if writes == 1 && client.deleted {
t.Fatal("route remove deleted the CNAME before saving config")
}
state = secrets
writes++
return nil
}
newCloudflareRouteClient = func(token string) cloudflare.API {
if token != "test-api-token" {
t.Fatal("Cloudflare route command leaked the token beyond the API boundary")
}
return client
}
seeds := 0
var seedErr error
initializeCloudflareTunnelOpenBao = func(config.Config) error {
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
wantIngress := 2
if seeds%2 == 1 {
wantIngress = 1
}
if err != nil || !present || len(stored.Config.Ingress) != wantIngress {
t.Fatal("OpenBao was not seeded with the saved tunnel config")
}
seeds++
return seedErr
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelHostname = "app.example.test"
cloudflareTunnelService = "http://service.default.svc:8080"
cloudflareTunnelZoneID = "zone-id"
if err := runCloudflareTunnelRoute(nil, true); err != nil {
t.Fatal(err)
}
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 2 || !client.ensured || seeds != 1 {
t.Fatal("route add did not save config and ensure the CNAME")
}
if err := runCloudflareTunnelRoute(nil, false); err != nil {
t.Fatal(err)
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || !client.deleted || seeds != 2 {
t.Fatal("route remove did not save config and delete the CNAME")
}
client.ensureErr = errors.New("unavailable")
if err := runCloudflareTunnelRoute(nil, true); err == nil {
t.Fatal("route add accepted a CNAME failure")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 2 || seeds != 2 {
t.Fatal("route add saved ingress config after a CNAME failure")
}
client.ensureErr = nil
seedErr = errors.New("unavailable")
if err := runCloudflareTunnelRoute(nil, true); err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("route add did not return a safe OpenBao seeding error")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 2 || writes != 3 || seeds != 3 {
t.Fatal("route add did not retain DNS-safe state before OpenBao seeding failed")
}
deletes := client.deleteCalls
if err := runCloudflareTunnelRoute(nil, false); err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("route remove did not return a safe OpenBao seeding error")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 4 || seeds != 4 || client.deleteCalls != deletes {
t.Fatal("route remove deleted DNS before OpenBao seeding completed")
}
}
func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
})
loadCloudflareTunnelConfig = func(string) (config.Config, error) { return config.Config{}, nil }
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"platform/cloudflare": {"api-token": "test-api-token"}}, nil
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelHostname = "app.example.test"
cloudflareTunnelService = "http://service.default.svc:8080"
cloudflareTunnelZoneID = "zone-id"
err := runCloudflareTunnelRoute(nil, true)
if err == nil || strings.Contains(err.Error(), "test-api-token") {
t.Fatal("route command error revealed the Cloudflare token")
}
}

View file

@ -102,8 +102,8 @@ provider Secret directly.
## Webhook TLS
The public Gateway terminates HTTPS with a cert-manager certificate. Its
Cloudflare DNS-01 token is used only to issue the `nid3.com` certificate;
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:
```powershell
@ -111,9 +111,23 @@ kubectl -n cert-manager get externalsecret cloudflare-api-token
kubectl -n gateway-system get certificate webhook-tls
```
Enter the Pi-hole values, Cloudflare DNS-01 token, and Tunnel token through
`--prompt-operational-secrets`, then run `--initialize-openbao`. Do not put the
Cloudflare token in the cluster repository.
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.
## Cloudflare Tunnel Routes
After reconcile has created the tunnel, 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>
go run . cloudflare-tunnel route remove --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>
```
Both commands require all three route values, preserve the terminal 404 rule,
and refuse an existing CNAME that does not point to the managed tunnel.
## Internal Platform UIs

View file

@ -23,7 +23,8 @@ secrets:
platform/cloudflare:
api-token: encrypted-value
platform/cloudflare-tunnel:
token: encrypted-value
credentials.json: encrypted-value
config.yml: encrypted-value
```
Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are
@ -38,9 +39,14 @@ For an existing configuration, run `bootstrap --config <path>
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, masked Cloudflare DNS-01 and Tunnel tokens, and generates the webhook
authorization value. The DNS-01 token issues the Gateway certificate; it is not
used by ExternalDNS.
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.
`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.
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares

View file

@ -17,6 +17,7 @@ import (
"strings"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github"
@ -51,6 +52,12 @@ var decryptGeneratedSOPS = decryptSOPSFile
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)
@ -193,6 +200,9 @@ 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 {
return err
}
repoDir := filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName)
@ -248,6 +258,59 @@ func (r Runner) Run() error {
return nil
}
func (r Runner) reconcileCloudflareTunnel(ctx context.Context) 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")
}
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 {
operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
if err != nil {

View file

@ -1,6 +1,7 @@
package bootstrap
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
@ -14,6 +15,7 @@ import (
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"gopkg.in/yaml.v3"
@ -792,3 +794,117 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) {
t.Fatalf("Terraform plan path is not absolute: %q, %v", path, err)
}
}
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) {
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"},
}
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
}
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) {
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"},
"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")
}
}

View file

@ -0,0 +1,160 @@
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const apiURL = "https://api.cloudflare.com/client/v4"
// API is the narrow Cloudflare boundary used by the bootstrap and 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"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
}
type Client struct {
baseURL string
httpClient *http.Client
token string
}
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 {
return err
}
target := tunnelTarget(tunnelID)
if len(records) == 0 {
body, err := json.Marshal(struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
}{Type: "CNAME", Name: hostname, Content: target, Proxied: true})
if err != nil {
return errors.New("encode Cloudflare DNS record")
}
var created dnsRecord
return c.request(ctx, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/dns_records", body, &created)
}
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
}
return nil
}
func (c *Client) DeleteCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
records, err := c.cnameRecords(ctx, zoneID, hostname)
if err != nil {
return err
}
if len(records) == 0 {
return nil
}
target := tunnelTarget(tunnelID)
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
}
var deleted dnsRecord
return c.request(ctx, http.MethodDelete, "/zones/"+url.PathEscape(zoneID)+"/dns_records/"+url.PathEscape(records[0].ID), nil, &deleted)
}
func (c *Client) cnameRecords(ctx context.Context, zoneID, hostname string) ([]dnsRecord, error) {
var records []dnsRecord
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records?" + url.Values{"type": {"CNAME"}, "name": {hostname}}.Encode()
err := c.request(ctx, http.MethodGet, path, nil, &records)
return records, err
}
func (c *Client) request(ctx context.Context, method, path string, body []byte, result any) error {
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return errors.New("create Cloudflare API request")
}
request.Header.Set("Authorization", "Bearer "+c.token)
request.Header.Set("Content-Type", "application/json")
client := c.httpClient
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return errors.New("call Cloudflare API")
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("Cloudflare API returned HTTP %d", response.StatusCode)
}
var envelope struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
}
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil || !envelope.Success {
return errors.New("Cloudflare API returned an invalid response")
}
if err := json.Unmarshal(envelope.Result, result); err != nil {
return errors.New("Cloudflare API returned an invalid result")
}
return nil
}
func tunnelTarget(tunnelID string) string {
return tunnelID + ".cfargotunnel.com"
}

View file

@ -0,0 +1,118 @@
package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClientTunnelAndDNSLifecycle(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
if request.Header.Get("Authorization") != "Bearer test-api-token" {
t.Error("Cloudflare request did not use the API token header")
writer.WriteHeader(http.StatusUnauthorized)
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")
}
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []any{}})
case "POST /zones/zone-id/dns_records":
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body["content"] != "tunnel-id.cfargotunnel.com" || body["proxied"] != true {
t.Error("DNS create request was invalid")
}
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}})
default:
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
writer.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer server.Close()
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 {
t.Fatal(err)
}
if requests != 5 {
t.Fatalf("expected five Cloudflare requests, got %d", requests)
}
}
func TestClientFailureDoesNotRevealToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
client := NewClient("test-api-token")
client.baseURL = server.URL
client.httpClient = server.Client()
_, err := client.ListAccounts(context.Background())
if err == nil || strings.Contains(err.Error(), "test-api-token") {
t.Fatal("Cloudflare API failure exposed the token")
}
}
func TestClientDeletesOnlyMatchingTunnelCNAME(t *testing.T) {
deleted := false
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer test-api-token" {
t.Error("Cloudflare request did not use the API token header")
writer.WriteHeader(http.StatusUnauthorized)
return
}
switch request.Method + " " + request.URL.Path {
case "GET /zones/zone-id/dns_records":
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []map[string]any{{"id": "record-id", "name": "app.example.test", "content": "tunnel-id.cfargotunnel.com", "proxied": true}}})
case "DELETE /zones/zone-id/dns_records/record-id":
deleted = true
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}})
default:
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
writer.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer server.Close()
client := NewClient("test-api-token")
client.baseURL = server.URL
client.httpClient = server.Client()
if err := client.DeleteCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil || !deleted {
t.Fatal("matching managed CNAME was not deleted")
}
}

View file

@ -0,0 +1,158 @@
package cloudflare
import (
"encoding/json"
"errors"
"io"
"net/url"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
const (
credentialsKey = "credentials.json"
configKey = "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])?$`)
type Credentials struct {
AccountTag string `json:"AccountTag"`
TunnelSecret string `json:"TunnelSecret"`
TunnelID string `json:"TunnelID"`
}
type Route struct {
Hostname string `yaml:"hostname,omitempty"`
Service string `yaml:"service"`
}
type Config struct {
Tunnel string `yaml:"tunnel"`
CredentialsFile string `yaml:"credentials-file"`
Ingress []Route `yaml:"ingress"`
}
type StoredTunnel struct {
Credentials Credentials
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"}}}
}
func IsLegacyRunTokenState(values map[string]string) bool {
return len(values) == 1 && strings.TrimSpace(values["token"]) != ""
}
func NewRoute(hostname, service string) (Route, error) {
hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), "."))
if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) {
return Route{}, errors.New("hostname must be a valid public DNS hostname")
}
parsed, err := url.ParseRequestURI(service)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return Route{}, errors.New("service must be an absolute http or https URL")
}
return Route{Hostname: hostname, Service: service}, nil
}
func ParseStoredTunnel(values map[string]string) (StoredTunnel, 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")
}
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")
}
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{Credentials: credentials, Config: config}, true, nil
}
func (s StoredTunnel) Values() (map[string]string, error) {
credentials, err := json.Marshal(s.Credentials)
if err != nil {
return nil, errors.New("encode Cloudflare tunnel credentials")
}
config, err := yaml.Marshal(s.Config)
if err != nil {
return nil, errors.New("encode Cloudflare tunnel config")
}
return map[string]string{credentialsKey: string(credentials), configKey: string(config)}, nil
}
func (c *Config) AddRoute(route Route) (bool, error) {
if !validConfig(*c) {
return false, errors.New("Cloudflare tunnel config is invalid")
}
for _, existing := range c.Ingress[:len(c.Ingress)-1] {
if existing.Hostname == route.Hostname {
if existing.Service == route.Service {
return false, nil
}
return false, errors.New("hostname already has a different Cloudflare tunnel route")
}
}
terminal := c.Ingress[len(c.Ingress)-1]
c.Ingress = append(c.Ingress[:len(c.Ingress)-1], route, terminal)
return true, nil
}
func (c *Config) RemoveRoute(route Route) (bool, error) {
if !validConfig(*c) {
return false, errors.New("Cloudflare tunnel config is invalid")
}
for index, existing := range c.Ingress[:len(c.Ingress)-1] {
if existing.Hostname != route.Hostname {
continue
}
if existing.Service != route.Service {
return false, errors.New("hostname does not match the requested Cloudflare tunnel service")
}
c.Ingress = append(c.Ingress[:index], c.Ingress[index+1:]...)
return true, nil
}
return false, nil
}
func validConfig(config Config) bool {
if config.Tunnel == "" || config.CredentialsFile != credentialsFile || len(config.Ingress) == 0 {
return false
}
last := len(config.Ingress) - 1
if config.Ingress[last].Hostname != "" || config.Ingress[last].Service != "http_status:404" {
return false
}
seen := map[string]bool{}
for _, route := range config.Ingress[:last] {
normalized, err := NewRoute(route.Hostname, route.Service)
if err != nil || normalized != route || seen[route.Hostname] {
return false
}
seen[route.Hostname] = true
}
return true
}

View file

@ -0,0 +1,50 @@
package cloudflare
import "testing"
func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) {
stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")}
values, err := stored.Values()
if err != nil {
t.Fatal(err)
}
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")
}
}
func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) {
for _, values := range []map[string]string{
{"token": "legacy-run-token"},
{"credentials.json": "{}"},
} {
if _, _, err := ParseStoredTunnel(values); err == nil {
t.Fatal("ambiguous tunnel state was accepted")
}
}
}
func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) {
config := NewConfig("tunnel")
route, err := NewRoute("app.example.test", "http://service.default.svc:8080")
if err != nil {
t.Fatal(err)
}
changed, err := config.AddRoute(route)
if err != nil || !changed || len(config.Ingress) != 2 {
t.Fatal("route was not added before the terminal ingress")
}
changed, err = config.AddRoute(route)
if err != nil || changed {
t.Fatal("matching route add was not idempotent")
}
changed, err = config.RemoveRoute(route)
if err != nil || !changed || len(config.Ingress) != 1 || config.Ingress[0].Service != "http_status:404" {
t.Fatal("route removal did not preserve the terminal ingress")
}
changed, err = config.RemoveRoute(route)
if err != nil || changed {
t.Fatal("matching route remove was not idempotent")
}
}

View file

@ -132,10 +132,9 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string,
reader := bufio.NewReader(os.Stdin)
piholeServer := prompt(reader, "Pi-hole server", "")
piholePassword := promptSecret(reader, "Pi-hole password", "")
cloudflareAPIToken := promptSecret(reader, "Cloudflare DNS-01 API token", "")
cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "")
if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" || cloudflareTunnelToken == "" {
return nil, errors.New("Pi-hole server, password, Cloudflare DNS-01 API token, and Cloudflare Tunnel token are required")
cloudflareAPIToken := promptSecret(reader, "Cloudflare API token", "")
if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" {
return nil, errors.New("Pi-hole server, password, and Cloudflare API token are required")
}
registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0]
dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{
@ -151,7 +150,6 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string,
"cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)},
"platform/pihole": {"server": piholeServer, "password": piholePassword},
"platform/cloudflare": {"api-token": cloudflareAPIToken},
"platform/cloudflare-tunnel": {"token": cloudflareTunnelToken},
}, nil
}