feat(openbao): add SSH engine automation for ops-warden signing

Declarative roles, warden-sign policy, apply/verify scripts, and Makefile
targets openbao-configure-ssh and openbao-verify-ssh. Document operator flow
in docs/openbao.md for NET-WP-0020 T5 / WP-0008 T2.
This commit is contained in:
2026-06-18 01:06:43 +02:00
parent 108944cd3e
commit c24956fb5a
6 changed files with 521 additions and 7 deletions

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env bash
set -euo pipefail
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
OPENBAO_RELEASE="${OPENBAO_RELEASE:-openbao}"
KUBECTL="${KUBECTL:-kubectl}"
TOKEN_FILE="${OPENBAO_TOKEN_FILE:-}"
SSH_MOUNT="${OPENBAO_SSH_MOUNT:-ssh}"
ROLES_SPEC="${ROLES_SPEC:-}"
POLICY_DIR="${POLICY_DIR:-}"
CA_PUBKEY_OUT="${OPENBAO_SSH_CA_PUBKEY_OUT:-}"
K8S_CA_SECRET="${OPENBAO_SSH_CA_K8S_SECRET:-openbao-ssh-ca-pub}"
DRY_RUN=0
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROLES_SPEC="${ROLES_SPEC:-$REPO_DIR/openbao/ssh/roles-spec.yaml}"
POLICY_DIR="${POLICY_DIR:-$REPO_DIR/openbao/policies}"
usage() {
cat <<'USAGE'
Usage: scripts/openbao-apply-ssh-engine.sh [--dry-run]
Applies the OpenBao SSH secrets engine for ops-warden signing:
- enable ssh/ mount (idempotent)
- write adm/agt/atm roles from openbao/ssh/roles-spec.yaml
- load warden-sign policy
- export CA public key (optional K8s secret + local file)
Requires initialized, unsealed OpenBao and a token with ssh engine admin
(typically platform-admin). Token from OPENBAO_TOKEN_FILE or prompt.
Env:
OPENBAO_SSH_CA_PUBKEY_OUT Write CA pubkey to this path (non-secret)
OPENBAO_SSH_CA_K8S_SECRET K8s secret name in openbao namespace (default: openbao-ssh-ca-pub)
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
pod="${OPENBAO_RELEASE}-0"
WARNINGS=0
warn() {
WARNINGS=$((WARNINGS + 1))
printf 'WARN: %s\n' "$*" >&2
}
read_token() {
if [ "$DRY_RUN" -eq 1 ]; then
printf 'dry-run-token\n'
return
fi
if [ -n "$TOKEN_FILE" ]; then
if [ ! -f "$TOKEN_FILE" ]; then
echo "ERROR: OPENBAO_TOKEN_FILE does not exist: $TOKEN_FILE" >&2
exit 1
fi
head -n 1 "$TOKEN_FILE"
return
fi
local token
read -r -s -p "OpenBao token: " token
printf '\n' >&2
printf '%s\n' "$token"
}
remote_bao() {
local token="$1"
shift
if [ "$DRY_RUN" -eq 1 ]; then
printf 'DRY-RUN: bao %s\n' "$*"
return 0
fi
printf '%s\n' "$token" | $KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"' sh "$@"
}
write_policy() {
local token="$1"
local name="$2"
local file="$3"
if [ ! -f "$file" ]; then
echo "ERROR: missing policy file: $file" >&2
exit 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
printf 'DRY-RUN: bao policy write %s %s\n' "$name" "$file"
return 0
fi
{ printf '%s\n' "$token"; cat "$file"; } | $KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; bao policy write "$1" -' sh "$name"
}
enable_ssh_engine() {
local token="$1"
local output status
if output="$(remote_bao "$token" secrets enable -path="$SSH_MOUNT" ssh 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
case "$output" in
*"path is already in use"*)
printf 'OK: %s/ SSH secrets engine is already enabled.\n' "$SSH_MOUNT"
return 0
;;
*)
printf '%s\n' "$output" >&2
echo "ERROR: failed to enable SSH engine (exit $status)" >&2
exit 1
;;
esac
}
apply_roles() {
local token="$1"
if [ ! -f "$ROLES_SPEC" ]; then
echo "ERROR: roles spec not found: $ROLES_SPEC" >&2
exit 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
python3 - "$ROLES_SPEC" "$SSH_MOUNT" <<'PY'
import sys, yaml
spec = yaml.safe_load(open(sys.argv[1]))
mount = sys.argv[2]
for name, params in (spec.get("roles") or {}).items():
args = " ".join(f"{k}={v}" for k, v in params.items())
print(f"DRY-RUN: bao write {mount}/roles/{name} {args}")
PY
return 0
fi
python3 - "$token" "$ROLES_SPEC" "$SSH_MOUNT" "$OPENBAO_NAMESPACE" "$pod" "$KUBECTL" <<'PY'
import subprocess
import sys
token, spec_path, mount, namespace, pod, kubectl = sys.argv[1:7]
import yaml
spec = yaml.safe_load(open(spec_path))
roles = spec.get("roles") or {}
for role_name, params in roles.items():
args = [f"{k}={v}" for k, v in params.items()]
cmd = ["bao", "write", f"{mount}/roles/{role_name}"] + args
proc = subprocess.run(
[kubectl, "exec", "-i", "-n", namespace, pod, "--", "sh", "-c",
"read -r BAO_TOKEN; export BAO_TOKEN; exec bao \"$@\"", "sh"] + cmd,
input=(token + "\n").encode(),
capture_output=True,
)
if proc.returncode != 0:
sys.stderr.write(proc.stderr.decode())
raise SystemExit(f"failed to write role {role_name}")
print(f"OK: {mount}/roles/{role_name}")
PY
}
export_ca_pubkey() {
local token="$1"
if [ "$DRY_RUN" -eq 1 ]; then
printf 'DRY-RUN: bao read -field=public_key %s/public_key\n' "$SSH_MOUNT"
return 0
fi
local pubkey
pubkey="$(remote_bao "$token" read -field=public_key "${SSH_MOUNT}/public_key")"
if [ -z "$pubkey" ]; then
warn "Could not read SSH CA public key from ${SSH_MOUNT}/public_key"
return 0
fi
local fingerprint
fingerprint="$(printf '%s' "$pubkey" | sha256sum | awk '{print $1}')"
printf 'OK: SSH CA public key fingerprint sha256:%s\n' "$fingerprint"
if [ -n "$CA_PUBKEY_OUT" ]; then
mkdir -p "$(dirname "$CA_PUBKEY_OUT")"
printf '%s\n' "$pubkey" >"$CA_PUBKEY_OUT"
chmod 644 "$CA_PUBKEY_OUT"
printf 'OK: wrote CA pubkey to %s\n' "$CA_PUBKEY_OUT"
fi
if [ -n "$K8S_CA_SECRET" ]; then
local tmp
tmp="$(mktemp)"
printf '%s\n' "$pubkey" >"$tmp"
$KUBECTL create secret generic "$K8S_CA_SECRET" \
--namespace "$OPENBAO_NAMESPACE" \
--from-file=ca_user.pub="$tmp" \
--dry-run=client -o yaml | $KUBECTL apply -f -
rm -f "$tmp"
printf 'OK: K8s secret %s/%s updated\n' "$OPENBAO_NAMESPACE" "$K8S_CA_SECRET"
fi
}
token="$(read_token)"
if [ -z "$token" ]; then
echo "ERROR: empty token" >&2
exit 1
fi
remote_bao "$token" status
enable_ssh_engine "$token"
apply_roles "$token"
write_policy "$token" warden-sign "$POLICY_DIR/warden-sign.hcl"
remote_bao "$token" list "${SSH_MOUNT}/roles"
export_ca_pubkey "$token"
cat <<NEXT
OpenBao SSH engine configuration applied.
Next steps:
1. make openbao-verify-ssh
2. railiance-infra: make bootstrap-ssh-ca SSH_CA_PUBKEY=<path>
3. ops-warden: warden sign smoke (WP-0008 T2)
NEXT
if [ "$WARNINGS" -gt 0 ]; then
printf '\nCompleted with %s warning(s).\n' "$WARNINGS"
fi

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
OPENBAO_RELEASE="${OPENBAO_RELEASE:-openbao}"
KUBECTL="${KUBECTL:-kubectl}"
TOKEN_FILE="${OPENBAO_TOKEN_FILE:-}"
SSH_MOUNT="${OPENBAO_SSH_MOUNT:-ssh}"
EXPECTED_ROLES="${OPENBAO_SSH_EXPECTED_ROLES:-adm-role agt-role atm-role}"
USE_TOKEN_HELPER=0
DRY_RUN=0
usage() {
cat <<'USAGE'
Usage: scripts/openbao-verify-ssh-engine.sh [--dry-run] [--use-token-helper]
Non-mutating checks: ssh/ mount present, expected roles listed, warden-sign policy.
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--use-token-helper) USE_TOKEN_HELPER=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
pod="${OPENBAO_RELEASE}-0"
FAILURES=0
fail() { FAILURES=$((FAILURES + 1)); printf '[FAIL] %s\n' "$*" >&2; }
ok() { printf '[OK] %s\n' "$*"; }
read_token() {
if [ "$USE_TOKEN_HELPER" -eq 1 ]; then
printf '__USE_TOKEN_HELPER__\n'
return
fi
if [ "$DRY_RUN" -eq 1 ]; then
printf 'dry-run-token\n'
return
fi
if [ -n "$TOKEN_FILE" ] && [ -f "$TOKEN_FILE" ]; then
head -n 1 "$TOKEN_FILE"
return
fi
local token
read -r -s -p "OpenBao token: " token
printf '\n' >&2
printf '%s\n' "$token"
}
remote_bao() {
local token="$1"
shift
if [ "$token" = "__USE_TOKEN_HELPER__" ]; then
$KUBECTL exec -n "$OPENBAO_NAMESPACE" "$pod" -- bao "$@"
return
fi
if [ "$DRY_RUN" -eq 1 ]; then
printf 'DRY-RUN: bao %s\n' "$*"
return 0
fi
printf '%s\n' "$token" | $KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"' sh "$@"
}
token="$(read_token)"
secrets_out="$(remote_bao "$token" secrets list 2>&1)" || {
fail "secrets list failed: $secrets_out"
exit 1
}
if printf '%s\n' "$secrets_out" | grep -Eq "(^|[[:space:]])${SSH_MOUNT}/"; then
ok "SSH mount ${SSH_MOUNT}/ is enabled"
else
fail "SSH mount ${SSH_MOUNT}/ not found in secrets list"
fi
roles_out="$(remote_bao "$token" list "${SSH_MOUNT}/roles" 2>&1)" || {
fail "list ${SSH_MOUNT}/roles failed: $roles_out"
exit 1
}
for role in $EXPECTED_ROLES; do
if printf '%s\n' "$roles_out" | grep -q "$role"; then
ok "role ${role} exists"
else
fail "role ${role} missing"
fi
done
policy_out="$(remote_bao "$token" policy list 2>&1)" || {
fail "policy list failed: $policy_out"
exit 1
}
if printf '%s\n' "$policy_out" | grep -q 'warden-sign'; then
ok "policy warden-sign present"
else
fail "policy warden-sign missing"
fi
if [ "$FAILURES" -gt 0 ]; then
printf '\nSSH engine verification failed (%s failure(s)).\n' "$FAILURES" >&2
exit 1
fi
printf '\nSSH engine verification passed.\n'