package openbao import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "time" "github.com/Pingu-Studio/MaidnCLI/internal/config" "gopkg.in/yaml.v3" ) type status struct { Initialized bool `json:"initialized"` Sealed bool `json:"sealed"` } type RecoveryMaterial struct { UnsealKeysB64 []string `json:"unseal_keys_b64"` UnsealThreshold int `json:"unseal_threshold"` RootToken string `json:"root_token"` } type operationalSecrets struct { Secrets map[string]map[string]string `yaml:"secrets"` } var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() cmd := exec.CommandContext(ctx, "age", "-d", "-i", identityPath, bundlePath) output, err := cmd.Output() if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { return nil, fmt.Errorf("decrypt OpenBao recovery material timed out after %s", commandTimeout) } return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err) } return output, nil } var openBaoStatus = getStatus var commandTimeout = time.Minute var openBaoMutationTimeout = 5 * time.Minute func EnsureRecoveryIdentity(identityPath string) (string, error) { if _, err := os.Stat(identityPath); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil { return "", err } if err := exec.Command("age-keygen", "-o", identityPath).Run(); err != nil { return "", fmt.Errorf("create OpenBao recovery identity: %w", err) } } else if err != nil { return "", err } recipient, err := exec.Command("age-keygen", "-y", identityPath).Output() if err != nil { return "", fmt.Errorf("derive OpenBao recovery recipient: %w", err) } if value := strings.TrimSpace(string(recipient)); value != "" { return value, nil } return "", fmt.Errorf("OpenBao recovery recipient is empty") } func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) { fmt.Fprintln(os.Stderr, "OpenBao: validate recovery recipient") if err := validateRecoveryRecipient(recipient, bundlePath); err != nil { return nil, err } fmt.Fprintln(os.Stderr, "OpenBao: wait for pod") if err := waitForPod(kubeconfig); err != nil { return nil, err } fmt.Fprintln(os.Stderr, "OpenBao: read status") current, err := getStatus(kubeconfig) if err != nil { return nil, err } var material RecoveryMaterial if !current.Initialized { fmt.Fprintln(os.Stderr, "OpenBao: initialize") output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json") if err != nil { return nil, fmt.Errorf("initialize OpenBao: %w", err) } material, err = parseRecoveryMaterial(output) if err != nil { return nil, err } if err := encryptRecovery(recipient, bundlePath, output); err != nil { return nil, err } } else { fmt.Fprintln(os.Stderr, "OpenBao: decrypt recovery material") material, err = ReadRecoveryMaterial(identityPath, bundlePath) if err != nil { return nil, err } } if current.Sealed { fmt.Fprintln(os.Stderr, "OpenBao: unseal") if err := unseal(kubeconfig, material); err != nil { return nil, err } } reviewerToken, err := kubectlOutput(kubeconfig, "-n", "openbao", "create", "token", "openbao-auth", "--duration=8760h") if err != nil { return nil, fmt.Errorf("create OpenBao Kubernetes token reviewer token: %w", err) } fmt.Fprintln(os.Stderr, "OpenBao: configure Kubernetes auth") if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil { return nil, err } fmt.Fprintln(os.Stderr, "OpenBao: seed operational secrets") secrets, err := seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath) if err != nil { return nil, err } fmt.Fprintln(os.Stderr, "OpenBao: refresh External Secrets") if err := refreshExternalSecrets(kubeconfig); err != nil { return nil, err } return secrets, nil } func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() cmd := exec.CommandContext(ctx, "sops", "--decrypt", "--output-type", "yaml", path) cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath) plaintext, err := cmd.Output() if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { return nil, fmt.Errorf("decrypt operational SOPS secrets timed out after %s", commandTimeout) } return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err) } var document operationalSecrets if err := yaml.Unmarshal(plaintext, &document); err != nil { return nil, fmt.Errorf("parse operational SOPS secrets: %w", err) } if len(document.Secrets) == 0 { return nil, fmt.Errorf("operational SOPS secrets contains no secrets") } paths := make([]string, 0, len(document.Secrets)) for secretPath := range document.Secrets { paths = append(paths, secretPath) } sort.Strings(paths) for _, secretPath := range paths { values := document.Secrets[secretPath] if err := writeSecret(kubeconfig, rootToken, secretPath, values); err != nil { return nil, err } } return document.Secrets, nil } func validateRecoveryRecipient(recipient, bundlePath string) error { if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil { return err } probe, err := os.CreateTemp(filepath.Dir(bundlePath), "age-recipient-*") if err != nil { return err } probePath := probe.Name() if err := probe.Close(); err != nil { return err } defer os.Remove(probePath) ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() cmd := exec.CommandContext(ctx, "age", "-r", recipient, "-o", probePath) cmd.Stdin = bytes.NewReader(nil) if output, err := cmd.CombinedOutput(); err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { return fmt.Errorf("validate OpenBao recovery recipient timed out after %s", commandTimeout) } return fmt.Errorf("validate OpenBao recovery recipient: %w: %s", err, bytes.TrimSpace(output)) } return nil } // ReadRecoveryMaterial decrypts and validates the local OpenBao recovery bundle. func ReadRecoveryMaterial(identityPath, bundlePath string) (RecoveryMaterial, error) { plaintext, err := decryptRecovery(identityPath, bundlePath) if err != nil { return RecoveryMaterial{}, err } return parseRecoveryMaterial(plaintext) } func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) { var material RecoveryMaterial decoder := json.NewDecoder(bytes.NewReader(plaintext)) if err := decoder.Decode(&material); err != nil { return RecoveryMaterial{}, fmt.Errorf("parse OpenBao recovery material: %w", err) } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material must contain one JSON document") } if material.RootToken == "" || strings.TrimSpace(material.RootToken) != material.RootToken { return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material requires a root token") } if material.UnsealThreshold < 1 { return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material requires a positive unseal threshold") } if len(material.UnsealKeysB64) < material.UnsealThreshold { return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains fewer than %d unseal keys", material.UnsealThreshold) } seen := make(map[string]bool, len(material.UnsealKeysB64)) for _, key := range material.UnsealKeysB64 { if key == "" || strings.TrimSpace(key) != key { return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains an invalid unseal key") } if seen[key] { return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains duplicate unseal keys") } seen[key] = true } return material, nil } func writeSecret(kubeconfig, token, secretPath string, values map[string]string) error { if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 { return fmt.Errorf("invalid OpenBao secret path %q", secretPath) } keys := make([]string, 0, len(values)) for key := range values { if !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(key) { return fmt.Errorf("invalid OpenBao secret key %q", key) } keys = append(keys, key) } sort.Strings(keys) arguments := make([]string, 0, len(keys)) input := strings.Builder{} input.WriteString(token) input.WriteByte('\n') for _, key := range keys { arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments))) input.WriteString(base64.StdEncoding.EncodeToString([]byte(values[key]))) input.WriteByte('\n') } reads := make([]string, 0, len(keys)) decodes := make([]string, 0, len(keys)) for index := range keys { reads = append(reads, fmt.Sprintf("read -r value%d_b64", index)) decodes = append(decodes, fmt.Sprintf("value%d=$(printf '%%s' \"$value%d_b64\" | base64 -d; printf x)\nvalue%d=${value%d%%x}", index, index, index, index)) } script := "read -r token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null" _, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script) if err != nil { return fmt.Errorf("write OpenBao secret %q", secretPath) } return nil } func waitForPod(kubeconfig string) error { deadline := time.Now().Add(10 * time.Minute) var lastErr error for time.Now().Before(deadline) { if _, err := getStatus(kubeconfig); err == nil { return nil } else { lastErr = err } time.Sleep(2 * time.Second) } if lastErr != nil { return fmt.Errorf("OpenBao pod did not become ready: %w", lastErr) } return fmt.Errorf("OpenBao pod did not become ready") } func getStatus(kubeconfig string) (status, error) { command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "openbao-0", "--", "bao", "status", "-format=json"} ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() output, err := exec.CommandContext(ctx, "kubectl", command...).Output() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return status{}, fmt.Errorf("get OpenBao status timed out after %s", commandTimeout) } if err != nil && !json.Valid(output) { return status{}, fmt.Errorf("get OpenBao status: %w", err) } var current status if err := json.Unmarshal(output, ¤t); err != nil { return status{}, fmt.Errorf("parse OpenBao status: %w", err) } return current, nil } func unseal(kubeconfig string, material RecoveryMaterial) error { if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold { return fmt.Errorf("OpenBao recovery material has insufficient unseal keys") } for _, key := range material.UnsealKeysB64 { _, _ = execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null") } current, err := openBaoStatus(kubeconfig) if err != nil { return fmt.Errorf("verify OpenBao unseal: %w", err) } if current.Sealed { return unsealWithControllerSecret(kubeconfig) } return nil } func unsealWithControllerSecret(kubeconfig string) error { const script = `bao operator unseal -reset >/dev/null 2>&1 || true for share in /unseal/unseal-*; do [ -f "$share" ] || continue cat "$share" | bao operator unseal >/dev/null 2>&1 || true done` if _, err := execInUnsealController(kubeconfig, script); err != nil { return fmt.Errorf("unseal OpenBao with controller secret: %w", err) } current, err := openBaoStatus(kubeconfig) if err != nil { return fmt.Errorf("verify OpenBao controller unseal: %w", err) } if current.Sealed { return errors.New("OpenBao remains sealed after controller unseal") } return nil } func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error { const script = `fail() { printf '%s\n' "$1" >&2; exit 1; } read -r root_token read -r reviewer_token export BAO_TOKEN="$root_token" bao secrets enable -path=secret kv-v2 >/dev/null 2>&1 || true bao auth enable kubernetes >/dev/null 2>&1 || true bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null || fail kubernetes-auth-config cat >/tmp/external-secrets.hcl <<'EOF' path "secret/data/platform/*" { capabilities = ["read"] } path "secret/data/cicd/*" { capabilities = ["read"] } path "secret/metadata/platform/*" { capabilities = ["list", "read"] } path "secret/metadata/cicd/*" { capabilities = ["list", "read"] } EOF bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null || fail platform-external-secrets-policy rm -f /tmp/external-secrets.hcl bao write auth/kubernetes/role/external-secrets bound_service_account_names=external-secrets bound_service_account_namespaces=external-secrets policies=external-secrets ttl=1h >/dev/null 2>&1 || fail platform-external-secrets-role` input := []byte(rootToken + "\n" + reviewerToken + "\n") output, err := execInPodMutation(kubeconfig, input, "sh", "-ec", script) if err != nil { return openBaoMutationError("configure Kubernetes auth", err, output, rootToken, reviewerToken) } return nil } // ConfigureSecretGrants creates only OpenBao policies and Kubernetes auth // roles. GitOps manifests create the matching SecretStores and ExternalSecrets. func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants []config.SecretGrant) error { if err := config.ValidateSecretGrants(grants); err != nil { return err } if len(grants) == 0 { return nil } material, err := ReadRecoveryMaterial(identityPath, bundlePath) if err != nil { return err } var script strings.Builder script.WriteString("read -r root_token\nexport BAO_TOKEN=\"$root_token\"\n") for _, grant := range grants { name := "maidn-" + grant.Application + "-" + grant.Consumer namespace := "tekton-pipelines" if grant.Consumer == "runtime" { name += "-" + grant.Environment namespace = grant.Application + "-" + grant.Environment } script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n") for _, secret := range grant.Secrets { script.WriteString("path \"secret/data/apps/" + grant.Application + "/" + secret + "\" {\n capabilities = [\"read\"]\n}\n") } for _, shared := range grant.Shared { script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n") } script.WriteString("EOF\n") script.WriteString("bao policy write " + name + " /tmp/" + name + ".hcl >/dev/null\n") script.WriteString("rm -f /tmp/" + name + ".hcl\n") script.WriteString("bao write auth/kubernetes/role/" + name + " bound_service_account_names=" + name + " bound_service_account_namespaces=" + namespace + " policies=" + name + " ttl=1h >/dev/null\n") } output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script.String()) if err != nil { return openBaoMutationError("configure OpenBao secret grants", err, output, material.RootToken) } return nil } func openBaoMutationError(action string, err error, output []byte, sensitive ...string) error { diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...) if diagnostic == "" && err != nil { diagnostic = redactOpenBaoDiagnostic(err.Error(), sensitive...) } if diagnostic == "" { return errors.New(action) } return fmt.Errorf("%s: %s", action, diagnostic) } func redactOpenBaoDiagnostic(diagnostic string, sensitive ...string) string { for _, value := range sensitive { if value != "" { diagnostic = strings.ReplaceAll(diagnostic, value, "[REDACTED]") } } return diagnostic } func refreshExternalSecrets(kubeconfig string) error { available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}") if err != nil || strings.TrimSpace(string(available)) != "True" { return nil } timestamp := time.Now().UnixNano() _, err = kubectlOutput(kubeconfig, "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite") if err != nil { return fmt.Errorf("refresh OpenBao secret store after seed: %w", err) } webhook, err := kubectlOutput(kubeconfig, "get", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", "--ignore-not-found", "-o=name") if err != nil { return fmt.Errorf("check Forgejo webhook ExternalSecret after OpenBao seed: %w", err) } if strings.TrimSpace(string(webhook)) == "" { return nil } _, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(timestamp)...) if err != nil { return fmt.Errorf("refresh ExternalSecrets after OpenBao seed: %w", err) } return nil } func externalSecretRefreshArgs(timestamp int64) []string { return []string{"annotate", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite"} } func encryptRecovery(recipient, bundlePath string, plaintext []byte) error { if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil { return err } cmd := exec.Command("age", "-r", recipient, "-o", bundlePath) cmd.Stdin = bytes.NewReader(plaintext) if _, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("encrypt OpenBao recovery material: %w", err) } return os.Chmod(bundlePath, 0600) } var execInPod = func(kubeconfig string, input []byte, args ...string) ([]byte, error) { command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...) return commandOutput(input, "kubectl", command...) } var execInPodMutation = func(kubeconfig string, input []byte, args ...string) ([]byte, error) { command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...) return commandOutputWithTimeout(input, openBaoMutationTimeout, "kubectl", command...) } var execInUnsealController = func(kubeconfig, script string) ([]byte, error) { command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "deployment/openbao-unseal", "--", "sh", "-ec", script} return commandOutput(nil, "kubectl", command...) } var kubectlOutput = func(kubeconfig string, args ...string) ([]byte, error) { command := append([]string{"--kubeconfig", kubeconfig}, args...) return commandOutput(nil, "kubectl", command...) } func commandOutput(input []byte, name string, args ...string) ([]byte, error) { return commandOutputWithTimeout(input, commandTimeout, name, args...) } func commandOutputWithTimeout(input []byte, timeout time.Duration, name string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) cmd.Stdin = bytes.NewReader(input) output, err := cmd.CombinedOutput() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return output, fmt.Errorf("%s timed out after %s", name, timeout) } return output, err }