maidn-cli/internal/openbao/bootstrap.go

310 lines
11 KiB
Go

package openbao
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"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) {
cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err)
}
return output, nil
}
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) {
if err := validateRecoveryRecipient(recipient, bundlePath); err != nil {
return nil, err
}
if err := waitForPod(kubeconfig); err != nil {
return nil, err
}
current, err := getStatus(kubeconfig)
if err != nil {
return nil, err
}
var material RecoveryMaterial
if !current.Initialized {
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 {
material, err = ReadRecoveryMaterial(identityPath, bundlePath)
if err != nil {
return nil, err
}
}
if current.Sealed {
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)
}
if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil {
return nil, err
}
return seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
}
func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) {
cmd := exec.Command("sops", "--decrypt", "--output-type", "yaml", path)
cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath)
plaintext, err := cmd.Output()
if err != nil {
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)
cmd := exec.Command("age", "-r", recipient, "-o", probePath)
if output, err := cmd.CombinedOutput(); err != nil {
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, rootToken, 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(rootToken)
input.WriteByte('\n')
for _, key := range keys {
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
input.WriteString(values[key])
input.WriteByte('\n')
}
reads := make([]string, 0, len(keys))
for index := range keys {
reads = append(reads, fmt.Sprintf("read -r value%d", index))
}
script := "read -r root_token\n" + strings.Join(reads, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
_, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script)
return err
}
func waitForPod(kubeconfig string) error {
deadline := time.Now().Add(10 * time.Minute)
for time.Now().Before(deadline) {
if _, err := getStatus(kubeconfig); err == nil {
return nil
}
time.Sleep(2 * time.Second)
}
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"}
output, err := exec.Command("kubectl", command...).Output()
if err != nil && !json.Valid(output) {
return status{}, fmt.Errorf("get OpenBao status: %w", err)
}
var current status
if err := json.Unmarshal(output, &current); 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[:material.UnsealThreshold] {
if _, err := execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null"); err != nil {
return fmt.Errorf("unseal OpenBao: %w", err)
}
}
return nil
}
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
const script = `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
cat >/tmp/external-secrets.hcl <<'EOF'
path "secret/data/*" {
capabilities = ["read"]
}
path "secret/metadata/*" {
capabilities = ["list", "read"]
}
EOF
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null
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`
input := []byte(rootToken + "\n" + reviewerToken + "\n")
_, err := execInPod(kubeconfig, input, "sh", "-ec", script)
return err
}
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)
}
func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
cmd := exec.Command("kubectl", command...)
cmd.Stdin = bytes.NewReader(input)
return cmd.CombinedOutput()
}
func kubectlOutput(kubeconfig string, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig}, args...)
return exec.Command("kubectl", command...).Output()
}