Archived
feat(ipa): add create-nixos-ipa-host-account script #76
@@ -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
|
||||
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/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 as root to this host for ipa-getkeytab.
|
||||
# Default: $IPA_SERVER (from env.sh / environment).
|
||||
# --dc-user <u> 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 <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.
|
||||
# 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/<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/${HOSTNAME}.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/${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/<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/${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
|
||||
Reference in New Issue
Block a user