From 6c1891812ee070b8f8d27fd6df5211bcb0a107b5 Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Tue, 28 Jul 2026 08:57:55 +1000 Subject: [PATCH] feat(ipa): add create-nixos-ipa-host-account script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single command to enroll a NixOS host in FreeIPA and produce a sops-encrypted keytab at secrets/.keytab: - Adds the .sops.yaml creation rule automatically (with all registered platform-variant age keys as recipients) - SSHes to the domain controller to run ipa host-add + ipa-getkeytab - Refreshes the admin Kerberos ticket via `ssh -t ... kinit admin` if missing or expired, so no manual kinit step is needed - SCPs the keytab and encrypts it in-place with sops (file must be at secrets/.keytab before encryption so the path-based creation rule matches — the common failure point when doing this manually) Also adds HOME_DOMAIN and IPA_SERVER to scripts/env.sh, matching variables.nix's homeDomain/ipaServer (same manual-sync pattern as NIX_CACHE_HOST/LAN_DOMAIN). Co-Authored-By: Claude Sonnet 4.6 --- scripts/env.sh | 11 + scripts/ipa/create-nixos-ipa-host-account.sh | 256 +++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100755 scripts/ipa/create-nixos-ipa-host-account.sh diff --git a/scripts/env.sh b/scripts/env.sh index 22860a7..1419c9c 100755 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -88,6 +88,17 @@ export NIX_CACHE_HOST : "${LAN_DOMAIN:=gitea.lan.ddnsgeek.com}" export LAN_DOMAIN +# Matches variables.nix's homeDomain -- the base LAN domain for service +# subdomains, FreeIPA Kerberos realm, and host FQDNs. +: "${HOME_DOMAIN:=sweet.home}" +export HOME_DOMAIN + +# Matches variables.nix's ipaServer -- the FreeIPA server hostname. +# scripts/ipa/create-nixos-ipa-host-account.sh SSHes here to run +# ipa host-add and ipa-getkeytab. +: "${IPA_SERVER:=domain-controller.sweet.home}" +export IPA_SERVER + # nix_extra_opts: call as a plain statement (NOT inside $(...)/<(...) -- # that forks a subshell, and the whole point is exporting a decision back # into *this* shell) to populate the global NIX_OPTS array with whatever diff --git a/scripts/ipa/create-nixos-ipa-host-account.sh b/scripts/ipa/create-nixos-ipa-host-account.sh new file mode 100755 index 0000000..fbe4dd3 --- /dev/null +++ b/scripts/ipa/create-nixos-ipa-host-account.sh @@ -0,0 +1,256 @@ +#!/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