Archived
Passing a FQDN like "nixos.sweet.home" instead of the short hostname "nixos" caused the script to create a double-FQDN IPA host account (nixos.sweet.home.sweet.home). Add an early check that rejects any TARGET containing a dot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
11 KiB
Bash
Executable File
304 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Add a NixOS host to the FreeIPA domain and produce a sops-encrypted keytab
|
|
# at secrets/<hostname>.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/<hostname>.keytab for
|
|
# the creation rule to match -- the common mistake that breaks sops)
|
|
#
|
|
# Usage:
|
|
# scripts/ipa/create-nixos-ipa-host-account.sh [options] <hostname>
|
|
#
|
|
# Arguments:
|
|
# <hostname> Short hostname, e.g. "tailscale-router". The FQDN is
|
|
# derived as <hostname>.<HOME_DOMAIN>.
|
|
#
|
|
# Options:
|
|
# --ip <addr> Register this IP with the IPA host record (optional).
|
|
# --dc <host> SSH to this host to run IPA commands.
|
|
# Default: $IPA_SERVER (from env.sh / environment).
|
|
# --dc-user <u> SSH user on the domain controller. Default: wayne.
|
|
# --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: wayne)
|
|
# with passwordless sudo (or sudo cached). IPA commands and kinit run
|
|
# as root via sudo so the Kerberos ticket is in root's cache where all
|
|
# ipa tools expect it. If there's no valid ticket, the script runs
|
|
# `sudo kinit admin` 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 <flake-target> first so the host
|
|
# can decrypt its own keytab on boot. This script adds the .sops.yaml
|
|
# creation rule for secrets/<hostname>.keytab automatically, but the
|
|
# host age key anchor (&lxc-<hostname> etc.) must already exist —
|
|
# otherwise only the admin key can decrypt the keytab and the deployed
|
|
# host will fail to read it.
|
|
# 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="wayne"
|
|
IP_ADDR=""
|
|
DRY_RUN=false
|
|
TARGET=""
|
|
|
|
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 "${TARGET}" ]]; then echo "Unexpected argument: $1" >&2; usage 1; fi
|
|
TARGET="$1"; shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "${TARGET}" ]]; then
|
|
echo "Error: hostname required." >&2
|
|
usage 1
|
|
fi
|
|
|
|
# Reject FQDNs passed by mistake — the script appends HOME_DOMAIN itself.
|
|
# "nixos.sweet.home" → FQDN would become "nixos.sweet.home.sweet.home".
|
|
if [[ "${TARGET}" == *"."* ]]; then
|
|
echo "Error: <hostname> must be the short name (e.g. 'nixos'), not a FQDN." >&2
|
|
echo " The FQDN is derived automatically as ${TARGET}.${HOME_DOMAIN}." >&2
|
|
exit 1
|
|
fi
|
|
|
|
FQDN="${TARGET}.${HOME_DOMAIN}"
|
|
KEYTAB_SECRET="${REPO_ROOT}/secrets/${TARGET}.keytab"
|
|
# Temp path on the domain controller — use a name that won't collide.
|
|
DC_TMP="/tmp/nixos-keytab-${TARGET}-$$.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 nixpkgs#sops'"
|
|
SOPS_CMD=(nix run "nixpkgs#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/<hostname>.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/${TARGET}.keytab"
|
|
|
|
RULE_EXISTS=false
|
|
# Match "path_regex: secrets/<hostname>...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/${TARGET}.*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}-${TARGET}"
|
|
if grep -q "^ - &${anchor} " .sops.yaml; then
|
|
RECIPIENTS+=("*${anchor}")
|
|
fi
|
|
done
|
|
|
|
if [[ ${#RECIPIENTS[@]} -eq 1 ]]; then
|
|
echo "Warning: no platform age keys found for '${TARGET}' in .sops.yaml." >&2
|
|
echo " Run scripts/secrets/sync-host-keys.sh <flake-target> first," >&2
|
|
echo " otherwise only the admin key can decrypt the keytab and the" >&2
|
|
echo " deployed host won't be able to read it at boot." >&2
|
|
echo " Continuing with admin-only encryption..." >&2
|
|
fi
|
|
|
|
# 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 ${TARGET} FreeIPA enrollment (binary sops file).
|
|
# Generated by scripts/ipa/create-nixos-ipa-host-account.sh.
|
|
- path_regex: secrets/${TARGET}\\.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. All IPA commands run via
|
|
# sudo so the ticket must be in root's cache — check and refresh as root.
|
|
# ssh -t allocates a PTY so kinit (and sudo if needed) can prompt normally;
|
|
# no password ever touches this script or the shell history.
|
|
if ! $DRY_RUN; then
|
|
if ! ssh "${DC_USER}@${DC_HOST}" "sudo klist -s" &>/dev/null; then
|
|
log "No valid Kerberos ticket on ${DC_HOST} — running sudo kinit admin"
|
|
ssh -t "${DC_USER}@${DC_HOST}" "sudo kinit admin"
|
|
if ! ssh "${DC_USER}@${DC_HOST}" "sudo 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.
|
|
if $DRY_RUN; then
|
|
echo "[dry-run] ssh ${DC_USER}@${DC_HOST} sudo ipa host-add '${FQDN}' ${IP_FLAG} --force"
|
|
else
|
|
HOST_ADD_OUT=$(ssh "${DC_USER}@${DC_HOST}" "sudo ipa host-add '${FQDN}' ${IP_FLAG} --force 2>&1") \
|
|
&& HOST_ADD_RC=0 || HOST_ADD_RC=$?
|
|
if [[ $HOST_ADD_RC -eq 0 ]]; then
|
|
echo "${HOST_ADD_OUT}"
|
|
elif echo "${HOST_ADD_OUT}" | grep -q "already exists"; then
|
|
logn "(host already registered)"
|
|
else
|
|
echo "Error: ipa host-add failed (exit ${HOST_ADD_RC}):" >&2
|
|
echo "${HOST_ADD_OUT}" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# --- Step 3: Fetch the keytab from the domain controller ---
|
|
|
|
log "Fetching keytab for host/${FQDN}"
|
|
|
|
# Remove the plaintext keytab if the script aborts before encryption completes.
|
|
# The trap is cleared at the end of step 4 once sops has encrypted it in-place.
|
|
trap 'rm -f "${KEYTAB_SECRET}"' EXIT
|
|
|
|
dc_run "sudo ipa-getkeytab -s '${IPA_SERVER}' -p 'host/${FQDN}' -k '${DC_TMP}'"
|
|
|
|
if $DRY_RUN; then
|
|
echo "[dry-run] Would stream ${DC_USER}@${DC_HOST}:${DC_TMP} → secrets/${TARGET}.keytab"
|
|
else
|
|
logn "Streaming keytab from ${DC_HOST}:${DC_TMP} → secrets/${TARGET}.keytab"
|
|
# scp can't read a root-owned temp file as ${DC_USER}; pipe through sudo cat instead.
|
|
ssh "${DC_USER}@${DC_HOST}" "sudo cat '${DC_TMP}'" > "${KEYTAB_SECRET}"
|
|
|
|
logn "Removing temp file on ${DC_HOST}"
|
|
dc_run "sudo rm -f '${DC_TMP}'"
|
|
fi
|
|
|
|
# --- Step 4: Encrypt in-place ---
|
|
#
|
|
# The file must already be at secrets/<hostname>.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/${TARGET}.keytab in-place with sops"
|
|
run "${SOPS_CMD[@]}" -e --input-type binary -i "${KEYTAB_SECRET}"
|
|
|
|
# Encryption succeeded — the file is now sops-encrypted; cancel the cleanup trap.
|
|
trap - EXIT
|
|
|
|
# --- Done ---
|
|
|
|
if ! $DRY_RUN; then
|
|
echo ""
|
|
echo "Done. secrets/${TARGET}.keytab is sops-encrypted and ready."
|
|
echo ""
|
|
echo "Next steps:"
|
|
echo " 1. Verify: grep '\"data\": \"ENC' secrets/${TARGET}.keytab"
|
|
echo " 2. Stage and commit:"
|
|
echo " git add secrets/${TARGET}.keytab .sops.yaml"
|
|
echo " git commit -m 'secrets: add IPA keytab for ${TARGET}'"
|
|
echo " 3. Add to hosts/${TARGET}/host.nix (networking block and imports):"
|
|
echo ""
|
|
echo " networking = {"
|
|
echo " hostName = \"${TARGET}\";"
|
|
echo " domain = vars.homeDomain; # required for Kerberos FQDN"
|
|
echo " nameservers = [ vars.domainControllerIp ]; # IPA DNS"
|
|
echo " ..."
|
|
echo " };"
|
|
echo ""
|
|
echo " imports = ["
|
|
echo " (import ../../modules/ipa/client.nix {"
|
|
echo " keytabSopsFile = ../../secrets/${TARGET}.keytab;"
|
|
echo " caCertFile = ../../certs/ipa-ca.crt;"
|
|
echo " })"
|
|
echo " ];"
|
|
echo ""
|
|
echo " 4. Deploy: nixos-rebuild switch (or create-proxmox-resource.sh)"
|
|
fi
|