Files
pxe-server/scripts/deploy-unattend.sh
cproudlock 0b1625ed87 Build/playbook re-run safety: corp access, netplan rollback, first-boot retry
H1  corp_admin_cidrs (UFW 22/9009 on the corp NIC from these only; refuses
    when it would lock out the current SSH client) and corp_ssh_password_auth
    (sshd drop-in, passwords only from the imaging subnet). Defaults keep the
    current behaviour and print a loud warning.
H2  pxe-netplan-apply.sh: pre-flight refusal (pxe-MAC NIC holds the default
    route, a DHCP lease or the route to an SSH client), run from the USB
    before anything changes; post-apply checks (names, imaging IPv4, corp
    IPv4 back within 30 s, SSH route) and a 120 s systemd-run auto-revert.
H3  restore() puts the previous NIC names back (two-phase rename via
    temporary names, so swaps work) before generate + apply.
H4  a failed rename is fatal (restore + exit 1); the play drops the old
    any-interface UFW rules only after verifying both NICs and their
    per-interface SSH rules.
H5  first-boot checks the playbook exit code, retries at next boot (max 5),
    writes /var/lib/pxe-firstboot.failed and /etc/issue + /etc/motd banners.
M1  build-usb.sh verifies iPXE files against the playbook pins, every wheel
    against its own package's hashes, drops the unpinned pip fallback,
    requires --site-vars or --no-site-vars.
M2  webapp restarts when the code, wheels or unit changed.
M3  ignore-carrier on the imaging NIC; ip_nonlocal_bind kept for the
    apache/networkd boot race.
M4  menu suffix must match blancco_kver; missing kernel with no earlier
    build is fatal; a live menu with the staged Blancco test entry is not
    overwritten.
M5  build-proxmox-iso.sh is unsupported (hard exit); use build-usb --image.
M6  deploy-unattend.sh: password via SSHPASS env and sudo stdin only,
    %q-quoted remote args, sha256 check of the live file; lint-unattend.py
    reads PXE_SERVER/PXE_USER/PXE_PASS and uses sshpass -e.
M7  the playbook lints the unattend files before the force: yes copies
    (lint-unattend.py ships on CIDATA as scripts/); pre-commit ACMR.
M8  Blancco initramfs build: no ignore_errors, pipefail, trap cleanup.
Also: Send-ImagingStage.ps1 deploy, carrier hook install tasks
(pxe-nic-carrier, networkd-dispatcher), L1 imaging-active hold
(allow_disruptive) and no iPXE downgrade below an Updates-page install,
L2 iso-volid, L3 kver pin read from the playbook, L4 cp -rL, L5 refuse
>= 4 GiB WinPE files, L6 PIP_DIR wipe guard, L7 per-package wheel hashes,
no online fallback, L8 check-updates.py (apt_pkg/cached compare, all
components, exit 2 on no debs / bad answers).
2026-10-15 14:32:23 -04:00

105 lines
4.2 KiB
Bash
Executable File

#!/bin/bash
#
# deploy-unattend.sh - the ONLY way to put an unattend answer file on a PXE
# server. A bad answer file does not fail at copy time: every bay imaged
# with it stops at "Windows could not parse or process unattend answer
# file" and has to be re-imaged. So this refuses to copy unless the
# linter passes, backs the live file up, swaps it in atomically, checks
# the live copy is byte-identical (sha256) to the linted one, and lints the
# copy on the server afterwards. Any check failing restores the backup.
#
# Usage: scripts/deploy-unattend.sh <image-type> <local FlatUnattendW10.xml>
# e.g. scripts/deploy-unattend.sh gea-engineer playbook/FlatUnattendW10-engineer.xml
#
# Env: PXE_SERVER (default 172.16.9.1), PXE_USER (default pxe); the
# remote password is read from PXE_PASS or prompted for. It never appears
# on a command line (local or remote): sshpass gets it from SSHPASS, sudo
# on the server from stdin.
set -euo pipefail
IMAGE="${1:?usage: $0 <image-type> <local unattend xml>}"
SRC="${2:?usage: $0 <image-type> <local unattend xml>}"
SERVER="${PXE_SERVER:-172.16.9.1}"
USER_="${PXE_USER:-pxe}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
LINT="$REPO_ROOT/scripts/lint-unattend.py"
DEST="/srv/samba/winpeapps/$IMAGE/Deploy/FlatUnattendW10.xml"
STAMP="$(date +%Y%m%d-%H%M%S)"
case "$IMAGE" in
*[!A-Za-z0-9._-]*|"") echo "bad image type: $IMAGE" >&2; exit 2 ;;
esac
case "$SERVER$USER_" in
*[!A-Za-z0-9._@:-]*) echo "bad PXE_SERVER / PXE_USER" >&2; exit 2 ;;
esac
[ -f "$SRC" ] || { echo "no such file: $SRC" >&2; exit 2; }
echo "== 1/4 lint $SRC"
if ! python3 "$LINT" --local "$SRC"; then
echo "REFUSED: the linter failed; nothing was copied." >&2
exit 1
fi
LOCAL_SHA=$(sha256sum <"$SRC" | cut -d' ' -f1)
if [ -z "${PXE_PASS:-}" ]; then
read -rs -p "password for $USER_@$SERVER: " PXE_PASS; echo
fi
export SSHPASS="$PXE_PASS" PXE_PASS PXE_USER="$USER_" PXE_SERVER="$SERVER"
SSH=(sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 "$USER_@$SERVER")
# remote SCRIPT ARGS...: run SCRIPT under bash on the server with ARGS. The
# first stdin line is the password; the script reads it into $P and hands
# it to "sudo -S" through a pipe (printf is a builtin: not in ps). Every
# word is %q-quoted for the remote shell.
remote() {
local script=$1; shift
local cmd
cmd="bash -c $(printf '%q' "$script") _$(printf ' %q' "$@")"
printf '%s\n' "$PXE_PASS" | "${SSH[@]}" "$cmd"
}
# Prologue every remote script starts with.
PRE='set -euo pipefail; IFS= read -r P; S() { printf "%s\n" "$P" | sudo -k -S -p "" "$@"; };'
echo "== 2/4 upload to $SERVER"
TMP=$(remote "$PRE"' mktemp /tmp/unattend.XXXXXX')
case "$TMP" in /tmp/unattend.*) ;; *) echo "remote mktemp failed: $TMP" >&2; exit 1 ;; esac
sshpass -e scp -q -o StrictHostKeyChecking=no "$SRC" "$USER_@$SERVER:$TMP"
echo "== 3/4 back up and swap $DEST"
OUT=$(remote "$PRE"'
DEST=$1 TMP=$2 STAMP=$3 WANT=$4
[ -f "$DEST" ] || { echo "no live file at $DEST" >&2; rm -f "$TMP"; exit 1; }
GOT=$(sha256sum <"$TMP" | cut -d" " -f1)
[ "$GOT" = "$WANT" ] || { echo "upload corrupted: $GOT != $WANT" >&2; rm -f "$TMP"; exit 1; }
S cp -a "$DEST" "$DEST.bak-$STAMP"
S install -m 644 -o root -g root "$TMP" "$DEST.new"
S mv -f "$DEST.new" "$DEST"
rm -f "$TMP"
echo "backup: $DEST.bak-$STAMP"
echo "live-sha256: $(sha256sum <"$DEST" | cut -d" " -f1)"' "$DEST" "$TMP" "$STAMP" "$LOCAL_SHA")
echo "$OUT"
rollback() {
echo "$1: restoring the backup." >&2
remote "$PRE"' S cp -a "$1.bak-$2" "$1"' "$DEST" "$STAMP"
exit 1
}
LIVE_SHA=$(sed -n 's/^live-sha256: //p' <<<"$OUT")
if [ "$LIVE_SHA" != "$LOCAL_SHA" ]; then
rollback "LIVE FILE sha256 $LIVE_SHA != linted copy $LOCAL_SHA"
fi
echo "== 4/4 lint the live copy"
if ! python3 "$LINT" --server "$SERVER" --image "$IMAGE" --sha256 >"${TMPDIR:-/tmp}/lint-live-$STAMP.log" 2>&1; then
cat "${TMPDIR:-/tmp}/lint-live-$STAMP.log" >&2
rollback "LIVE FILE FAILS LINT"
fi
cat "${TMPDIR:-/tmp}/lint-live-$STAMP.log"
if ! grep -q "^sha256 $LOCAL_SHA $IMAGE\$" "${TMPDIR:-/tmp}/lint-live-$STAMP.log"; then
rollback "the linter read back a different file than the one deployed"
fi
rm -f "${TMPDIR:-/tmp}/lint-live-$STAMP.log"
echo "Deployed $SRC to $IMAGE (sha256 $LOCAL_SHA)."