feat: import Cloudflare tunnel credentials #11
|
|
@ -16,12 +16,14 @@ var cloudflareTunnelConfigPath string
|
||||||
var cloudflareTunnelHostname string
|
var cloudflareTunnelHostname string
|
||||||
var cloudflareTunnelService string
|
var cloudflareTunnelService string
|
||||||
var cloudflareTunnelZoneID string
|
var cloudflareTunnelZoneID string
|
||||||
|
var cloudflareTunnelCredentialsFile string
|
||||||
|
|
||||||
var loadCloudflareTunnelConfig = config.Load
|
var loadCloudflareTunnelConfig = config.Load
|
||||||
var readCloudflareTunnelSecrets = bootstrap.ReadOperationalSecrets
|
var readCloudflareTunnelSecrets = bootstrap.ReadOperationalSecrets
|
||||||
var writeCloudflareTunnelSecrets = bootstrap.WriteOperationalSecrets
|
var writeCloudflareTunnelSecrets = bootstrap.WriteOperationalSecrets
|
||||||
var newCloudflareRouteClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) }
|
var newCloudflareRouteClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) }
|
||||||
var initializeCloudflareTunnelOpenBao = bootstrap.InitializeOpenBao
|
var initializeCloudflareTunnelOpenBao = bootstrap.InitializeOpenBao
|
||||||
|
var readCloudflareTunnelCredentials = cloudflare.ReadCredentialsFile
|
||||||
|
|
||||||
var cloudflareTunnelCmd = &cobra.Command{
|
var cloudflareTunnelCmd = &cobra.Command{
|
||||||
Use: "cloudflare-tunnel",
|
Use: "cloudflare-tunnel",
|
||||||
|
|
@ -46,9 +48,17 @@ var cloudflareTunnelRouteRemoveCmd = &cobra.Command{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cloudflareTunnelImportCmd = &cobra.Command{
|
||||||
|
Use: "import",
|
||||||
|
Short: "Import local Cloudflare Tunnel credentials into encrypted operational state.",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return runCloudflareTunnelImport()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(cloudflareTunnelCmd)
|
rootCmd.AddCommand(cloudflareTunnelCmd)
|
||||||
cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd)
|
cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd, cloudflareTunnelImportCmd)
|
||||||
cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd)
|
cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd)
|
||||||
cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML")
|
cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML")
|
||||||
for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} {
|
for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} {
|
||||||
|
|
@ -59,6 +69,49 @@ func init() {
|
||||||
_ = command.MarkFlagRequired("service")
|
_ = command.MarkFlagRequired("service")
|
||||||
_ = command.MarkFlagRequired("zone-id")
|
_ = command.MarkFlagRequired("zone-id")
|
||||||
}
|
}
|
||||||
|
cloudflareTunnelImportCmd.Flags().StringVar(&cloudflareTunnelCredentialsFile, "credentials-file", "", "Path to local Cloudflare Tunnel credentials JSON")
|
||||||
|
_ = cloudflareTunnelImportCmd.MarkFlagRequired("credentials-file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCloudflareTunnelImport() error {
|
||||||
|
if cloudflareTunnelConfigPath == "" {
|
||||||
|
return errors.New("--config is required")
|
||||||
|
}
|
||||||
|
if cloudflareTunnelCredentialsFile == "" {
|
||||||
|
return errors.New("--credentials-file is required")
|
||||||
|
}
|
||||||
|
cfg, err := loadCloudflareTunnelConfig(cloudflareTunnelConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secrets, err := readCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
|
||||||
|
}
|
||||||
|
state := secrets["platform/cloudflare-tunnel"]
|
||||||
|
_, present, stateErr := cloudflare.ParseStoredTunnel(state)
|
||||||
|
if present {
|
||||||
|
return errors.New("Cloudflare tunnel state is already managed; refusing to overwrite it")
|
||||||
|
}
|
||||||
|
if stateErr != nil && !cloudflare.IsLegacyRunTokenState(state) {
|
||||||
|
return stateErr
|
||||||
|
}
|
||||||
|
credentials, err := readCloudflareTunnelCredentials(cloudflareTunnelCredentialsFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
values, err := (cloudflare.StoredTunnel{Credentials: credentials, Config: cloudflare.NewConfig(credentials.TunnelID)}).Values()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secrets["platform/cloudflare-tunnel"] = values
|
||||||
|
if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil {
|
||||||
|
return errors.New("save encrypted Cloudflare tunnel credentials")
|
||||||
|
}
|
||||||
|
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
|
||||||
|
return errors.New("Cloudflare tunnel credentials were saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
|
func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
|
||||||
|
|
@ -89,7 +142,7 @@ func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !present {
|
if !present {
|
||||||
return errors.New("Cloudflare tunnel credentials and config are not generated; run bootstrap reconcile first")
|
return errors.New("Cloudflare tunnel credentials and config are not generated; run cloudflare-tunnel import with a local credentials file")
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if cmd != nil && cmd.Context() != nil {
|
if cmd != nil && cmd.Context() != nil {
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,6 @@ type fakeCloudflareRouteAPI struct {
|
||||||
deleteCalls int
|
deleteCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*fakeCloudflareRouteAPI) ListAccounts(context.Context) ([]cloudflare.Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*fakeCloudflareRouteAPI) ListTunnels(context.Context, string, string) ([]cloudflare.Tunnel, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*fakeCloudflareRouteAPI) CreateTunnel(context.Context, string, string) (cloudflare.Tunnel, error) {
|
|
||||||
return cloudflare.Tunnel{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeCloudflareRouteAPI) EnsureCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
|
func (f *fakeCloudflareRouteAPI) EnsureCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
|
||||||
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
|
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
|
||||||
return &routeTestError{}
|
return &routeTestError{}
|
||||||
|
|
@ -180,3 +168,123 @@ func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) {
|
||||||
t.Fatal("route command error revealed the Cloudflare token")
|
t.Fatal("route command error revealed the Cloudflare token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCloudflareTunnelImportAcceptsAbsentOrLegacyState(t *testing.T) {
|
||||||
|
originalLoad := loadCloudflareTunnelConfig
|
||||||
|
originalRead := readCloudflareTunnelSecrets
|
||||||
|
originalWrite := writeCloudflareTunnelSecrets
|
||||||
|
originalInitialize := initializeCloudflareTunnelOpenBao
|
||||||
|
originalCredentials := readCloudflareTunnelCredentials
|
||||||
|
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadCloudflareTunnelConfig = originalLoad
|
||||||
|
readCloudflareTunnelSecrets = originalRead
|
||||||
|
writeCloudflareTunnelSecrets = originalWrite
|
||||||
|
initializeCloudflareTunnelOpenBao = originalInitialize
|
||||||
|
readCloudflareTunnelCredentials = originalCredentials
|
||||||
|
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
|
||||||
|
})
|
||||||
|
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
|
||||||
|
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
|
||||||
|
}
|
||||||
|
cloudflareTunnelConfigPath = "bootstrap.yaml"
|
||||||
|
cloudflareTunnelCredentialsFile = "local-credentials.json"
|
||||||
|
for _, state := range []map[string]map[string]string{
|
||||||
|
{},
|
||||||
|
{"platform/cloudflare-tunnel": {"token": "legacy-run-token"}},
|
||||||
|
} {
|
||||||
|
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
||||||
|
readCloudflareTunnelCredentials = func(path string) (cloudflare.Credentials, error) {
|
||||||
|
if path != cloudflareTunnelCredentialsFile {
|
||||||
|
t.Fatal("credentials were not read from the explicit file path")
|
||||||
|
}
|
||||||
|
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
|
||||||
|
}
|
||||||
|
written := false
|
||||||
|
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
|
||||||
|
state = values
|
||||||
|
written = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
initializeCloudflareTunnelOpenBao = func(config.Config) error {
|
||||||
|
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||||
|
if !written || err != nil || !present || len(stored.Config.Ingress) != 1 {
|
||||||
|
t.Fatal("OpenBao was not seeded after encrypted tunnel state was saved")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := runCloudflareTunnelImport(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); err != nil || !present || len(state["platform/cloudflare-tunnel"]) != 2 {
|
||||||
|
t.Fatal("credentials file was not imported as managed tunnel state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudflareTunnelImportRefusesManagedStateAndReturnsSafeSeedFailure(t *testing.T) {
|
||||||
|
originalLoad := loadCloudflareTunnelConfig
|
||||||
|
originalRead := readCloudflareTunnelSecrets
|
||||||
|
originalWrite := writeCloudflareTunnelSecrets
|
||||||
|
originalInitialize := initializeCloudflareTunnelOpenBao
|
||||||
|
originalCredentials := readCloudflareTunnelCredentials
|
||||||
|
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadCloudflareTunnelConfig = originalLoad
|
||||||
|
readCloudflareTunnelSecrets = originalRead
|
||||||
|
writeCloudflareTunnelSecrets = originalWrite
|
||||||
|
initializeCloudflareTunnelOpenBao = originalInitialize
|
||||||
|
readCloudflareTunnelCredentials = originalCredentials
|
||||||
|
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
|
||||||
|
})
|
||||||
|
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
|
||||||
|
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
|
||||||
|
}
|
||||||
|
cloudflareTunnelConfigPath = "bootstrap.yaml"
|
||||||
|
cloudflareTunnelCredentialsFile = "local-credentials.json"
|
||||||
|
managed, err := (cloudflare.StoredTunnel{Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, Config: cloudflare.NewConfig("tunnel")}).Values()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := map[string]map[string]string{"platform/cloudflare-tunnel": managed}
|
||||||
|
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
||||||
|
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
|
||||||
|
t.Fatal("managed state import read the credential file")
|
||||||
|
return cloudflare.Credentials{}, nil
|
||||||
|
}
|
||||||
|
writeCloudflareTunnelSecrets = func(string, string, map[string]map[string]string) error {
|
||||||
|
t.Fatal("managed state import overwrote encrypted state")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
initializeCloudflareTunnelOpenBao = func(config.Config) error {
|
||||||
|
t.Fatal("managed state import seeded OpenBao")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := runCloudflareTunnelImport(); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
|
||||||
|
t.Fatal("managed state import was not refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
state = map[string]map[string]string{}
|
||||||
|
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
|
||||||
|
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
|
||||||
|
}
|
||||||
|
written := false
|
||||||
|
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
|
||||||
|
written = true
|
||||||
|
state = values
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
initializeCloudflareTunnelOpenBao = func(config.Config) error {
|
||||||
|
if !written {
|
||||||
|
t.Fatal("OpenBao seeding ran before encrypted state was saved")
|
||||||
|
}
|
||||||
|
return errors.New("unavailable")
|
||||||
|
}
|
||||||
|
err = runCloudflareTunnelImport()
|
||||||
|
if err == nil || strings.Contains(err.Error(), "unavailable") {
|
||||||
|
t.Fatal("import did not return a safe OpenBao seeding error")
|
||||||
|
}
|
||||||
|
if _, present, parseErr := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); parseErr != nil || !present {
|
||||||
|
t.Fatal("import did not retain encrypted state after OpenBao seeding failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,9 @@ provider Secret directly.
|
||||||
## Webhook TLS
|
## Webhook TLS
|
||||||
|
|
||||||
The public Gateway terminates HTTPS with a cert-manager certificate. The
|
The public Gateway terminates HTTPS with a cert-manager certificate. The
|
||||||
Cloudflare API token issues the `nid3.com` certificate and manages the tunnel;
|
Cloudflare API token issues the `nid3.com` certificate and manages explicit
|
||||||
Pi-hole remains the ExternalDNS provider. Check certificate readiness with:
|
Tunnel CNAME records. Pi-hole remains the ExternalDNS provider. Check
|
||||||
|
certificate readiness with:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
kubectl -n cert-manager get externalsecret cloudflare-api-token
|
kubectl -n cert-manager get externalsecret cloudflare-api-token
|
||||||
|
|
@ -112,14 +113,19 @@ kubectl -n gateway-system get certificate webhook-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
Enter the Pi-hole values and Cloudflare API token through
|
Enter the Pi-hole values and Cloudflare API token through
|
||||||
`--prompt-operational-secrets`, then run `bootstrap --mode=reconcile` to create
|
`--prompt-operational-secrets`, then import the user-approved local credentials
|
||||||
the managed tunnel state. Do not put the Cloudflare token or tunnel credentials
|
file before bootstrap:
|
||||||
in the cluster repository.
|
|
||||||
|
```powershell
|
||||||
|
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not put the Cloudflare token or tunnel credentials in the cluster repository.
|
||||||
|
|
||||||
## Cloudflare Tunnel Routes
|
## Cloudflare Tunnel Routes
|
||||||
|
|
||||||
After reconcile has created the tunnel, manage one explicit proxied CNAME and
|
After importing the credentials, manage one explicit proxied CNAME and ingress
|
||||||
ingress rule at a time:
|
rule at a time:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
go run . cloudflare-tunnel route add --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>
|
go run . cloudflare-tunnel route add --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,19 @@ For a new operational-secret input, run `bootstrap --config <path>
|
||||||
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials
|
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials
|
||||||
from the configured Forgejo account, prompts for the Pi-hole server and masked
|
from the configured Forgejo account, prompts for the Pi-hole server and masked
|
||||||
password and Cloudflare API token, and generates the webhook authorization
|
password and Cloudflare API token, and generates the webhook authorization
|
||||||
value. The Cloudflare token issues the Gateway certificate and creates the
|
value. The Cloudflare token issues the Gateway certificate and manages explicit
|
||||||
CLI-managed tunnel; it is not used by ExternalDNS.
|
Tunnel CNAME records; it is not used by ExternalDNS.
|
||||||
|
|
||||||
`bootstrap` creates a named Cloudflare Tunnel only when
|
Import the user-approved local credential file before bootstrap validates the
|
||||||
`platform/cloudflare-tunnel` has no generated state. It stores only the
|
tunnel state:
|
||||||
credentials JSON and local config YAML shown above; it never saves a Tunnel run
|
|
||||||
token. The Cloudflare API token must have access to exactly one account.
|
```powershell
|
||||||
|
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
|
||||||
|
```
|
||||||
|
|
||||||
|
The import command reads the file only in memory, validates its credential
|
||||||
|
shape, stores only the credentials JSON and terminal-404 local config shown
|
||||||
|
above, and seeds OpenBao. It never saves a Tunnel run token.
|
||||||
|
|
||||||
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
|
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
|
||||||
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares
|
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,6 @@ var writeGeneratedSOPS = writeSOPSEncryptedFile
|
||||||
|
|
||||||
var readCloudflareOperationalSecrets = ReadOperationalSecrets
|
var readCloudflareOperationalSecrets = ReadOperationalSecrets
|
||||||
|
|
||||||
var writeCloudflareOperationalSecrets = WriteOperationalSecrets
|
|
||||||
|
|
||||||
var newCloudflareClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) }
|
|
||||||
|
|
||||||
var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorization string) error {
|
var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorization string) error {
|
||||||
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, cfg.Flux.ManifestsRepo, cfg.Flux.RepoName, cfg.Flux.Branch, "maidn/bootstrap-"+cfg.ClusterID)
|
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, cfg.Flux.ManifestsRepo, cfg.Flux.RepoName, cfg.Flux.Branch, "maidn/bootstrap-"+cfg.ClusterID)
|
||||||
return manager.EnsureWebhook(repo, webhookURL, authorization)
|
return manager.EnsureWebhook(repo, webhookURL, authorization)
|
||||||
|
|
@ -200,7 +196,7 @@ func (r Runner) Run() error {
|
||||||
if manager.MigrationPending {
|
if manager.MigrationPending {
|
||||||
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
|
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
|
||||||
}
|
}
|
||||||
if err := r.reconcileCloudflareTunnel(context.Background()); err != nil {
|
if err := r.reconcileCloudflareTunnel(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,58 +254,21 @@ func (r Runner) Run() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileCloudflareTunnel(ctx context.Context) error {
|
func (r Runner) reconcileCloudflareTunnel() error {
|
||||||
secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
|
secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
|
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
|
||||||
}
|
}
|
||||||
tunnelState := secrets["platform/cloudflare-tunnel"]
|
tunnelState := secrets["platform/cloudflare-tunnel"]
|
||||||
legacyRunToken := cloudflare.IsLegacyRunTokenState(tunnelState)
|
|
||||||
_, present, err := cloudflare.ParseStoredTunnel(tunnelState)
|
_, present, err := cloudflare.ParseStoredTunnel(tunnelState)
|
||||||
if err != nil && !legacyRunToken {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if present {
|
if present {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"])
|
if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) {
|
||||||
if token == "" {
|
return errors.New("Cloudflare tunnel credentials and config are not generated; run cicd-tool cloudflare-tunnel import --config <config> --credentials-file <local-file>")
|
||||||
return errors.New("encrypted operational secrets requires platform/cloudflare.api-token")
|
|
||||||
}
|
}
|
||||||
client := newCloudflareClient(token)
|
|
||||||
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
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -795,116 +794,47 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeCloudflareAPI struct {
|
func TestReconcileCloudflareTunnelValidatesManagedState(t *testing.T) {
|
||||||
accounts []cloudflare.Account
|
|
||||||
tunnels []cloudflare.Tunnel
|
|
||||||
created cloudflare.Tunnel
|
|
||||||
err error
|
|
||||||
creates int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeCloudflareAPI) ListAccounts(context.Context) ([]cloudflare.Account, error) {
|
|
||||||
return f.accounts, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeCloudflareAPI) ListTunnels(context.Context, string, string) ([]cloudflare.Tunnel, error) {
|
|
||||||
return f.tunnels, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeCloudflareAPI) CreateTunnel(context.Context, string, string) (cloudflare.Tunnel, error) {
|
|
||||||
f.creates++
|
|
||||||
return f.created, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*fakeCloudflareAPI) EnsureCNAME(context.Context, string, string, string) error { return nil }
|
|
||||||
|
|
||||||
func (*fakeCloudflareAPI) DeleteCNAME(context.Context, string, string, string) error { return nil }
|
|
||||||
|
|
||||||
func TestReconcileCloudflareTunnelCreatesEncryptedStateOnce(t *testing.T) {
|
|
||||||
originalRead := readCloudflareOperationalSecrets
|
originalRead := readCloudflareOperationalSecrets
|
||||||
originalWrite := writeCloudflareOperationalSecrets
|
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
||||||
originalClient := newCloudflareClient
|
values, err := (cloudflare.StoredTunnel{
|
||||||
t.Cleanup(func() {
|
Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"},
|
||||||
readCloudflareOperationalSecrets = originalRead
|
Config: cloudflare.NewConfig("tunnel"),
|
||||||
writeCloudflareOperationalSecrets = originalWrite
|
}).Values()
|
||||||
newCloudflareClient = originalClient
|
if err != nil {
|
||||||
})
|
t.Fatal(err)
|
||||||
state := map[string]map[string]string{
|
|
||||||
"platform/cloudflare": {"api-token": "test-api-token"},
|
|
||||||
"platform/cloudflare-tunnel": {"token": "legacy-run-token"},
|
|
||||||
"platform/pihole": {"password": "preserved"},
|
|
||||||
}
|
}
|
||||||
|
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
||||||
|
return map[string]map[string]string{"platform/cloudflare-tunnel": values}, nil
|
||||||
|
}
|
||||||
|
if err := (Runner{Config: config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}}).reconcileCloudflareTunnel(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileCloudflareTunnelRequiresImportForAbsentOrLegacyState(t *testing.T) {
|
||||||
|
originalRead := readCloudflareOperationalSecrets
|
||||||
|
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
||||||
|
for _, state := range []map[string]map[string]string{
|
||||||
|
nil,
|
||||||
|
{"platform/cloudflare-tunnel": {"token": "legacy-run-token"}},
|
||||||
|
} {
|
||||||
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
||||||
var saved map[string]map[string]string
|
err := (Runner{}).reconcileCloudflareTunnel()
|
||||||
writeCloudflareOperationalSecrets = func(_, _ string, values map[string]map[string]string) error {
|
if err == nil || !strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") {
|
||||||
saved = values
|
t.Fatal("missing Cloudflare tunnel state did not return a safe import instruction")
|
||||||
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) {
|
func TestReconcileCloudflareTunnelRejectsMalformedState(t *testing.T) {
|
||||||
originalRead := readCloudflareOperationalSecrets
|
originalRead := readCloudflareOperationalSecrets
|
||||||
originalClient := newCloudflareClient
|
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
||||||
t.Cleanup(func() {
|
|
||||||
readCloudflareOperationalSecrets = originalRead
|
|
||||||
newCloudflareClient = originalClient
|
|
||||||
})
|
|
||||||
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
||||||
return map[string]map[string]string{
|
return map[string]map[string]string{"platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"}}, nil
|
||||||
"platform/cloudflare": {"api-token": "test-api-token"},
|
|
||||||
"platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
called := false
|
err := (Runner{}).reconcileCloudflareTunnel()
|
||||||
newCloudflareClient = func(string) cloudflare.API {
|
if err == nil || strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") {
|
||||||
called = true
|
t.Fatal("malformed Cloudflare tunnel state was not rejected safely")
|
||||||
return &fakeCloudflareAPI{}
|
|
||||||
}
|
|
||||||
err := (Runner{Config: config.Config{ClusterID: "demo"}}).reconcileCloudflareTunnel(context.Background())
|
|
||||||
if err == nil || called {
|
|
||||||
t.Fatal("ambiguous Cloudflare tunnel state was not rejected before API access")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReconcileCloudflareTunnelRequiresOneAccessibleAccount(t *testing.T) {
|
|
||||||
originalRead := readCloudflareOperationalSecrets
|
|
||||||
originalClient := newCloudflareClient
|
|
||||||
t.Cleanup(func() {
|
|
||||||
readCloudflareOperationalSecrets = originalRead
|
|
||||||
newCloudflareClient = originalClient
|
|
||||||
})
|
|
||||||
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
|
||||||
return map[string]map[string]string{"platform/cloudflare": {"api-token": "test-api-token"}}, nil
|
|
||||||
}
|
|
||||||
client := &fakeCloudflareAPI{accounts: []cloudflare.Account{{ID: "first"}, {ID: "second"}}}
|
|
||||||
newCloudflareClient = func(string) cloudflare.API { return client }
|
|
||||||
err := (Runner{Config: config.Config{ClusterID: "demo"}}).reconcileCloudflareTunnel(context.Background())
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "exactly one account") || strings.Contains(err.Error(), "test-api-token") || client.creates != 0 {
|
|
||||||
t.Fatal("multiple Cloudflare accounts were not rejected safely before tunnel creation")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,24 +14,12 @@ import (
|
||||||
|
|
||||||
const apiURL = "https://api.cloudflare.com/client/v4"
|
const apiURL = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
// API is the narrow Cloudflare boundary used by the bootstrap and route commands.
|
// API is the narrow Cloudflare DNS boundary used by route commands.
|
||||||
type API interface {
|
type API interface {
|
||||||
ListAccounts(context.Context) ([]Account, error)
|
|
||||||
ListTunnels(context.Context, string, string) ([]Tunnel, error)
|
|
||||||
CreateTunnel(context.Context, string, string) (Tunnel, error)
|
|
||||||
EnsureCNAME(context.Context, string, string, string) error
|
EnsureCNAME(context.Context, string, string, string) error
|
||||||
DeleteCNAME(context.Context, string, string, string) error
|
DeleteCNAME(context.Context, string, string, string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Account struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tunnel struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
TunnelSecret string `json:"tunnel_secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type dnsRecord struct {
|
type dnsRecord struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -49,32 +37,6 @@ func NewClient(token string) *Client {
|
||||||
return &Client{baseURL: apiURL, httpClient: &http.Client{Timeout: 15 * time.Second}, token: token}
|
return &Client{baseURL: apiURL, httpClient: &http.Client{Timeout: 15 * time.Second}, token: token}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListAccounts(ctx context.Context) ([]Account, error) {
|
|
||||||
var accounts []Account
|
|
||||||
err := c.request(ctx, http.MethodGet, "/accounts", nil, &accounts)
|
|
||||||
return accounts, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) ListTunnels(ctx context.Context, accountID, name string) ([]Tunnel, error) {
|
|
||||||
var tunnels []Tunnel
|
|
||||||
path := "/accounts/" + url.PathEscape(accountID) + "/cfd_tunnel?" + url.Values{"name": {name}}.Encode()
|
|
||||||
err := c.request(ctx, http.MethodGet, path, nil, &tunnels)
|
|
||||||
return tunnels, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CreateTunnel(ctx context.Context, accountID, name string) (Tunnel, error) {
|
|
||||||
var tunnel Tunnel
|
|
||||||
body, err := json.Marshal(struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ConfigSrc string `json:"config_src"`
|
|
||||||
}{Name: name, ConfigSrc: "local"})
|
|
||||||
if err != nil {
|
|
||||||
return tunnel, errors.New("encode Cloudflare tunnel request")
|
|
||||||
}
|
|
||||||
err = c.request(ctx, http.MethodPost, "/accounts/"+url.PathEscape(accountID)+"/cfd_tunnel", body, &tunnel)
|
|
||||||
return tunnel, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) EnsureCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
|
func (c *Client) EnsureCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
|
||||||
records, err := c.cnameRecords(ctx, zoneID, hostname)
|
records, err := c.cnameRecords(ctx, zoneID, hostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClientTunnelAndDNSLifecycle(t *testing.T) {
|
func TestClientCreatesTunnelCNAME(t *testing.T) {
|
||||||
requests := 0
|
requests := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
requests++
|
requests++
|
||||||
|
|
@ -19,19 +19,6 @@ func TestClientTunnelAndDNSLifecycle(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch request.Method + " " + request.URL.Path {
|
switch request.Method + " " + request.URL.Path {
|
||||||
case "GET /accounts":
|
|
||||||
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []map[string]string{{"id": "account-id"}}})
|
|
||||||
case "GET /accounts/account-id/cfd_tunnel":
|
|
||||||
if request.URL.Query().Get("name") != "maidn-demo" {
|
|
||||||
t.Error("tunnel lookup used the wrong name")
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []any{}})
|
|
||||||
case "POST /accounts/account-id/cfd_tunnel":
|
|
||||||
var body map[string]string
|
|
||||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body["name"] != "maidn-demo" || body["config_src"] != "local" {
|
|
||||||
t.Error("tunnel create request was invalid")
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "tunnel-id", "tunnel_secret": "test-tunnel-secret"}})
|
|
||||||
case "GET /zones/zone-id/dns_records":
|
case "GET /zones/zone-id/dns_records":
|
||||||
if request.URL.Query().Get("type") != "CNAME" || request.URL.Query().Get("name") != "app.example.test" {
|
if request.URL.Query().Get("type") != "CNAME" || request.URL.Query().Get("name") != "app.example.test" {
|
||||||
t.Error("DNS lookup was invalid")
|
t.Error("DNS lookup was invalid")
|
||||||
|
|
@ -53,23 +40,11 @@ func TestClientTunnelAndDNSLifecycle(t *testing.T) {
|
||||||
client := NewClient("test-api-token")
|
client := NewClient("test-api-token")
|
||||||
client.baseURL = server.URL
|
client.baseURL = server.URL
|
||||||
client.httpClient = server.Client()
|
client.httpClient = server.Client()
|
||||||
accounts, err := client.ListAccounts(context.Background())
|
if err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil {
|
||||||
if err != nil || len(accounts) != 1 || accounts[0].ID != "account-id" {
|
|
||||||
t.Fatal("account lookup failed")
|
|
||||||
}
|
|
||||||
tunnels, err := client.ListTunnels(context.Background(), accounts[0].ID, "maidn-demo")
|
|
||||||
if err != nil || len(tunnels) != 0 {
|
|
||||||
t.Fatal("tunnel lookup failed")
|
|
||||||
}
|
|
||||||
tunnel, err := client.CreateTunnel(context.Background(), accounts[0].ID, "maidn-demo")
|
|
||||||
if err != nil || tunnel.ID != "tunnel-id" || tunnel.TunnelSecret != "test-tunnel-secret" {
|
|
||||||
t.Fatal("tunnel create failed")
|
|
||||||
}
|
|
||||||
if err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", tunnel.ID); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if requests != 5 {
|
if requests != 2 {
|
||||||
t.Fatalf("expected five Cloudflare requests, got %d", requests)
|
t.Fatalf("expected two Cloudflare requests, got %d", requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +57,7 @@ func TestClientFailureDoesNotRevealToken(t *testing.T) {
|
||||||
client := NewClient("test-api-token")
|
client := NewClient("test-api-token")
|
||||||
client.baseURL = server.URL
|
client.baseURL = server.URL
|
||||||
client.httpClient = server.Client()
|
client.httpClient = server.Client()
|
||||||
_, err := client.ListAccounts(context.Background())
|
err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id")
|
||||||
if err == nil || strings.Contains(err.Error(), "test-api-token") {
|
if err == nil || strings.Contains(err.Error(), "test-api-token") {
|
||||||
t.Fatal("Cloudflare API failure exposed the token")
|
t.Fatal("Cloudflare API failure exposed the token")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package cloudflare
|
package cloudflare
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -41,10 +43,6 @@ type StoredTunnel struct {
|
||||||
Config Config
|
Config Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func TunnelName(clusterID string) string {
|
|
||||||
return "maidn-" + clusterID
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConfig(tunnelID string) Config {
|
func NewConfig(tunnelID string) Config {
|
||||||
return Config{Tunnel: tunnelID, CredentialsFile: credentialsFile, Ingress: []Route{{Service: "http_status:404"}}}
|
return Config{Tunnel: tunnelID, CredentialsFile: credentialsFile, Ingress: []Route{{Service: "http_status:404"}}}
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +51,47 @@ func IsLegacyRunTokenState(values map[string]string) bool {
|
||||||
return len(values) == 1 && strings.TrimSpace(values["token"]) != ""
|
return len(values) == 1 && strings.TrimSpace(values["token"]) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReadCredentialsFile(path string) (Credentials, error) {
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Credentials{}, errors.New("invalid Cloudflare credentials file")
|
||||||
|
}
|
||||||
|
if credentials, err := parseCredentials(contents); err == nil {
|
||||||
|
return credentials, nil
|
||||||
|
}
|
||||||
|
credentials, err := credentialsFromSecret(contents)
|
||||||
|
if err != nil {
|
||||||
|
return Credentials{}, errors.New("invalid Cloudflare credentials file")
|
||||||
|
}
|
||||||
|
return credentials, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialsFromSecret(contents []byte) (Credentials, error) {
|
||||||
|
var secret struct {
|
||||||
|
APIVersion string `yaml:"apiVersion"`
|
||||||
|
Kind string `yaml:"kind"`
|
||||||
|
StringData map[string]string `yaml:"stringData"`
|
||||||
|
Data map[string]string `yaml:"data"`
|
||||||
|
}
|
||||||
|
decoder := yaml.NewDecoder(strings.NewReader(string(contents)))
|
||||||
|
if err := decoder.Decode(&secret); err != nil || decoder.Decode(&struct{}{}) != io.EOF || secret.APIVersion != "v1" || secret.Kind != "Secret" {
|
||||||
|
return Credentials{}, errors.New("invalid Secret")
|
||||||
|
}
|
||||||
|
plaintext, inStringData := secret.StringData[credentialsKey]
|
||||||
|
encoded, inData := secret.Data[credentialsKey]
|
||||||
|
if inStringData == inData {
|
||||||
|
return Credentials{}, errors.New("missing Secret credentials")
|
||||||
|
}
|
||||||
|
if inStringData {
|
||||||
|
return parseCredentials([]byte(plaintext))
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return Credentials{}, errors.New("invalid Secret credentials")
|
||||||
|
}
|
||||||
|
return parseCredentials(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
func NewRoute(hostname, service string) (Route, error) {
|
func NewRoute(hostname, service string) (Route, error) {
|
||||||
hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), "."))
|
hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), "."))
|
||||||
if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) {
|
if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) {
|
||||||
|
|
@ -77,11 +116,9 @@ func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) {
|
||||||
if !hasCredentials || !hasConfig || len(values) != 2 {
|
if !hasCredentials || !hasConfig || len(values) != 2 {
|
||||||
return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml")
|
return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml")
|
||||||
}
|
}
|
||||||
var credentials Credentials
|
credentials, err := parseCredentials([]byte(credentialsJSON))
|
||||||
decoder := json.NewDecoder(strings.NewReader(credentialsJSON))
|
if err != nil {
|
||||||
decoder.DisallowUnknownFields()
|
return StoredTunnel{}, false, err
|
||||||
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" {
|
|
||||||
return StoredTunnel{}, false, errors.New("Cloudflare tunnel credentials are invalid")
|
|
||||||
}
|
}
|
||||||
var config Config
|
var config Config
|
||||||
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML))
|
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML))
|
||||||
|
|
@ -92,6 +129,16 @@ func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) {
|
||||||
return StoredTunnel{Credentials: credentials, Config: config}, true, nil
|
return StoredTunnel{Credentials: credentials, Config: config}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseCredentials(contents []byte) (Credentials, error) {
|
||||||
|
var credentials Credentials
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(contents)))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" {
|
||||||
|
return Credentials{}, errors.New("Cloudflare tunnel credentials are invalid")
|
||||||
|
}
|
||||||
|
return credentials, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s StoredTunnel) Values() (map[string]string, error) {
|
func (s StoredTunnel) Values() (map[string]string, error) {
|
||||||
credentials, err := json.Marshal(s.Credentials)
|
credentials, err := json.Marshal(s.Credentials)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package cloudflare
|
package cloudflare
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) {
|
func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) {
|
||||||
stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")}
|
stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")}
|
||||||
|
|
@ -25,6 +30,36 @@ func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadCredentialsFileRequiresKnownFields(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||||
|
raw := `{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel"}`
|
||||||
|
for _, contents := range []string{
|
||||||
|
raw,
|
||||||
|
"apiVersion: v1\nkind: Secret\nstringData:\n credentials.json: |\n " + raw + "\n",
|
||||||
|
"apiVersion: v1\nkind: Secret\ndata:\n credentials.json: " + base64.StdEncoding.EncodeToString([]byte(raw)) + "\n",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credentials, err := ReadCredentialsFile(path)
|
||||||
|
if err != nil || credentials.TunnelID != "tunnel" {
|
||||||
|
t.Fatal("supported credential file was not accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(`{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel","extra":"value"}`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" {
|
||||||
|
t.Fatal("credential file with unknown fields did not return a safe error")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("apiVersion: v1\nkind: Secret\ndata: {}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" {
|
||||||
|
t.Fatal("Secret without credentials did not return a safe error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) {
|
func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) {
|
||||||
config := NewConfig("tunnel")
|
config := NewConfig("tunnel")
|
||||||
route, err := NewRoute("app.example.test", "http://service.default.svc:8080")
|
route, err := NewRoute("app.example.test", "http://service.default.svc:8080")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue