From af31208251983000339cd228845d80f50ab7a77e Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Sun, 19 Jul 2026 10:53:32 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 22 +++++++-- scripts/prepare-host.sh | 107 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100755 scripts/prepare-host.sh diff --git a/.gitignore b/.gitignore index f16ecf1..3c301b2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ config/tls/ data/ .env artifacts/ +.claude/ diff --git a/README.md b/README.md index 7f317f6..38c3ad0 100644 --- a/README.md +++ b/README.md @@ -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-.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-.sh @:/tmp/ +ssh @ 'sudo bash /tmp/openbao-ssh-setup-on-.sh' # additive, no lockout +scripts/ssh-login.sh # sign a user cert + connect +``` + +The installer just adds these sshd directives (additive): ``` TrustedUserCAKeys /etc/ssh/openbao_user_ca.pub # trust user certs diff --git a/scripts/prepare-host.sh b/scripts/prepare-host.sh new file mode 100755 index 0000000..f870af3 --- /dev/null +++ b/scripts/prepare-host.sh @@ -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 [--user lutz] [--hostname NAME] [--no-client-trust] +# +# 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 [--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" < /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 <