#!/usr/bin/env bash # Add a NixOS host to the FreeIPA domain and produce a sops-encrypted keytab # at secrets/.keytab, ready for modules/ipa/client.nix. # # One command replaces three error-prone manual steps: # 1. ipa host-add on the domain controller # 2. ipa-getkeytab on the domain controller + SCP back # 3. sops encrypt in-place (must be at secrets/.keytab for # the creation rule to match -- the common mistake that breaks sops) # # Usage: # scripts/ipa/create-nixos-ipa-host-account.sh [options] # # Arguments: # Short hostname, e.g. "tailscale-router". The FQDN is # derived as .. # # Options: # --ip Register this IP with the IPA host record (optional). # --dc SSH as root to this host for ipa-getkeytab. # Default: $IPA_SERVER (from env.sh / environment). # --dc-user SSH user on the domain controller. Default: root. # --dry-run Print what would be done without making any changes. # -h, --help Show this message. # # Prereqs: # 1. Run from the repo root (so .sops.yaml and secrets/ are found). # 2. SSH access to the domain controller as --dc-user (default: root). # If there's no valid Kerberos ticket on the DC, the script runs # `kinit admin` there interactively — you'll be prompted for the IPA # admin password once. The password never touches this script. # 3. The host's age key(s) must already be in .sops.yaml. Run # scripts/secrets/sync-host-keys.sh first so the host # can decrypt its own keytab on boot. This script adds the .sops.yaml # creation rule for secrets/.keytab automatically, but the # host age key anchor (&lxc- etc.) must already exist. # 4. sops in PATH, or Nix available to run it via `nix run`. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # shellcheck source=../env.sh source "${SCRIPT_DIR}/../env.sh" # --- Argument parsing --- DC_HOST="${IPA_SERVER}" DC_USER="root" IP_ADDR="" DRY_RUN=false HOSTNAME="" usage() { sed -n '/^# Usage:/,/^[^#]/{ /^#/{ s/^# \?//; p } }' "$0" exit "${1:-0}" } while [[ $# -gt 0 ]]; do case "$1" in --ip) IP_ADDR="$2"; shift 2 ;; --dc) DC_HOST="$2"; shift 2 ;; --dc-user) DC_USER="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; -h|--help) usage 0 ;; -*) echo "Unknown flag: $1" >&2; usage 1 ;; *) if [[ -n "${HOSTNAME}" ]]; then echo "Unexpected argument: $1" >&2; usage 1; fi HOSTNAME="$1"; shift ;; esac done if [[ -z "${HOSTNAME}" ]]; then echo "Error: hostname required." >&2 usage 1 fi FQDN="${HOSTNAME}.${HOME_DOMAIN}" REALM="${HOME_DOMAIN^^}" # uppercase: SWEET.HOME KEYTAB_SECRET="${REPO_ROOT}/secrets/${HOSTNAME}.keytab" # Temp path on the domain controller — use a name that won't collide. DC_TMP="/tmp/nixos-keytab-${HOSTNAME}-$$.keytab" # --- Helpers --- log() { echo "==> $*"; } logn() { echo " $*"; } run() { if $DRY_RUN; then echo "[dry-run] $*" else "$@" fi } dc_run() { # Run a command string on the domain controller via SSH. if $DRY_RUN; then echo "[dry-run] ssh ${DC_USER}@${DC_HOST} $*" else ssh "${DC_USER}@${DC_HOST}" "$@" fi } # --- Locate sops --- if command -v sops &>/dev/null; then SOPS_CMD=(sops) else log "sops not in PATH — will use 'nix run github:NixOS/nixpkgs/nixos-25.11#sops'" SOPS_CMD=(nix run "github:NixOS/nixpkgs/nixos-25.11#sops" --) fi # --- Preflight checks --- cd "${REPO_ROOT}" [[ -f .sops.yaml ]] || { echo "Error: .sops.yaml not found — run from repo root." >&2; exit 1; } [[ -d secrets ]] || { echo "Error: secrets/ not found — run from repo root." >&2; exit 1; } # --- Step 1: Ensure .sops.yaml has a creation rule for this keytab --- # # sops matches creation rules against the PATH of the file being encrypted, # not the output path. To match secrets/.keytab, the file must # already be at that path when sops -e -i is called. The creation rule must # also exist at that point or sops will refuse with "no matching creation # rules found." log "Checking .sops.yaml for creation rule: secrets/${HOSTNAME}.keytab" RULE_EXISTS=false # Match "path_regex: secrets/...keytab" — using .*keytab rather # than \.keytab because the file stores the regex verbatim (\.keytab = two # chars: backslash + dot), which a BRE \. (= escaped literal dot) won't span. if grep -q "path_regex: secrets/${HOSTNAME}.*keytab" .sops.yaml 2>/dev/null; then RULE_EXISTS=true logn "Rule already exists — skipping addition." fi if ! $RULE_EXISTS; then # Collect which platform-variant age anchors exist in .sops.yaml for this # hostname. The keytab is platform-agnostic (same FQDN regardless of # whether lxc/proxmox/linode variant is deployed), so all platform anchors # that have been registered get added as recipients. RECIPIENTS=("*admin") for platform in lxc proxmox linode; do anchor="${platform}-${HOSTNAME}" if grep -q "^ - &${anchor} " .sops.yaml; then RECIPIENTS+=("*${anchor}") fi done # Build the indented recipient list for the YAML block. RECIPIENT_YAML="" for r in "${RECIPIENTS[@]}"; do RECIPIENT_YAML+=" - ${r}"$'\n' done RECIPIENT_YAML="${RECIPIENT_YAML%$'\n'}" # strip trailing newline NEW_RULE=" # Host keytab for ${HOSTNAME} FreeIPA enrollment (binary sops file). # Generated by scripts/ipa/create-nixos-ipa-host-account.sh. - path_regex: secrets/${HOSTNAME}\\.keytab\$ key_groups: - age: ${RECIPIENT_YAML}" if $DRY_RUN; then echo "[dry-run] Would append to .sops.yaml:" echo "${NEW_RULE}" else logn "Adding creation rule (recipients: ${RECIPIENTS[*]})" printf '%s\n' "${NEW_RULE}" >> .sops.yaml logn "Added." fi fi # --- Step 2: Add IPA host account (idempotent) --- log "Adding FreeIPA host account: ${FQDN}" # Ensure there's a valid admin Kerberos ticket on the DC. # ipa host-add and ipa-getkeytab both need one. If the ticket is missing or # expired, run kinit admin interactively over SSH (ssh -t allocates a PTY so # kinit can prompt for the password normally — no password ever touches this # script or the shell history on either machine). if ! $DRY_RUN; then if ! ssh "${DC_USER}@${DC_HOST}" "klist -s" &>/dev/null; then log "No valid Kerberos ticket on ${DC_HOST} — running kinit admin" ssh -t "${DC_USER}@${DC_HOST}" "kinit admin" # Verify it actually worked before proceeding. if ! ssh "${DC_USER}@${DC_HOST}" "klist -s" &>/dev/null; then echo "Error: kinit admin failed or produced no valid ticket." >&2 exit 1 fi else logn "Kerberos ticket on ${DC_HOST} is valid." fi fi IP_FLAG="" [[ -n "${IP_ADDR}" ]] && IP_FLAG="--ip-address=${IP_ADDR}" # --force: create the host record even if DNS doesn't resolve it yet. # Pipe through grep to suppress the "already exists" warning without # hiding real errors (ipa exits 1 for real errors, 0 for already-exists). HOST_ADD_CMD="ipa host-add '${FQDN}' ${IP_FLAG} --force 2>&1 | \ tee /dev/stderr | grep -q 'already exists' && echo '(host already registered)' || true" dc_run "bash -c \"${HOST_ADD_CMD}\"" # --- Step 3: Fetch the keytab from the domain controller --- log "Fetching keytab for host/${FQDN}" dc_run "ipa-getkeytab -s '${DC_HOST}' -p 'host/${FQDN}' -k '${DC_TMP}'" if $DRY_RUN; then echo "[dry-run] Would scp ${DC_USER}@${DC_HOST}:${DC_TMP} ${KEYTAB_SECRET}" else logn "Copying keytab from ${DC_HOST}:${DC_TMP} → secrets/${HOSTNAME}.keytab" scp "${DC_USER}@${DC_HOST}:${DC_TMP}" "${KEYTAB_SECRET}" logn "Removing temp file on ${DC_HOST}" dc_run "rm -f '${DC_TMP}'" fi # --- Step 4: Encrypt in-place --- # # The file must already be at secrets/.keytab (done above) so # sops matches the creation rule by path. Using -i (in-place) rather than # stdout redirect keeps the path intact through the encrypt call. log "Encrypting secrets/${HOSTNAME}.keytab in-place with sops" run "${SOPS_CMD[@]}" -e --input-type binary -i "${KEYTAB_SECRET}" # --- Done --- if ! $DRY_RUN; then echo "" echo "Done. secrets/${HOSTNAME}.keytab is sops-encrypted and ready." echo "" echo "Next steps:" echo " 1. Verify: grep '\"data\": \"ENC' secrets/${HOSTNAME}.keytab" echo " 2. Stage and commit:" echo " git add secrets/${HOSTNAME}.keytab .sops.yaml" echo " git commit -m 'secrets: add IPA keytab for ${HOSTNAME}'" echo " 3. Add the module to hosts/${HOSTNAME}/host.nix:" echo " (import ../../modules/ipa/client.nix {" echo " keytabSopsFile = ../../secrets/${HOSTNAME}.keytab;" echo " caCertFile = ../../certs/ipa-ca.crt;" echo " })" echo " 4. Deploy: nixos-rebuild switch (or create-proxmox-resource.sh)" fi