#!/usr/bin/env bash # # Materialise the Ansible deploy SSH key and print its path on stdout. # # export SSH_KEY_PATH="$(scripts/load-ssh-key.sh)" # ansible-playbook ansible/playbooks/static-site.yml # # Ansible itself knows exactly one thing about credentials: SSH_KEY_PATH, a path # to a 0600 private key. This script is the only thing that knows where that key # comes from, so adding a backend never touches the inventory or a playbook. # # env (default) the key is already on disk at $SSH_KEY_PATH # ci from $SSH_PRIVATE_KEY_B64 or $SSH_PRIVATE_KEY # (aliases: github, gitlab) # vault from HashiCorp Vault or OpenBao, KV v2 # # Select with the first argument or $SSH_KEY_BACKEND. Only the path is printed; # key material never reaches stdout, a CI log, or the process list. # # See docs/secrets.md. set -euo pipefail umask 077 backend="${1:-${SSH_KEY_BACKEND:-env}}" # Vault and OpenBao share the KV v2 API, so either works. VAULT_* is checked # first; BAO_* is accepted so an OpenBao deployment needs no translation. VAULT_ADDR="${VAULT_ADDR:-${BAO_ADDR:-}}" VAULT_TOKEN="${VAULT_TOKEN:-${BAO_TOKEN:-}}" VAULT_KV_MOUNT="${VAULT_KV_MOUNT:-${BAO_KV_MOUNT:-kv}}" VAULT_SECRET_PATH="${VAULT_SECRET_PATH:-${BAO_SECRET_PATH:-infra/ansible/deploy-key}}" VAULT_SECRET_FIELD="${VAULT_SECRET_FIELD:-${BAO_SECRET_FIELD:-private_key}}" log() { printf 'load-ssh-key: %s\n' "$*" >&2; } die() { log "$*"; exit 1; } # GNU coreutils spells it --decode; BSD and macOS spell it -D. decode_base64() { if printf '' | base64 --decode >/dev/null 2>&1; then base64 --decode else base64 -D fi } # Write stdin to a fresh 0600 file in a private directory, validate it, print the # path. Never echoes what it wrote. install_key() { local dir dest dir="$(mktemp -d "${TMPDIR:-/tmp}/ansible-ssh.XXXXXX")" chmod 700 "$dir" dest="$dir/id_deploy" cat >"$dest" chmod 600 "$dest" [ -s "$dest" ] || die "no key material was produced (backend: $backend)" grep -q 'PRIVATE KEY' "$dest" \ || die "the value fetched from $backend does not look like a private key" # OpenSSH rejects a key whose file lacks a trailing newline. [ -z "$(tail -c 1 "$dest")" ] || printf '\n' >>"$dest" printf '%s\n' "$dest" } case "$backend" in env) [ -n "${SSH_KEY_PATH:-}" ] || die "SSH_KEY_PATH is not set" [ -r "${SSH_KEY_PATH}" ] \ || die "SSH_KEY_PATH does not point at a readable file: $SSH_KEY_PATH" perms="$(stat -c '%a' "$SSH_KEY_PATH" 2>/dev/null || stat -f '%Lp' "$SSH_KEY_PATH")" [ "$perms" = "600" ] || log "warning: $SSH_KEY_PATH is mode $perms; ssh wants 600" printf '%s\n' "$SSH_KEY_PATH" ;; ci | github | gitlab) # Store the key base64-encoded. GitHub Actions redacts secrets from logs but # mangles multi-line values passed through some contexts; GitLab can only # *mask* a value that is a single line with no whitespace, which an OpenSSH # private key never is. One encoding satisfies both: # # base64 -w0 < ~/.ssh/deploy_key # Linux # base64 < ~/.ssh/deploy_key | tr -d '\n' # macOS # [Convert]::ToBase64String( # [IO.File]::ReadAllBytes("$HOME\.ssh\deploy_key")) # PowerShell # # On Windows use exactly that. certutil -encode wraps its output in a header # and line breaks, and Get-Content re-encodes the bytes — both produce # something that looks like base64 and decodes to an unusable key. # # SSH_PRIVATE_KEY is still accepted for a raw or file-backed value, and is # treated as base64 if it is neither a path nor a PEM block. if [ -n "${SSH_PRIVATE_KEY_B64:-}" ]; then printf '%s' "$SSH_PRIVATE_KEY_B64" | decode_base64 | install_key elif [ -n "${SSH_PRIVATE_KEY:-}" ]; then if [ -f "$SSH_PRIVATE_KEY" ] && [ -r "$SSH_PRIVATE_KEY" ]; then install_key <"$SSH_PRIVATE_KEY" elif case "$SSH_PRIVATE_KEY" in *"PRIVATE KEY"*) true ;; *) false ;; esac; then printf '%s' "$SSH_PRIVATE_KEY" | install_key else printf '%s' "$SSH_PRIVATE_KEY" | decode_base64 | install_key fi else die "set SSH_PRIVATE_KEY_B64, or SSH_PRIVATE_KEY to a raw key or a path" fi ;; vault | openbao) [ -n "$VAULT_ADDR" ] || die "VAULT_ADDR (or BAO_ADDR) is not set" [ -n "$VAULT_TOKEN" ] || die "VAULT_TOKEN (or BAO_TOKEN) is not set" cli="" command -v vault >/dev/null 2>&1 && cli=vault [ -z "$cli" ] && command -v bao >/dev/null 2>&1 && cli=bao if [ -n "$cli" ]; then # `env` rather than bare NAME=value prefixes: in `A=1 B=$A cmd` the $A # expands to the *outer* A, which reads as a bug even when it is not. Both # spellings are set for the command only, never exported to this shell. material="$( env VAULT_ADDR="$VAULT_ADDR" VAULT_TOKEN="$VAULT_TOKEN" \ BAO_ADDR="$VAULT_ADDR" BAO_TOKEN="$VAULT_TOKEN" \ "$cli" kv get -mount="$VAULT_KV_MOUNT" \ -field="$VAULT_SECRET_FIELD" "$VAULT_SECRET_PATH" )" || die "'$cli kv get' failed for $VAULT_KV_MOUNT/$VAULT_SECRET_PATH" elif command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then # -H keeps the token out of the process list. This is the KV v2 data # endpoint; KV v1 has no /data/ segment. response="$( curl --fail --silent --show-error \ -H "X-Vault-Token: $VAULT_TOKEN" \ "$VAULT_ADDR/v1/$VAULT_KV_MOUNT/data/$VAULT_SECRET_PATH" )" || die "request to $VAULT_ADDR failed — check the token, and that the vault is unsealed" # The field name goes in as argv, not interpolated into the program text. material="$(printf '%s' "$response" | python3 -c 'import json, sys try: print(json.load(sys.stdin)["data"]["data"][sys.argv[1]], end="") except (ValueError, KeyError, TypeError): sys.exit(1) ' "$VAULT_SECRET_FIELD")" \ || die "no field '$VAULT_SECRET_FIELD' in $VAULT_KV_MOUNT/$VAULT_SECRET_PATH" else die "need either the 'vault' or 'bao' CLI, or curl + python3" fi printf '%s' "$material" | install_key ;; *) die "unknown backend '$backend' (expected: env, ci, vault)" ;; esac