feat: manage Cloudflare tunnels #10

Merged
eding merged 1 commit from feat/managed-cloudflare-tunnel into main 2026-08-04 00:18:52 +02:00
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 ## Webhook TLS
The public Gateway terminates HTTPS with a cert-manager certificate. Its The public Gateway terminates HTTPS with a cert-manager certificate. The
Cloudflare DNS-01 token is used only to issue the `nid3.com` certificate; Cloudflare API token issues the `nid3.com` certificate and manages the tunnel;
Pi-hole remains the ExternalDNS provider. Check certificate readiness with: Pi-hole remains the ExternalDNS provider. Check certificate readiness with:
```powershell ```powershell
@ -111,9 +111,23 @@ kubectl -n cert-manager get externalsecret cloudflare-api-token
kubectl -n gateway-system get certificate webhook-tls kubectl -n gateway-system get certificate webhook-tls
``` ```
Enter the Pi-hole values, Cloudflare DNS-01 token, and Tunnel token through Enter the Pi-hole values and Cloudflare API token through
`--prompt-operational-secrets`, then run `--initialize-openbao`. Do not put the `--prompt-operational-secrets`, then run `bootstrap --mode=reconcile` to create
Cloudflare token in the cluster repository. 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 ## Internal Platform UIs

View file

@ -23,7 +23,8 @@ secrets:
platform/cloudflare: platform/cloudflare:
api-token: encrypted-value api-token: encrypted-value
platform/cloudflare-tunnel: 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 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> 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, masked Cloudflare DNS-01 and Tunnel tokens, and generates the webhook password and Cloudflare API token, and generates the webhook authorization
authorization value. The DNS-01 token issues the Gateway certificate; it is not value. The Cloudflare token issues the Gateway certificate and creates the
used by ExternalDNS. 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 `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

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

View file

@ -1,6 +1,7 @@
package bootstrap package bootstrap
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
@ -14,6 +15,7 @@ import (
"time" "time"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "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/forgejo"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao" "github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"gopkg.in/yaml.v3" "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) 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) reader := bufio.NewReader(os.Stdin)
piholeServer := prompt(reader, "Pi-hole server", "") piholeServer := prompt(reader, "Pi-hole server", "")
piholePassword := promptSecret(reader, "Pi-hole password", "") piholePassword := promptSecret(reader, "Pi-hole password", "")
cloudflareAPIToken := promptSecret(reader, "Cloudflare DNS-01 API token", "") cloudflareAPIToken := promptSecret(reader, "Cloudflare API token", "")
cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "") if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" {
if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" || cloudflareTunnelToken == "" { return nil, errors.New("Pi-hole server, password, and Cloudflare API token are required")
return nil, errors.New("Pi-hole server, password, Cloudflare DNS-01 API token, and Cloudflare Tunnel token are required")
} }
registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0] registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0]
dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{ 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)}, "cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)},
"platform/pihole": {"server": piholeServer, "password": piholePassword}, "platform/pihole": {"server": piholeServer, "password": piholePassword},
"platform/cloudflare": {"api-token": cloudflareAPIToken}, "platform/cloudflare": {"api-token": cloudflareAPIToken},
"platform/cloudflare-tunnel": {"token": cloudflareTunnelToken},
}, nil }, nil
} }