maidn-cli/cmd/cloudflare_tunnel.go

198 lines
7.3 KiB
Go

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