Compare commits
No commits in common. "92ae57121d4af18418a3f9fa26eedfd2b057da2a" and "de8e8209cfa5599b8a3b30944106482ece44c016" have entirely different histories.
92ae57121d
...
de8e8209cf
|
|
@ -16,14 +16,12 @@ 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",
|
||||||
|
|
@ -48,17 +46,9 @@ 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, cloudflareTunnelImportCmd)
|
cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd)
|
||||||
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} {
|
||||||
|
|
@ -69,49 +59,6 @@ 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 {
|
||||||
|
|
@ -142,7 +89,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 cloudflare-tunnel import with a local credentials file")
|
return errors.New("Cloudflare tunnel credentials and config are not generated; run bootstrap reconcile first")
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if cmd != nil && cmd.Context() != nil {
|
if cmd != nil && cmd.Context() != nil {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,18 @@ 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{}
|
||||||
|
|
@ -168,123 +180,3 @@ 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,9 +103,8 @@ 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 explicit
|
Cloudflare API token issues the `nid3.com` certificate and manages the tunnel;
|
||||||
Tunnel CNAME records. Pi-hole remains the ExternalDNS provider. Check
|
Pi-hole remains the ExternalDNS provider. Check certificate readiness with:
|
||||||
certificate readiness with:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
kubectl -n cert-manager get externalsecret cloudflare-api-token
|
kubectl -n cert-manager get externalsecret cloudflare-api-token
|
||||||
|
|
@ -113,19 +112,14 @@ 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 import the user-approved local credentials
|
`--prompt-operational-secrets`, then run `bootstrap --mode=reconcile` to create
|
||||||
file before bootstrap:
|
the managed tunnel state. Do not put the Cloudflare token or tunnel credentials
|
||||||
|
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 importing the credentials, manage one explicit proxied CNAME and ingress
|
After reconcile has created the tunnel, manage one explicit proxied CNAME and
|
||||||
rule at a time:
|
ingress 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,19 +40,13 @@ 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 manages explicit
|
value. The Cloudflare token issues the Gateway certificate and creates the
|
||||||
Tunnel CNAME records; it is not used by ExternalDNS.
|
CLI-managed tunnel; it is not used by ExternalDNS.
|
||||||
|
|
||||||
Import the user-approved local credential file before bootstrap validates the
|
`bootstrap` creates a named Cloudflare Tunnel only when
|
||||||
tunnel state:
|
`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
|
||||||
```powershell
|
token. The Cloudflare API token must have access to exactly one account.
|
||||||
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,6 +54,10 @@ 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)
|
||||||
|
|
@ -196,7 +200,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(); err != nil {
|
if err := r.reconcileCloudflareTunnel(context.Background()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,20 +258,57 @@ func (r Runner) Run() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileCloudflareTunnel() error {
|
func (r Runner) reconcileCloudflareTunnel(ctx context.Context) 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
|
||||||
}
|
}
|
||||||
if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) {
|
token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"])
|
||||||
return errors.New("Cloudflare tunnel credentials and config are not generated; run cicd-tool cloudflare-tunnel import --config <config> --credentials-file <local-file>")
|
if token == "" {
|
||||||
|
return errors.New("encrypted operational secrets requires platform/cloudflare.api-token")
|
||||||
}
|
}
|
||||||
return err
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -794,47 +795,116 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileCloudflareTunnelValidatesManagedState(t *testing.T) {
|
type fakeCloudflareAPI struct {
|
||||||
originalRead := readCloudflareOperationalSecrets
|
accounts []cloudflare.Account
|
||||||
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
tunnels []cloudflare.Tunnel
|
||||||
values, err := (cloudflare.StoredTunnel{
|
created cloudflare.Tunnel
|
||||||
Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"},
|
err error
|
||||||
Config: cloudflare.NewConfig("tunnel"),
|
creates int
|
||||||
}).Values()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
|
||||||
return map[string]map[string]string{"platform/cloudflare-tunnel": values}, nil
|
|
||||||
}
|
|
||||||
if err := (Runner{Config: config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}}).reconcileCloudflareTunnel(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileCloudflareTunnelRequiresImportForAbsentOrLegacyState(t *testing.T) {
|
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
|
||||||
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
originalWrite := writeCloudflareOperationalSecrets
|
||||||
for _, state := range []map[string]map[string]string{
|
originalClient := newCloudflareClient
|
||||||
nil,
|
t.Cleanup(func() {
|
||||||
{"platform/cloudflare-tunnel": {"token": "legacy-run-token"}},
|
readCloudflareOperationalSecrets = originalRead
|
||||||
} {
|
writeCloudflareOperationalSecrets = originalWrite
|
||||||
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
newCloudflareClient = originalClient
|
||||||
err := (Runner{}).reconcileCloudflareTunnel()
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") {
|
state := map[string]map[string]string{
|
||||||
t.Fatal("missing Cloudflare tunnel state did not return a safe import instruction")
|
"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 TestReconcileCloudflareTunnelRejectsMalformedState(t *testing.T) {
|
func TestReconcileCloudflareTunnelRejectsAmbiguousStateWithoutAPICall(t *testing.T) {
|
||||||
originalRead := readCloudflareOperationalSecrets
|
originalRead := readCloudflareOperationalSecrets
|
||||||
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
originalClient := newCloudflareClient
|
||||||
|
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{"platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"}}, nil
|
return map[string]map[string]string{
|
||||||
|
"platform/cloudflare": {"api-token": "test-api-token"},
|
||||||
|
"platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
err := (Runner{}).reconcileCloudflareTunnel()
|
called := false
|
||||||
if err == nil || strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") {
|
newCloudflareClient = func(string) cloudflare.API {
|
||||||
t.Fatal("malformed Cloudflare tunnel state was not rejected safely")
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,24 @@ import (
|
||||||
|
|
||||||
const apiURL = "https://api.cloudflare.com/client/v4"
|
const apiURL = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
// API is the narrow Cloudflare DNS boundary used by route commands.
|
// API is the narrow Cloudflare boundary used by the bootstrap and 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"`
|
||||||
|
|
@ -37,6 +49,32 @@ 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 TestClientCreatesTunnelCNAME(t *testing.T) {
|
func TestClientTunnelAndDNSLifecycle(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,6 +19,19 @@ func TestClientCreatesTunnelCNAME(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")
|
||||||
|
|
@ -40,11 +53,23 @@ func TestClientCreatesTunnelCNAME(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()
|
||||||
if err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if requests != 2 {
|
if requests != 5 {
|
||||||
t.Fatalf("expected two Cloudflare requests, got %d", requests)
|
t.Fatalf("expected five Cloudflare requests, got %d", requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +82,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.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id")
|
_, err := client.ListAccounts(context.Background())
|
||||||
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,12 +1,10 @@
|
||||||
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"
|
||||||
|
|
||||||
|
|
@ -43,6 +41,10 @@ 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"}}}
|
||||||
}
|
}
|
||||||
|
|
@ -51,47 +53,6 @@ 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) {
|
||||||
|
|
@ -116,9 +77,11 @@ 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")
|
||||||
}
|
}
|
||||||
credentials, err := parseCredentials([]byte(credentialsJSON))
|
var credentials Credentials
|
||||||
if err != nil {
|
decoder := json.NewDecoder(strings.NewReader(credentialsJSON))
|
||||||
return StoredTunnel{}, false, err
|
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
|
var config Config
|
||||||
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML))
|
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML))
|
||||||
|
|
@ -129,16 +92,6 @@ 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,11 +1,6 @@
|
||||||
package cloudflare
|
package cloudflare
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"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")}
|
||||||
|
|
@ -30,36 +25,6 @@ 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