Add prepare-host.sh: one-command SSH host onboarding

Generalize the .26 rollout into a reusable template:
- authorize the host principal on ssh/roles/host (allowlist, merged)
- sign the host certificate (ssh/sign/host)
- emit a self-contained installer (user-CA TrustedUserCAKeys + HostCertificate)
  to artifacts/, additive and lockout-safe
- add @cert-authority for the host to the client's known_hosts
Needs no SSH access to the target (ssh-keyscan). README updated.

Also gitignore .claude/ (local harness settings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:53:32 +02:00
parent 5e5e7f779f
commit af31208251
3 changed files with 127 additions and 3 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ config/tls/
data/
.env
artifacts/
.claude/

View File

@@ -187,9 +187,25 @@ scripts/ssh-login.sh --sign-only HOST # just refresh the cert
Auth to OpenBAO uses a scoped `ssh-sign-user` periodic token at
`~/.config/openbao/ssh-sign.token` (can only call `ssh/sign/user`).
**Onboard a new host** (run on that host, as root): install the user CA +
its signed host cert and point sshd at them. `scripts/` generates a ready
self-contained installer per host — the pattern (additive, no lockout):
**Onboard a new host** — one command generates everything:
```bash
scripts/prepare-host.sh 192.168.0.30 # IP you'll SSH to
scripts/prepare-host.sh 192.168.0.30 --hostname pi.famfi.home # extra principal
```
It authorizes the host's principal on `ssh/roles/host`, signs its host cert,
writes a self-contained installer to `artifacts/openbao-ssh-setup-on-<host>.sh`,
and adds `@cert-authority` for it to your `~/.ssh/known_hosts`. Needs no SSH
access to the target (uses `ssh-keyscan`). Then finish on the target (as root):
```bash
scp artifacts/openbao-ssh-setup-on-<host>.sh <user>@<host>:/tmp/
ssh <user>@<host> 'sudo bash /tmp/openbao-ssh-setup-on-<host>.sh' # additive, no lockout
scripts/ssh-login.sh <host> # sign a user cert + connect
```
The installer just adds these sshd directives (additive):
```
TrustedUserCAKeys /etc/ssh/openbao_user_ca.pub # trust user certs

107
scripts/prepare-host.sh Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Onboard a host to OpenBAO SSH access in one command:
# * authorize the host's principal on the ssh/roles/host role (allowlist)
# * sign its SSH host certificate (ssh/sign/host)
# * generate a self-contained installer to run on the target (user-CA trust
# via TrustedUserCAKeys + the HostCertificate) — additive, no lockout risk
# * trust the host on THIS client (@cert-authority in ~/.ssh/known_hosts)
#
# Needs NO SSH access to the target (uses ssh-keyscan). Auth: $BAO_TOKEN or the
# root token in init-output.json.
#
# prepare-host.sh <host> [--user lutz] [--hostname NAME] [--no-client-trust]
#
# <host> is the address you will SSH to (IP or name); it becomes the host
# cert's principal and the client @cert-authority match. --hostname adds an
# extra principal (e.g. sign both the IP and a DNS name).
set -euo pipefail
SELF="$(cd "$(dirname "$0")" && pwd)"; ROOT_DIR="$(cd "$SELF/.." && pwd)"
INIT="$ROOT_DIR/init-output.json"; ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
CAFILE="$ROOT_DIR/ca/openbao-ssh-ca.pub"; ARTDIR="$ROOT_DIR/artifacts"
HOST=""; LOGIN_USER="lutz"; HOSTNAME_EXTRA=""; CLIENT_TRUST=1
while [ $# -gt 0 ]; do case "$1" in
--user) LOGIN_USER="$2"; shift 2;;
--hostname) HOSTNAME_EXTRA="$2"; shift 2;;
--no-client-trust) CLIENT_TRUST=0; shift;;
-*) echo "unknown option: $1" >&2; exit 1;;
*) HOST="$1"; shift;;
esac; done
[ -n "$HOST" ] || { echo "usage: prepare-host.sh <host> [--user U] [--hostname NAME] [--no-client-trust]" >&2; exit 1; }
TOKEN="${BAO_TOKEN:-}"; [ -z "$TOKEN" ] && [ -r "$INIT" ] && TOKEN="$(python3 -c "import json;print(json.load(open('$INIT'))['root_token'])")"
[ -n "$TOKEN" ] || { echo "no token (set BAO_TOKEN)" >&2; exit 1; }
api(){ curl -sS -H "X-Vault-Token: $TOKEN" "$@"; }
PRINCIPALS="$HOST"; [ -n "$HOSTNAME_EXTRA" ] && PRINCIPALS="$HOST,$HOSTNAME_EXTRA"
echo "[1/5] authorize principal(s) '$PRINCIPALS' on ssh/roles/host (allowlist)"
cur="$(api "$ADDR/v1/ssh/roles/host" | python3 -c "import sys,json;print(json.load(sys.stdin)['data'].get('allowed_domains',''))" 2>/dev/null || true)"
merged="$(CUR="$cur" ADD="$PRINCIPALS" python3 -c '
import os
seen=[]
for x in (os.environ["CUR"].split(",")+os.environ["ADD"].split(",")):
x=x.strip()
if x and x not in seen: seen.append(x)
print(",".join(seen))')"
body="$(M="$merged" python3 -c 'import json,os;print(json.dumps({"key_type":"ca","allow_host_certificates":True,"allow_user_certificates":False,"allowed_domains":os.environ["M"],"allow_bare_domains":True,"allow_subdomains":True,"ttl":"26280h"}))')"
api --data "$body" "$ADDR/v1/ssh/roles/host" -o /dev/null -w " role updated: %{http_code} (allowed: $merged)\n"
echo "[2/5] scan ed25519 host key of $HOST (no SSH login needed)"
HK="$(ssh-keyscan -t ed25519 "$HOST" 2>/dev/null | grep -v '^#' | awk '{print $2" "$3}' | head -1)"
[ -n "$HK" ] || { echo " FAILED to scan host key from $HOST (reachable? sshd up?)" >&2; exit 1; }
echo "[3/5] sign host certificate (principals: $PRINCIPALS)"
req="$(HK="$HK" PR="$PRINCIPALS" python3 -c 'import json,os;print(json.dumps({"public_key":os.environ["HK"],"cert_type":"host","valid_principals":os.environ["PR"],"ttl":"26280h"}))')"
HOSTCERT="$(printf '%s' "$req" | api --data @- "$ADDR/v1/ssh/sign/host" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['signed_key'])")"
[ -n "$HOSTCERT" ] || { echo " signing failed" >&2; exit 1; }
echo "[4/5] generate installer"
CAPUB="$(cat "$CAFILE" 2>/dev/null || api "$ADDR/v1/ssh/config/ca" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['public_key'])")"
mkdir -p "$ARTDIR"; INSTALLER="$ARTDIR/openbao-ssh-setup-on-$HOST.sh"
cat > "$INSTALLER" <<EOF
#!/usr/bin/env bash
# Run on $HOST as root (sudo). Additive — keeps existing auth, won't lock you out.
set -euo pipefail
[ "\$(id -u)" = 0 ] || { echo "run with sudo"; exit 1; }
install -m 0644 /dev/stdin /etc/ssh/openbao_user_ca.pub <<'CA'
$CAPUB
CA
install -m 0644 /dev/stdin /etc/ssh/ssh_host_ed25519_key-cert.pub <<'HC'
$HOSTCERT
HC
read -r -d '' D <<'DIR' || true
# OpenBAO SSH CA integration
TrustedUserCAKeys /etc/ssh/openbao_user_ca.pub
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
DIR
if grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d' /etc/ssh/sshd_config; then
printf '%s\n' "\$D" > /etc/ssh/sshd_config.d/10-openbao-ca.conf
else
grep -q 'OpenBAO SSH CA integration' /etc/ssh/sshd_config || printf '\n%s\n' "\$D" >> /etc/ssh/sshd_config
fi
sshd -t
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || service ssh reload
echo "OK: OpenBAO SSH CA + host cert installed on \$(hostname)"
EOF
chmod +x "$INSTALLER"
echo " -> $INSTALLER"
echo "[5/5] client trust (@cert-authority for $HOST)"
if [ "$CLIENT_TRUST" = 1 ]; then
ssh-keygen -R "$HOST" >/dev/null 2>&1 || true
KH="$HOME/.ssh/known_hosts"; touch "$KH"
grep -qF "@cert-authority $HOST " "$KH" || printf '@cert-authority %s %s\n' "$HOST" "$CAPUB" >> "$KH"
echo " ~/.ssh/known_hosts updated"
else
echo " skipped (--no-client-trust)"
fi
cat <<EOF
Done. To finish on the target host:
scp $INSTALLER ${LOGIN_USER}@${HOST}:/tmp/
ssh ${LOGIN_USER}@${HOST} 'sudo bash /tmp/$(basename "$INSTALLER")'
Then log in with a signed cert:
scripts/ssh-login.sh ${HOST}
EOF