feat: manage Cloudflare tunnels
This commit is contained in:
parent
eebd36cca1
commit
bfd1618403
144
cmd/cloudflare_tunnel.go
Normal file
144
cmd/cloudflare_tunnel.go
Normal 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
|
||||||
|
}
|
||||||
182
cmd/cloudflare_tunnel_test.go
Normal file
182
cmd/cloudflare_tunnel_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
160
internal/cloudflare/client.go
Normal file
160
internal/cloudflare/client.go
Normal 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"
|
||||||
|
}
|
||||||
118
internal/cloudflare/client_test.go
Normal file
118
internal/cloudflare/client_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
158
internal/cloudflare/tunnel.go
Normal file
158
internal/cloudflare/tunnel.go
Normal 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
|
||||||
|
}
|
||||||
50
internal/cloudflare/tunnel_test.go
Normal file
50
internal/cloudflare/tunnel_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue