fix(ha/deploy): use nixos+sudo instead of root SSH; temp key for inter-node comms

Root SSH was failing because only the RSA admin key was authorized but the
local dev box only has an ed25519 key. Fix:

- cluster-config.nix: add ed25519 keys to root (same set as nixos user) so
  future deployments work without the temp-key workaround
- deploy.sh/acceptance-tests.sh: SSH as nixos user with sudo instead of root@
- cluster-init.sh: HA_USER/HA_KEY env vars + n2_ssh()/n2_scp() helpers so
  inter-node SSH works regardless of whether root-to-root is available
- deploy.sh Phase 6: generate temp keypair, authorize on node2, place on node1
  for root to use during cluster-init, clean up afterward

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaH1cSGvhogRP5ExoF6nD8
This commit is contained in:
2026-07-28 17:30:53 +10:00
co-authored by Claude Sonnet 4.6
parent 3f9b968a41
commit acebbdbe26
4 changed files with 80 additions and 26 deletions
+6 -3
View File
@@ -19,9 +19,12 @@
# cluster-enable-stonith.sh once the fence key is deployed. # cluster-enable-stonith.sh once the fence key is deployed.
{ lib, vars, ... }: { lib, vars, ... }:
{ {
# Root SSH access with the admin key — needed for the deploy script to upload # Root SSH access — same key set as nixos user so all admin keys can reach root.
# and run cluster-init.sh as root on node1, and for node1→node2 SSH during init. users.users.root.openssh.authorizedKeys.keys = [
users.users.root.openssh.authorizedKeys.keys = [ vars.adminSshKey ]; vars.adminSshKey
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICMJhrfFayLBG+gWtO6oAvgambw5nWWgztiTFEaaaVRH debian@surface"
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGygkCljN6uKpdJbHTOQtn8ZnH+wKXDLAwrDFbLrE/65 nixos@nixos"
];
# Passwordless sudo for wheel — operator SSHes as nixos and uses sudo for # Passwordless sudo for wheel — operator SSHes as nixos and uses sudo for
# cluster management commands (drbdadm, crm*, pcs, etc.) # cluster management commands (drbdadm, crm*, pcs, etc.)
+3 -2
View File
@@ -24,8 +24,9 @@ RESULTS=()
pass() { echo " PASS: $1"; ((PASS++)); RESULTS+=("PASS $1"); } pass() { echo " PASS: $1"; ((PASS++)); RESULTS+=("PASS $1"); }
fail() { echo " FAIL: $1"; ((FAIL++)); RESULTS+=("FAIL $1"); } fail() { echo " FAIL: $1"; ((FAIL++)); RESULTS+=("FAIL $1"); }
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "root@${NODE1_IP}" "$@" 2>/dev/null; } HA_USER="nixos"
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "root@${NODE2_IP}" "$@" 2>/dev/null; } n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
echo "════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════"
echo " HA Cluster Acceptance Tests — $(date '+%Y-%m-%d %H:%M:%S')" echo " HA Cluster Acceptance Tests — $(date '+%Y-%m-%d %H:%M:%S')"
+31 -11
View File
@@ -36,6 +36,12 @@ VMID_NODE1="${VMID_NODE1:-}" # set by deploy.sh; needed for STONITH
VMID_NODE2="${VMID_NODE2:-}" VMID_NODE2="${VMID_NODE2:-}"
PVE_HOST="${PVE_HOST:-pve1.sweet.home}" PVE_HOST="${PVE_HOST:-pve1.sweet.home}"
PVE_USER="${PVE_USER:-wayne}" PVE_USER="${PVE_USER:-wayne}"
# Inter-node SSH: HA_USER is the user to SSH as on NODE2; HA_KEY is the private
# key to use. Default is root-to-root (no key arg). deploy.sh sets HA_USER=nixos
# and HA_KEY=/tmp/cluster-init-key so the script works even when root-to-root SSH
# is not available.
HA_USER="${HA_USER:-root}"
HA_KEY="${HA_KEY:-}"
# NFS dataset subdirectories to create under XFS_MOUNT. # NFS dataset subdirectories to create under XFS_MOUNT.
# Must mirror vars.nfsShares subpath values in variables.nix. # Must mirror vars.nfsShares subpath values in variables.nix.
@@ -58,6 +64,24 @@ warn() { echo "[cluster-init] WARNING: $*" >&2; }
[[ $(id -u) -eq 0 ]] || die "must run as root" [[ $(id -u) -eq 0 ]] || die "must run as root"
[[ "$(hostname)" == "$NODE1" ]] || die "must run on $NODE1" [[ "$(hostname)" == "$NODE1" ]] || die "must run on $NODE1"
# Inter-node SSH/SCP helpers — abstract over root-to-root vs nixos+sudo.
_SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
[[ -n "$HA_KEY" ]] && _SSH_OPTS="-i $HA_KEY $_SSH_OPTS"
if [[ "$HA_USER" == "root" ]]; then
n2_ssh() { ssh $_SSH_OPTS "root@${NODE2_IP}" "$@"; }
n2_scp() { scp $_SSH_OPTS "$1" "root@${NODE2_IP}:$2"; }
else
# Non-root user with passwordless sudo; wrap each command with sudo.
n2_ssh() { ssh $_SSH_OPTS "${HA_USER}@${NODE2_IP}" sudo "$@"; }
n2_scp() {
# SCP to a tmp path, then sudo-move to the real destination as the remote user.
local src="$1" dst="$2"
local tmp="/tmp/_cluster_init_scp_$$"
scp $_SSH_OPTS "$src" "${HA_USER}@${NODE2_IP}:${tmp}"
ssh $_SSH_OPTS "${HA_USER}@${NODE2_IP}" sudo mv "${tmp}" "${dst}"
}
fi
# ── 0. Corosync authkey ─────────────────────────────────────────────────── # ── 0. Corosync authkey ───────────────────────────────────────────────────
AUTHKEY="/etc/corosync/authkey" AUTHKEY="/etc/corosync/authkey"
mkdir -p /etc/corosync mkdir -p /etc/corosync
@@ -67,13 +91,13 @@ if [[ ! -f "$AUTHKEY" ]]; then
chmod 0400 "$AUTHKEY" chmod 0400 "$AUTHKEY"
fi fi
log "Distributing authkey to $NODE2..." log "Distributing authkey to $NODE2..."
ssh "root@${NODE2_IP}" "mkdir -p /etc/corosync" n2_ssh "mkdir -p /etc/corosync"
scp -q "$AUTHKEY" "root@${NODE2_IP}:${AUTHKEY}" n2_scp "$AUTHKEY" "$AUTHKEY"
ssh "root@${NODE2_IP}" "chmod 0400 '${AUTHKEY}'" n2_ssh "chmod 0400 '${AUTHKEY}'"
log "Restarting corosync on both nodes..." log "Restarting corosync on both nodes..."
systemctl restart corosync systemctl restart corosync
ssh "root@${NODE2_IP}" "systemctl restart corosync" n2_ssh "systemctl restart corosync"
sleep 3 sleep 3
# ── 1. Corosync quorum ──────────────────────────────────────────────────── # ── 1. Corosync quorum ────────────────────────────────────────────────────
@@ -104,15 +128,11 @@ if ! drbdadm dstate ha-data 2>/dev/null | grep -q "UpToDate\|Inconsistent\|Diskl
fi fi
log "Initialising DRBD metadata on $NODE2..." log "Initialising DRBD metadata on $NODE2..."
ssh "root@${NODE2_IP}" " n2_ssh "bash -c 'if ! drbdadm dstate ha-data 2>/dev/null | grep -q UpToDate.Inconsistent.Diskless; then drbdadm create-md ha-data --force; fi'"
if ! drbdadm dstate ha-data 2>/dev/null | grep -q 'UpToDate\|Inconsistent\|Diskless'; then
drbdadm create-md ha-data --force
fi
"
log "Bringing up DRBD on both nodes..." log "Bringing up DRBD on both nodes..."
drbdadm up ha-data 2>/dev/null || true drbdadm up ha-data 2>/dev/null || true
ssh "root@${NODE2_IP}" "drbdadm up ha-data 2>/dev/null" || true n2_ssh "drbdadm up ha-data" 2>/dev/null || true
log "Forcing $NODE1 to DRBD Primary for initial sync..." log "Forcing $NODE1 to DRBD Primary for initial sync..."
drbdadm primary ha-data --force drbdadm primary ha-data --force
@@ -163,7 +183,7 @@ saveconfig /etc/target/saveconfig.json
EOF EOF
log "Distributing iSCSI saveconfig to $NODE2..." log "Distributing iSCSI saveconfig to $NODE2..."
scp -q /etc/target/saveconfig.json "root@${NODE2_IP}:/etc/target/saveconfig.json" n2_scp /etc/target/saveconfig.json /etc/target/saveconfig.json
log "Unmounting ${XFS_MOUNT} — Pacemaker manages it..." log "Unmounting ${XFS_MOUNT} — Pacemaker manages it..."
umount "${XFS_MOUNT}" umount "${XFS_MOUNT}"
+40 -10
View File
@@ -147,14 +147,16 @@ pve_check() {
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*" ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*"
} }
HA_USER="nixos"
n1() { n1() {
# Run a command on ha-server-1 via SSH. # Run a command on ha-server-1 via SSH as nixos with sudo.
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "root@${NODE1_IP}" "$@" 2>/dev/null ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null
} }
n2() { n2() {
# Run a command on ha-server-2 via SSH. # Run a command on ha-server-2 via SSH as nixos with sudo.
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "root@${NODE2_IP}" "$@" 2>/dev/null ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null
} }
wait_for_ssh() { wait_for_ssh() {
@@ -167,7 +169,7 @@ wait_for_ssh() {
log "Waiting for SSH on ${label} (${ip}) — up to 5 min..." log "Waiting for SSH on ${label} (${ip}) — up to 5 min..."
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=3 \ if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=3 \
-o BatchMode=yes "root@${ip}" true 2>/dev/null; then -o BatchMode=yes "${HA_USER}@${ip}" true 2>/dev/null; then
logn "${label} is up." logn "${label} is up."
return 0 return 0
fi fi
@@ -332,25 +334,53 @@ if ! $SKIP_CLUSTER_INIT; then
[[ -x "$CLUSTER_INIT" ]] || chmod +x "$CLUSTER_INIT" [[ -x "$CLUSTER_INIT" ]] || chmod +x "$CLUSTER_INIT"
if $DRY_RUN; then if $DRY_RUN; then
logn "[dry-run] Would scp cluster-init.sh to root@${NODE1_IP} and run it" logn "[dry-run] Would generate temp key, authorise on ${NODE2_HOST}, scp cluster-init.sh to ${NODE1_HOST}, and run it as root via sudo"
else else
# Generate a temp keypair so cluster-init.sh can SSH node1→node2 as ${HA_USER}.
# Root on node1 has no keys; a temp key bridging node1→node2 nixos solves this.
TEMP_KEY="${REPO_ROOT}/.tmp-cluster-init-key"
TEMP_KEY_PUB="${TEMP_KEY}.pub"
rm -f "$TEMP_KEY" "$TEMP_KEY_PUB"
ssh-keygen -t ed25519 -f "$TEMP_KEY" -N "" -C "cluster-init-temp-$(date +%s)" -q
TEMP_PUBKEY=$(cat "$TEMP_KEY_PUB")
logn "Authorising temp key on ${NODE2_HOST} for ${HA_USER}..."
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE2_IP}" \
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '${TEMP_PUBKEY}' >> ~/.ssh/authorized_keys"
logn "Placing temp key on ${NODE1_HOST} for root..."
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
"$TEMP_KEY" "${HA_USER}@${NODE1_IP}:/tmp/cluster-init-key"
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
"sudo mkdir -p /root/.ssh && sudo cp /tmp/cluster-init-key /root/.ssh/cluster-init-key && \
sudo chmod 600 /root/.ssh/cluster-init-key && rm -f /tmp/cluster-init-key"
logn "Uploading cluster-init.sh to ${NODE1_HOST}..." logn "Uploading cluster-init.sh to ${NODE1_HOST}..."
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \ scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
"$CLUSTER_INIT" "root@${NODE1_IP}:/tmp/cluster-init.sh" "$CLUSTER_INIT" "${HA_USER}@${NODE1_IP}:/tmp/cluster-init.sh"
logn "Running cluster-init.sh on ${NODE1_HOST}..." logn "Running cluster-init.sh on ${NODE1_HOST}..."
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "root@${NODE1_IP}" \ ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
"NODE1=${NODE1_HOST} NODE2=${NODE2_HOST} \ "sudo env NODE1=${NODE1_HOST} NODE2=${NODE2_HOST} \
NODE1_IP=${NODE1_IP} NODE2_IP=${NODE2_IP} \ NODE1_IP=${NODE1_IP} NODE2_IP=${NODE2_IP} \
VIP=192.168.2.229 XFS_MOUNT=/srv/ha-data \ VIP=192.168.2.229 XFS_MOUNT=/srv/ha-data \
ISCSI_IQN=iqn.2026-01.home.sweet:ha-storage \ ISCSI_IQN=iqn.2026-01.home.sweet:ha-storage \
VMID_NODE1=${VMID1} VMID_NODE2=${VMID2} \ VMID_NODE1=${VMID1} VMID_NODE2=${VMID2} \
HA_USER=${HA_USER} HA_KEY=/root/.ssh/cluster-init-key \
bash /tmp/cluster-init.sh" bash /tmp/cluster-init.sh"
logn "Cleaning up temp key from both nodes..."
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE2_IP}" \
"sed -i '/cluster-init-temp/d' ~/.ssh/authorized_keys" 2>/dev/null || true
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
"sudo rm -f /root/.ssh/cluster-init-key" 2>/dev/null || true
rm -f "$TEMP_KEY" "$TEMP_KEY_PUB"
# Encrypt the corosync authkey generated by cluster-init and commit it. # Encrypt the corosync authkey generated by cluster-init and commit it.
log " Encrypting corosync authkey into secrets/ha-corosync-authkey..." log " Encrypting corosync authkey into secrets/ha-corosync-authkey..."
AUTHKEY_TMP="${REPO_ROOT}/secrets/ha-corosync-authkey.tmp" AUTHKEY_TMP="${REPO_ROOT}/secrets/ha-corosync-authkey.tmp"
n1 "cat /etc/corosync/authkey" > "$AUTHKEY_TMP" ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
"sudo cat /etc/corosync/authkey" > "$AUTHKEY_TMP"
if [[ ! -s "$AUTHKEY_TMP" ]]; then if [[ ! -s "$AUTHKEY_TMP" ]]; then
err "corosync authkey on node1 is empty — cluster-init may have failed." err "corosync authkey on node1 is empty — cluster-init may have failed."
fi fi