575 lines
22 KiB
Go
575 lines
22 KiB
Go
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 AppSecretIdentityTokens struct {
|
|
Admin string
|
|
E2E string
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ProvisionAppSecretIdentities creates short-lived non-root tokens for secret
|
|
// administration and one fixture probe. Tokens are returned only to be placed
|
|
// into encrypted operational state by the caller.
|
|
func ProvisionAppSecretIdentities(kubeconfig, identityPath, bundlePath, app string) (AppSecretIdentityTokens, error) {
|
|
if !managedSecretPart.MatchString(app) {
|
|
return AppSecretIdentityTokens{}, errors.New("E2E application must be a lowercase DNS label")
|
|
}
|
|
material, err := ReadRecoveryMaterial(identityPath, bundlePath)
|
|
if err != nil {
|
|
return AppSecretIdentityTokens{}, err
|
|
}
|
|
script := `read -r root_token
|
|
export BAO_TOKEN="$root_token"
|
|
cat >/tmp/maidn-app-secret-admin.hcl <<'EOF'
|
|
path "secret/data/apps/*" { capabilities = ["create", "update"] }
|
|
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/data/shared/*" { capabilities = ["create", "update"] }
|
|
path "secret/metadata/shared/*" { capabilities = ["list", "read", "delete"] }
|
|
EOF
|
|
cat >/tmp/maidn-e2e.hcl <<'EOF'
|
|
path "secret/data/apps/` + app + `/e2e-probe" { capabilities = ["create", "update"] }
|
|
path "secret/metadata/apps/` + app + `" { capabilities = ["list"] }
|
|
path "secret/metadata/apps/` + app + `/e2e-probe" { capabilities = ["read", "delete"] }
|
|
EOF
|
|
bao policy write maidn-app-secret-admin /tmp/maidn-app-secret-admin.hcl >/dev/null
|
|
bao policy write maidn-e2e-` + app + ` /tmp/maidn-e2e.hcl >/dev/null
|
|
bao token create -orphan -policy=maidn-app-secret-admin -ttl=1h -explicit-max-ttl=1h -format=json | base64 | tr -d '\n'; printf '\n'
|
|
bao token create -orphan -policy=maidn-e2e-` + app + ` -ttl=1h -explicit-max-ttl=1h -format=json | base64 | tr -d '\n'; printf '\n'
|
|
rm -f /tmp/maidn-app-secret-admin.hcl /tmp/maidn-e2e.hcl`
|
|
output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script)
|
|
if err != nil {
|
|
return AppSecretIdentityTokens{}, openBaoMutationError("provision app-secret identities", err, output, material.RootToken)
|
|
}
|
|
var responses []struct {
|
|
Auth struct {
|
|
ClientToken string `json:"client_token"`
|
|
} `json:"auth"`
|
|
}
|
|
for _, line := range bytes.Split(bytes.TrimSpace(output), []byte("\n")) {
|
|
var response struct {
|
|
Auth struct {
|
|
ClientToken string `json:"client_token"`
|
|
} `json:"auth"`
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(string(line))
|
|
if err != nil || json.Unmarshal(decoded, &response) != nil || response.Auth.ClientToken == "" {
|
|
return AppSecretIdentityTokens{}, errors.New("parse provisioned app-secret identity")
|
|
}
|
|
responses = append(responses, response)
|
|
}
|
|
if len(responses) != 2 {
|
|
return AppSecretIdentityTokens{}, errors.New("provision app-secret identities returned an incomplete result")
|
|
}
|
|
return AppSecretIdentityTokens{Admin: responses[0].Auth.ClientToken, E2E: responses[1].Auth.ClientToken}, 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
|
|
}
|