package cmd import ( "crypto/rand" "encoding/base64" "fmt" "math/big" "os" "os/exec" "strings" "kforge/internal/config" "github.com/spf13/cobra" ) // passwordChars mirrors the character set from your original // shell command: A-Za-z0-9 + printable special chars. const passwordChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_{|}~` var ( secretsApplyEnvs []string secretsApplyForce bool ) var secretsApplyCmd = &cobra.Command{ Use: "apply", Short: "Generate and apply cluster secrets for an environment", Long: `Generates secure random credentials for all enabled infrastructure services and creates/updates Kubernetes Secrets in the cluster. Secrets are created with kubectl — KUBE_HOST, KUBE_TOKEN, and KUBE_CERTIFICATE must be set in the environment (Gitea injects these automatically during CI runs). Already-existing secrets are NOT overwritten unless --force is passed. This prevents accidental credential rotation. Examples: kforge secrets apply --env staging kforge secrets apply --env production --force`, RunE: runSecretsApply, } func init() { secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil, "Environment(s) to apply secrets for (required)") secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false, "Overwrite existing secrets (triggers credential rotation)") _ = secretsApplyCmd.MarkFlagRequired("env") secretsCmd.AddCommand(secretsApplyCmd) } func runSecretsApply(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } for _, envKey := range secretsApplyEnvs { fmt.Printf("\nApplying secrets for environment: %s\n", envKey) if err := applySecretsForEnv(cfg, envKey); err != nil { return fmt.Errorf("env %q: %w", envKey, err) } } return nil } func applySecretsForEnv(cfg *config.KforgeConfig, envKey string) error { env, err := config.ResolveEnvironment(cfg, envKey) if err != nil { return err } // Basic auth htpasswd secret if env.Ingress.Auth.Enabled { if err := applyBasicAuthSecret(&env); err != nil { return fmt.Errorf("basic auth: %w", err) } } infra := env.Infrastructure if infra.Cache != nil { if err := applyGenericSecret( env.FullName+"-cache-credentials", env.Namespace, map[string]string{"password": generatePassword(32)}, ); err != nil { return fmt.Errorf("cache credentials: %w", err) } } if infra.Storage != nil { if err := applyGenericSecret( env.FullName+"-storage-credentials", env.Namespace, map[string]string{ "access_key": generateAlphanumeric(20), "secret_key": generatePassword(40), }, ); err != nil { return fmt.Errorf("storage credentials: %w", err) } } if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" { if err := applyGenericSecret( env.FullName+"-queue-credentials", env.Namespace, map[string]string{ "username": "kforge", "password": generatePassword(32), }, ); err != nil { return fmt.Errorf("queue credentials: %w", err) } } if infra.Search != nil { if err := applyGenericSecret( env.FullName+"-search-credentials", env.Namespace, map[string]string{"master_key": generatePassword(48)}, ); err != nil { return fmt.Errorf("search credentials: %w", err) } } return nil } // applyBasicAuthSecret generates an htpasswd entry for each user // and stores it in the basic-auth Secret. func applyBasicAuthSecret(env *config.ResolvedEnvironment) error { auth := env.Ingress.Auth if len(auth.Users) == 0 { return fmt.Errorf("auth.users must contain at least one username") } var htpasswdLines []string fmt.Printf(" Generating basic auth credentials:\n") for _, username := range auth.Users { password := generatePassword(32) // Generate bcrypt hash using htpasswd (available on most systems) // or fall back to a simple SHA1 if htpasswd isn't available. hash, err := generateHTPasswdEntry(username, password) if err != nil { return fmt.Errorf("hashing password for %s: %w", username, err) } htpasswdLines = append(htpasswdLines, hash) fmt.Printf(" user: %-20s password: %s\n", username, password) fmt.Printf(" ⚠ Save this password now — it will not be shown again.\n") } htpasswd := strings.Join(htpasswdLines, "\n") + "\n" return applyGenericSecret( auth.SecretName, env.Namespace, map[string]string{"auth": htpasswd}, ) } // generateHTPasswdEntry produces a username:bcrypt_hash string. // Uses htpasswd binary if available, otherwise uses openssl. func generateHTPasswdEntry(username, password string) (string, error) { // Try htpasswd first (apache2-utils package). if path, err := exec.LookPath("htpasswd"); err == nil { out, err := exec.Command(path, "-nbB", username, password).Output() if err == nil { return strings.TrimSpace(string(out)), nil } } // Fall back to openssl passwd -apr1 (MD5 crypt, still widely supported). if path, err := exec.LookPath("openssl"); err == nil { out, err := exec.Command(path, "passwd", "-apr1", password).Output() if err == nil { return username + ":" + strings.TrimSpace(string(out)), nil } } return "", fmt.Errorf("neither htpasswd nor openssl found; install apache2-utils") } // applyGenericSecret creates or updates a Kubernetes Secret using // kubectl. Skips creation if the secret already exists and --force // was not passed. func applyGenericSecret(name, namespace string, data map[string]string) error { // Check if secret already exists. checkCmd := kubectlCmd("get", "secret", name, "-n", namespace, "--ignore-not-found") out, err := checkCmd.Output() if err != nil { return fmt.Errorf("checking secret %s: %w", name, err) } exists := strings.TrimSpace(string(out)) != "" if exists && !secretsApplyForce { fmt.Printf(" ✓ secret %s already exists (use --force to rotate)\n", name) return nil } // Build kubectl create secret generic args. args := []string{ "create", "secret", "generic", name, "-n", namespace, "--save-config", "--dry-run=client", "-o", "yaml", } for k, v := range data { args = append(args, fmt.Sprintf("--from-literal=%s=%s", k, v)) } // Pipe through kubectl apply to handle create-or-update. createCmd := kubectlCmd(args...) yamlBytes, err := createCmd.Output() if err != nil { return fmt.Errorf("generating secret manifest for %s: %w", name, err) } applyCmd := kubectlCmd("apply", "-f", "-", "-n", namespace) applyCmd.Stdin = strings.NewReader(string(yamlBytes)) applyCmd.Stdout = os.Stdout applyCmd.Stderr = os.Stderr if err := applyCmd.Run(); err != nil { return fmt.Errorf("applying secret %s: %w", name, err) } action := "created" if exists { action = "rotated" } fmt.Printf(" ✓ secret %s %s\n", name, action) return nil } // kubectlCmd builds a kubectl invocation using the KUBE_HOST, // KUBE_TOKEN, and KUBE_CERTIFICATE env vars for auth — the same // pattern used in your existing Gitea Actions workflow. func kubectlCmd(args ...string) *exec.Cmd { base := []string{} if host := os.Getenv("KUBE_HOST"); host != "" { base = append(base, "--server="+host) } if token := os.Getenv("KUBE_TOKEN"); token != "" { base = append(base, "--token="+token) } if cert := os.Getenv("KUBE_CERTIFICATE"); cert != "" { // KUBE_CERTIFICATE is the base64-encoded CA cert. // Decode it to a temp file or pass inline. decoded, err := base64.StdEncoding.DecodeString(cert) if err == nil { // Write to a temp file for kubectl. f, err := os.CreateTemp("", "kforge-ca-*.crt") if err == nil { _, _ = f.Write(decoded) f.Close() base = append(base, "--certificate-authority="+f.Name()) } } } else { // No cert provided — use insecure skip (matches your current workflow). base = append(base, "--insecure-skip-tls-verify=true") } cmd := exec.Command("kubectl", append(base, args...)...) return cmd } // ------------------------------------------------------------ // Password generation // ------------------------------------------------------------ // generatePassword produces a cryptographically random password // of length n using the full printable ASCII character set. // Mirrors: tr -dc 'A-Za-z0-9!"#$%&...'