Merge branch 'fix-build' into pxe-webapp-redesign

This commit is contained in:
cproudlock
2026-10-15 14:38:48 -04:00
15 changed files with 1401 additions and 614 deletions

View File

@@ -2,43 +2,64 @@
# pxe-netplan-apply.sh - install the two-NIC netplan config without ever
# leaving the box unreachable on the corporate NIC.
#
# Usage: pxe-netplan-apply.sh STAGED_YAML PXE_NAME PXE_MAC CORP_NAME CORP_MAC
# Usage:
# pxe-netplan-apply.sh STAGED_YAML PXE_NAME PXE_MAC CORP_NAME CORP_MAC
# pxe-netplan-apply.sh --preflight STAGED_YAML PXE_NAME PXE_MAC CORP_NAME CORP_MAC
# pxe-netplan-apply.sh --revert BACKUP_DIR (the auto-revert timer)
#
# Called by pxe_server_setup.yml. Steps:
# Env (set by pxe_server_setup.yml):
# PXE_SUBNET imaging LAN, e.g. 172.16.9.0/24 (SSH peers inside it may
# legitimately come in on the imaging NIC)
# PXE_SSH_CLIENT IP of the admin's SSH client ($SSH_CONNECTION field 1);
# every established sshd peer is checked as well
# PXE_NETPLAN_REVERT_AFTER seconds before the auto-revert fires (120)
# PXE_NETPLAN_CORP_WAIT seconds to wait for the corp IPv4 again (30)
#
# Steps (apply):
# 1. validate STAGED_YAML in a scratch root (netplan generate --root-dir);
# nothing on the box changes if it is invalid
# 2. stop here (prints UNCHANGED) if it is already installed, no other
# file configures wired NICs, and both NICs carry their stable names
# 3. back up /etc/netplan to /var/backups/netplan-<timestamp>/
# 4. install it (other wired configs moved aside), netplan generate
# (restore on failure)
# 5. rename the NICs to their stable names (the .link files netplan
# writes do it on every later boot), netplan apply (restore on failure)
# 6. check the imaging address and the corp NIC (warning only: the corp
# cable may simply be unplugged)
# Prints CHANGED or UNCHANGED as the last line. Exit 1 = nothing applied
# (the previous config is back in place).
# 2. stop here (UNCHANGED) if it is already installed, no other file
# configures wired NICs, and both NICs carry their stable names
# 3. pre-flight: REFUSE (exit 1, nothing changed) when the NIC that
# PXE_MAC names holds the default route, a DHCP lease, or the route to
# an SSH client outside PXE_SUBNET. That NIC becomes static with no
# gateway, so any of these means it is the corp-cabled one (swapped
# MACs) and applying would cut the admin off.
# 4. back up /etc/netplan to /var/backups/netplan-<timestamp>/, record the
# current NIC names, and arm an auto-revert (systemd-run timer) that
# restores the backup unless step 8 cancels it
# 5. install it (other wired configs moved aside), netplan generate
# 6. rename the NICs to their stable names (the .link files netplan
# writes do it on every later boot)
# 7. netplan apply
# 8. check: both names present, the imaging NIC has its IPv4, the corp
# NIC has IPv4 again if it had one before (PXE_NETPLAN_CORP_WAIT s),
# SSH clients still route out of the corp side; then cancel the revert
# Any failure in 5-8 restores the backup AND the previous NIC names, then
# exits 1. Prints CHANGED / UNCHANGED (or NEEDED for --preflight) as the
# last line.
set -u
if [ $# -ne 5 ]; then
echo "usage: $0 STAGED_YAML PXE_NAME PXE_MAC CORP_NAME CORP_MAC" >&2
exit 2
fi
STAGED=$1
PXE_NAME=$2
PXE_MAC=${3,,}
CORP_NAME=$4
CORP_MAC=${5,,}
NETPLAN_DIR=${NETPLAN_DIR:-/etc/netplan}
SYSFS_NET=${SYSFS_NET:-/sys/class/net}
BACKUP_ROOT=${BACKUP_ROOT:-/var/backups}
LEASE_DIR=${LEASE_DIR:-/run/systemd/netif/leases}
LOCK_FILE=${LOCK_FILE:-/run/pxe-netplan-apply.lock}
REVERT_AFTER=${PXE_NETPLAN_REVERT_AFTER:-120}
CORP_WAIT=${PXE_NETPLAN_CORP_WAIT:-30}
PXE_WAIT=${PXE_NETPLAN_PXE_WAIT:-20}
TARGET=$NETPLAN_DIR/50-cloud-init.yaml
SELF=$(readlink -f "$0")
log() { echo "pxe-netplan-apply: $*"; }
# --- NIC helpers -------------------------------------------------------------
# Name of the physical NIC that has MAC $1 (empty if none).
iface_of() {
local d
for d in /sys/class/net/*; do
for d in "$SYSFS_NET"/*; do
[ -e "$d/device" ] || continue
if [ "$(cat "$d/address" 2>/dev/null)" = "$1" ]; then
basename "$d"
@@ -48,11 +69,159 @@ iface_of() {
return 0
}
has_v4() {
ip -4 -o addr show dev "$1" scope global 2>/dev/null | grep -q ' inet '
}
default_route_devs() {
{ ip -4 route show default; ip -6 route show default; } 2>/dev/null |
awk '{for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1)}' | sort -u
}
# DHCP-assigned address (networkd and NetworkManager both flag those
# "dynamic"), or a networkd lease file for the link.
has_dhcp_lease() {
local idx
ip -o addr show dev "$1" 2>/dev/null | grep -qw dynamic && return 0
idx=$(cat "$SYSFS_NET/$1/ifindex" 2>/dev/null)
[ -n "$idx" ] && [ -s "$LEASE_DIR/$idx" ] && return 0
return 1
}
route_dev() {
ip -o route get "$1" 2>/dev/null |
awk '{for (i = 1; i < NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
}
ip2int() {
local a b c d
[[ "$1" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1
a=${BASH_REMATCH[1]} b=${BASH_REMATCH[2]} c=${BASH_REMATCH[3]} d=${BASH_REMATCH[4]}
echo $(( (a << 24) | (b << 16) | (c << 8) | d ))
}
in_subnet() {
local ipn netn len mask
[ -n "${2:-}" ] || return 1
ipn=$(ip2int "$1") || return 1
netn=$(ip2int "${2%/*}") || return 1
len=${2#*/}
[[ "$len" =~ ^[0-9]+$ ]] && [ "$len" -le 32 ] || return 1
mask=$(( len == 0 ? 0 : (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF ))
[ $(( ipn & mask )) -eq $(( netn & mask )) ]
}
# Admin SSH clients: PXE_SSH_CLIENT, our own SSH_CONNECTION, and every
# established peer of sshd (port 22). IPv4-mapped IPv6 is unwrapped.
ssh_peers() {
{
[ -n "${PXE_SSH_CLIENT:-}" ] && echo "$PXE_SSH_CLIENT"
[ -n "${SSH_CONNECTION:-}" ] && echo "${SSH_CONNECTION%% *}"
ss -Htn state established '( sport = :22 )' 2>/dev/null | awk '{print $NF}' |
sed -E 's/:[0-9]+$//; s/^\[//; s/\]$//'
} | sed -E 's/^::ffff://' | grep -E '^[0-9a-fA-F.:]+$' | sort -u
}
# --- pre-flight --------------------------------------------------------------
# 0 = safe to apply. Prints every reason it is not.
preflight() {
local pif cif peer dev bad=0
pif=$(iface_of "$PXE_MAC")
cif=$(iface_of "$CORP_MAC")
if [ -z "$pif" ]; then
log "REFUSED: no physical NIC has pxe_nic_mac $PXE_MAC"
return 1
fi
if [ -z "$cif" ]; then
log "REFUSED: no physical NIC has corp_nic_mac $CORP_MAC"
return 1
fi
if default_route_devs | grep -qxF "$pif"; then
log "REFUSED: $pif (pxe_nic_mac $PXE_MAC) holds the default route"
bad=1
fi
if has_dhcp_lease "$pif"; then
log "REFUSED: $pif (pxe_nic_mac $PXE_MAC) has a DHCP lease"
bad=1
fi
while IFS= read -r peer; do
[ -n "$peer" ] || continue
dev=$(route_dev "$peer")
[ "$dev" = "$pif" ] || continue
if in_subnet "$peer" "${PXE_SUBNET:-}"; then
continue # an admin on the imaging LAN keeps 172.16.9.1
fi
log "REFUSED: the SSH client $peer is reached through $pif (pxe_nic_mac $PXE_MAC)"
bad=1
done < <(ssh_peers)
if [ "$bad" -ne 0 ]; then
log "The NIC named by pxe_nic_mac looks like the GE corporate one: pxe_nic_mac"
log "and corp_nic_mac are probably swapped, or the cables are. It would become"
log "static ${PXE_SUBNET:-imaging} with no gateway and cut this box off. Nothing was changed."
log "NICs now: $(for d in "$SYSFS_NET"/*; do [ -e "$d/device" ] && printf '%s=%s ' "$(basename "$d")" "$(cat "$d/address" 2>/dev/null)"; done)"
return 1
fi
return 0
}
# --- renaming ----------------------------------------------------------------
link_rename() {
local cur=$1 new=$2
log "renaming $cur to $new"
if ip link set dev "$cur" down &&
ip link set dev "$cur" name "$new" &&
ip link set dev "$new" up; then
return 0
fi
log "ERROR: rename $cur -> $new failed"
ip link set dev "$cur" up 2>/dev/null
ip link set dev "$new" up 2>/dev/null
return 1
}
# set_names MAC1 NAME1 MAC2 NAME2: give each MAC its name. A NIC that must
# change goes through a temporary name first, so a swap (pxe0 <-> corp0)
# works in both directions. Temporary names carry this run's PID, so they
# never collide with a name left over from an earlier (failed) pass.
set_names() {
local -a macs=("$1" "$3") want=("$2" "$4") cur=()
local i
RENAME_SEQ=$(( ${RENAME_SEQ:-0} + 1 ))
for i in 0 1; do
[ -n "${want[i]}" ] || return 0
cur[i]=$(iface_of "${macs[i]}")
if [ -z "${cur[i]}" ]; then
log "ERROR: no NIC with MAC ${macs[i]}"
return 1
fi
done
for i in 0 1; do
[ "${cur[i]}" = "${want[i]}" ] && continue
link_rename "${cur[i]}" "pxt$$-$RENAME_SEQ$i" || return 1
cur[i]=pxt$$-$RENAME_SEQ$i
done
for i in 0 1; do
[ "${cur[i]}" = "${want[i]}" ] && continue
link_rename "${cur[i]}" "${want[i]}" || return 1
done
for i in 0 1; do
if [ "$(iface_of "${macs[i]}")" != "${want[i]}" ]; then
log "ERROR: MAC ${macs[i]} is not named ${want[i]} after renaming"
return 1
fi
done
return 0
}
names_ok() {
[ "$(iface_of "$PXE_MAC")" = "$PXE_NAME" ] &&
[ "$(iface_of "$CORP_MAC")" = "$CORP_NAME" ]
}
# --- backup / restore --------------------------------------------------------
# Other netplan files that configure wired NICs (installer leftovers, an
# en* DHCP match): they would put a second config on the two NICs. Files
# without "ethernets" (WiFi, NetworkManager wifi profiles) are left alone.
@@ -61,6 +230,94 @@ other_files() {
! -path "$TARGET" -exec grep -l 'ethernets' {} + 2>/dev/null | sort
}
# State of one apply, next to (not inside) the backup, so restoring the
# backup never copies it into /etc/netplan.
write_state() {
{
echo "NETPLAN_DIR=$NETPLAN_DIR"
echo "PXE_MAC=$PXE_MAC"
echo "ORIG_PXE_IF=$ORIG_PXE_IF"
echo "CORP_MAC=$CORP_MAC"
echo "ORIG_CORP_IF=$ORIG_CORP_IF"
} >"$BACKUP.state"
}
read_state() {
local k v
while IFS='=' read -r k v; do
case "$k" in
NETPLAN_DIR) [[ "$v" =~ ^/[A-Za-z0-9/._-]*$ ]] && NETPLAN_DIR=$v ;;
PXE_MAC|CORP_MAC) [[ "$v" =~ ^([0-9a-f]{2}:){5}[0-9a-f]{2}$ ]] && printf -v "$k" '%s' "$v" ;;
ORIG_PXE_IF|ORIG_CORP_IF) [[ "$v" =~ ^[A-Za-z0-9._-]{0,15}$ ]] && printf -v "$k" '%s' "$v" ;;
esac
done <"$BACKUP.state"
TARGET=$NETPLAN_DIR/50-cloud-init.yaml
}
cancel_revert() {
[ -n "${REVERT_UNIT:-}" ] || return 0
systemctl stop "$REVERT_UNIT.timer" "$REVERT_UNIT.service" >/dev/null 2>&1
if systemctl is-active --quiet "$REVERT_UNIT.timer" 2>/dev/null; then
log "WARNING: could not cancel $REVERT_UNIT.timer (the committed marker still stops it)"
fi
}
# Put the backed-up config AND the previous NIC names back.
restore() {
local rc=0
log "restoring the previous config from $BACKUP"
find "$NETPLAN_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -delete
cp -a "$BACKUP/." "$NETPLAN_DIR/"
if [ -n "${ORIG_PXE_IF:-}" ] && [ -n "${ORIG_CORP_IF:-}" ]; then
set_names "$PXE_MAC" "$ORIG_PXE_IF" "$CORP_MAC" "$ORIG_CORP_IF" ||
{ log "ERROR: could not put the previous NIC names back"; rc=1; }
fi
netplan generate && netplan apply || { log "ERROR: netplan apply of the restored config failed"; rc=1; }
touch "$BACKUP.reverted"
return $rc
}
fail() {
log "ERROR: $*"
restore
cancel_revert
log "the previous config is back in place; nothing applied"
exit 1
}
# --- modes -------------------------------------------------------------------
MODE=apply
case "${1:-}" in
--preflight) MODE=preflight; shift ;;
--revert)
[ $# -eq 2 ] || { echo "usage: $0 --revert BACKUP_DIR" >&2; exit 2; }
BACKUP=${2%/}
[ -d "$BACKUP" ] && [ -f "$BACKUP.state" ] || { log "revert: no backup/state at $BACKUP"; exit 1; }
exec 9>"$LOCK_FILE"
flock -w 600 9 || log "revert: lock still held after 600 s; reverting anyway"
if [ -e "$BACKUP.committed" ] || [ -e "$BACKUP.reverted" ]; then
log "revert: $BACKUP already committed or reverted; nothing to do"
exit 0
fi
PXE_MAC="" CORP_MAC="" ORIG_PXE_IF="" ORIG_CORP_IF=""
read_state
log "revert: the apply was not confirmed within its window"
restore
exit $?
;;
esac
if [ $# -ne 5 ]; then
echo "usage: $0 [--preflight] STAGED_YAML PXE_NAME PXE_MAC CORP_NAME CORP_MAC" >&2
exit 2
fi
STAGED=$1
PXE_NAME=$2
PXE_MAC=${3,,}
CORP_NAME=$4
CORP_MAC=${5,,}
# 1. validate in a scratch root
SCRATCH=$(mktemp -d)
trap 'rm -rf "$SCRATCH"' EXIT
@@ -78,72 +335,98 @@ if cmp -s "$STAGED" "$TARGET" && [ -z "$(other_files)" ] && names_ok; then
exit 0
fi
# 3. back up
BACKUP=/var/backups/netplan-$(date +%Y%m%d-%H%M%S)
# 3. pre-flight
if ! preflight; then
exit 1
fi
if [ "$MODE" = preflight ]; then
log "pre-flight passed; the netplan config would change"
echo NEEDED
exit 0
fi
exec 9>"$LOCK_FILE"
flock -w 60 9 || { log "ERROR: another pxe-netplan-apply holds $LOCK_FILE; nothing changed"; exit 1; }
# 4. back up, record names, arm the auto-revert
ORIG_PXE_IF=$(iface_of "$PXE_MAC")
ORIG_CORP_IF=$(iface_of "$CORP_MAC")
CORP_HAD_V4=0
has_v4 "$ORIG_CORP_IF" && CORP_HAD_V4=1
BACKUP=$BACKUP_ROOT/netplan-$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP"
cp -a "$NETPLAN_DIR/." "$BACKUP/"
log "previous config saved in $BACKUP"
write_state
log "previous config saved in $BACKUP (NICs: $ORIG_PXE_IF, $ORIG_CORP_IF)"
restore() {
log "restoring the previous config from $BACKUP"
find "$NETPLAN_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -delete
cp -a "$BACKUP/." "$NETPLAN_DIR/"
netplan generate && netplan apply
}
REVERT_UNIT=""
if [ "$REVERT_AFTER" -gt 0 ] 2>/dev/null && command -v systemd-run >/dev/null; then
REVERT_UNIT=pxe-netplan-revert-$(basename "$BACKUP" | tr -cd 'A-Za-z0-9-')
if systemd-run --quiet --unit="$REVERT_UNIT" --on-active="$REVERT_AFTER" \
--timer-property=AccuracySec=1s "$SELF" --revert "$BACKUP"; then
log "auto-revert armed: $REVERT_UNIT.timer restores $BACKUP in ${REVERT_AFTER}s unless this run confirms"
else
log "WARNING: could not arm the auto-revert timer; relying on the in-script checks"
REVERT_UNIT=""
fi
else
log "WARNING: auto-revert off (no systemd-run or PXE_NETPLAN_REVERT_AFTER=0)"
fi
# 4. install, moving aside the other wired configs (an en* DHCP match would
# An SSH drop (HUP) must not stop us half-way; TERM/INT restore.
trap '' HUP
trap 'fail "interrupted"' INT TERM
# 5. install, moving aside the other wired configs (an en* DHCP match would
# put a DHCP client on the imaging NIC too)
other_files | while IFS= read -r f; do
while IFS= read -r f; do
[ -n "$f" ] || continue
log "moving aside $f (copy in $BACKUP)"
rm -f "$f"
done
done < <(other_files)
install -m 600 "$STAGED" "$TARGET"
if ! netplan generate; then
log "ERROR: netplan generate failed"
restore
exit 1
fi
netplan generate || fail "netplan generate failed"
# 5. stable names now, so dnsmasq/UFW/Samba (which use them) work before
# 6. stable names now, so dnsmasq/UFW/Samba (which use them) work before
# the next reboot. Does not depend on the netplan version renaming.
rename() {
local mac=$1 want=$2 cur
cur=$(iface_of "$mac")
if [ -z "$cur" ]; then
log "WARNING: no NIC with MAC $mac"
return 0
fi
[ "$cur" = "$want" ] && return 0
log "renaming $cur ($mac) to $want"
if ! { ip link set dev "$cur" down &&
ip link set dev "$cur" name "$want" &&
ip link set dev "$want" up; }; then
log "WARNING: rename $cur -> $want failed; it applies at the next reboot"
ip link set dev "$cur" up 2>/dev/null
fi
return 0
}
rename "$PXE_MAC" "$PXE_NAME"
rename "$CORP_MAC" "$CORP_NAME"
set_names "$PXE_MAC" "$PXE_NAME" "$CORP_MAC" "$CORP_NAME" || fail "NIC rename failed"
if ! netplan apply; then
log "ERROR: netplan apply failed"
restore
exit 1
# 7. apply
netplan apply || fail "netplan apply failed"
# 8. check
names_ok || fail "$PXE_NAME / $CORP_NAME are not on MACs $PXE_MAC / $CORP_MAC after apply"
ok=0
for _ in $(seq 1 "$PXE_WAIT"); do
if has_v4 "$PXE_NAME"; then ok=1; break; fi
sleep 1
done
[ "$ok" -eq 1 ] || fail "$PXE_NAME has no IPv4 address after ${PXE_WAIT}s"
if [ "$CORP_HAD_V4" -eq 1 ]; then
ok=0
for _ in $(seq 1 "$CORP_WAIT"); do
if has_v4 "$CORP_NAME"; then ok=1; break; fi
sleep 1
done
[ "$ok" -eq 1 ] || fail "$CORP_NAME had IPv4 before and has none after ${CORP_WAIT}s"
elif ! has_v4 "$CORP_NAME"; then
log "WARNING: $CORP_NAME has no IPv4 address (it had none before either: corp cable unplugged or no DHCP)"
fi
while IFS= read -r peer; do
[ -n "$peer" ] || continue
dev=$(route_dev "$peer")
if [ "$dev" = "$PXE_NAME" ] && ! in_subnet "$peer" "${PXE_SUBNET:-}"; then
fail "the SSH client $peer now routes through $PXE_NAME"
fi
if [ -z "$dev" ]; then
fail "no route to the SSH client $peer after apply"
fi
done < <(ssh_peers)
# 6. check
for _ in $(seq 1 20); do
ip -4 -br addr show dev "$PXE_NAME" 2>/dev/null | grep -q ' [0-9]' && break
sleep 1
done
ip -br addr show dev "$PXE_NAME" 2>/dev/null ||
log "WARNING: $PXE_NAME not present yet (reboot to apply the .link names)"
for _ in $(seq 1 30); do
ip -4 -br addr show dev "$CORP_NAME" 2>/dev/null | grep -q ' [0-9]' && break
sleep 1
done
ip -4 -br addr show dev "$CORP_NAME" 2>/dev/null | grep -q ' [0-9]' ||
log "WARNING: $CORP_NAME has no IPv4 address yet (corp cable unplugged or DHCP slow)"
touch "$BACKUP.committed"
cancel_revert
trap - INT TERM
ip -br addr show dev "$PXE_NAME" 2>/dev/null
ip -br addr show dev "$CORP_NAME" 2>/dev/null
echo CHANGED
exit 0

View File

@@ -30,6 +30,7 @@
- ansible
- wimtools
- conntrack
- networkd-dispatcher # carrier hook (pxe-nic-carrier)
- sbsigntool # sbverify: iPXE update signature checks
- p7zip-full # 7z: Dell driver packs, catalog cabs, Blancco ISO
- zip # driver zips PESetup unpacks
@@ -62,6 +63,9 @@
ipxe_shim_sha256: "5eecca2780bd49c900565e124516a1bd666ec5e012825f34991b6ba1ef2fa6cf" # ipxe/shim ipxe-16.1 ipxe-shimx64.efi
ipxe_snponly_sha256: "b1e67c3e4a1e8708ddfd0079ad4505e3a02245acb55ee9a95437ab3c507be82a" # x86_64-sb/snponly.efi
ipxe_bios_sha256: "0fb21c695c82fd70d4f89732ddcd285063776e2bbd71de9a2c39e9cfe4b180b8" # x86_64/ipxe.pxe
# Release the pins above come from. A re-run does not replace files the
# webapp's Updates page installed from a NEWER release (installed.json).
ipxe_release_tag: "v2.0.0"
# Zscaler Root CA: GE's Zscaler re-signs GitHub TLS, so the iPXE update
# check fails without it. Repo copy first, else the copy on the image share.
zscaler_ca_src: "{{ playbook_dir }}/certs/zscaler-root-ca.crt"
@@ -126,6 +130,37 @@
ufw_corp_rules:
- { port: "22", proto: tcp }
- { port: "9009", proto: tcp }
# Who may reach SSH and the webapp (9009, no login, runs as root) on the
# GE corporate NIC. Empty (the default) = all of GE, as before, with a
# loud warning every run. Set to the admin subnets/hosts, e.g.
# ["10.134.48.0/24", "3.12.34.56/32"]: UFW then allows 22 and 9009 on
# the corp NIC from these only. The play refuses to restrict if your own
# SSH session is not inside one of them. See docs/SITE-CUSTOMIZATION.md.
corp_admin_cidrs: []
# false: sshd refuses password logins except from the imaging subnet
# and localhost (keys only from the GE side). Default true keeps the
# shared 'pxe' password working from GE, so nobody is locked out; the
# play refuses false while the pxe user has no authorized_keys.
corp_ssh_password_auth: true
# Re-run safety: when imaging is in progress (SMB sessions open on 445
# or DHCP leases granted in the last pxe_busy_window_min minutes) a
# re-run does not bounce the NICs or restart dnsmasq/Apache/Samba.
# A needed network change then stops the play with a message;
# -e allow_disruptive=true overrides.
allow_disruptive: false
pxe_busy_window_min: 30
# networkd-dispatcher states the carrier hook (pxe-nic-carrier) runs in.
carrier_hook_states:
- carrier.d
- degraded.d
- degraded-carrier.d
- enslaved.d
- routable.d
- no-carrier.d
- off.d
# The admin's SSH client (empty when not run over SSH, or when sudo
# dropped SSH_CONNECTION; the checks also read sshd's live peers).
pxe_ssh_client: "{{ (lookup('env', 'SSH_CONNECTION') | default('', true)).split(' ')[0] }}"
# --- Blancco UEFI / Secure Boot path ---
# Blancco's UEFI entries boot a Canonical-signed Ubuntu HWE kernel
# (through Ubuntu's shim when Secure Boot is on), then switch_root into
@@ -136,6 +171,13 @@
# major+minor ("7.0.0-34-generic" -> "70"), as GetPxeScript.aspx expects:
# vmlinuz-ubuntu-70, kexec-initrd-70.img; modules are
# kmod-<blancco_kver>.tar.gz (blancco-init.sh fetches kmod-$(uname -r)).
# The pin lives in three places; this var is the source:
# 1. here (blancco_kver)
# 2. scripts/download-packages.sh reads its BLANCCO_KVER default from
# this line (packages linux-image/modules-<kver>)
# 3. playbook/ipxe/GetPxeScript.aspx names the files by suffix
# (vmlinuz-ubuntu-70, kexec-initrd-70.img); the play fails if that
# suffix is not blancco_ksuffix.
blancco_kver: "7.0.0-34-generic"
blancco_ksuffix: "{{ blancco_kver.split('.')[0] }}{{ blancco_kver.split('.')[1] }}"
image_types:
@@ -208,6 +250,145 @@
Fix playbook/site-vars.yml and re-run.
quiet: yes
# --- Re-run safety (nothing below changes the box until these pass) ---
- name: "Re-run safety: is imaging in progress? (SMB sessions, recent DHCP leases)"
args:
executable: /bin/bash
shell: |
smb=$(ss -Htn state established '( sport = :445 )' 2>/dev/null | wc -l)
recent=0
L=/var/lib/misc/dnsmasq.leases
lease="{{ pxe_dhcp_lease }}"
case "$lease" in
*[0-9]d) secs=$(( ${lease%d} * 86400 )) ;;
*[0-9]h) secs=$(( ${lease%h} * 3600 )) ;;
*[0-9]m) secs=$(( ${lease%m} * 60 )) ;;
*[0-9]s) secs=${lease%s} ;;
*[0-9]) secs=$lease ;;
*) secs=0 ;;
esac
if [ -f "$L" ] && [ "$secs" -gt 0 ]; then
# Field 1 is the expiry; granted = expiry - lease time.
recent=$(awk -v now="$(date +%s)" -v lease="$secs" -v win="{{ (pxe_busy_window_min | int) * 60 }}" \
'$1 > 0 && ($1 - lease) > (now - win) {n++} END {print n + 0}' "$L")
fi
echo "$smb $recent"
register: pxe_busy
changed_when: false
- name: "Re-run safety: decide"
set_fact:
pxe_busy_smb: "{{ pxe_busy.stdout.split()[0] | int }}"
pxe_busy_leases: "{{ pxe_busy.stdout.split()[1] | int }}"
pxe_hold_disruptive: "{{ ((pxe_busy.stdout.split()[0] | int) > 0 or (pxe_busy.stdout.split()[1] | int) > 0) and not (allow_disruptive | bool) }}"
- name: "Re-run safety: imaging in progress, disruptive steps are held"
debug:
msg: >-
{{ pxe_busy_smb }} SMB session(s) open and {{ pxe_busy_leases }}
DHCP lease(s) granted in the last {{ pxe_busy_window_min }} min:
imaging looks active. This run will NOT bounce the NICs or restart
dnsmasq/Apache/Samba (a needed network change stops the play).
Re-run when idle, or with -e allow_disruptive=true.
when: pxe_hold_disruptive | bool
- name: "Corp NIC: corp_admin_cidrs must be valid and include your own SSH session"
# Restricting SSH on the GE NIC to subnets that do not contain the
# admin running this play would lock them out on the next login.
when: corp_admin_cidrs | length > 0
environment:
PXE_SSH_CLIENT: "{{ pxe_ssh_client }}"
CIDRS: "{{ corp_admin_cidrs | join(' ') }}"
PXE_SUBNET: "{{ pxe_subnet }}"
args:
executable: /bin/bash
shell: |
peers=$( { echo "${PXE_SSH_CLIENT:-}"; ss -Htn state established '( sport = :22 )' 2>/dev/null | awk '{print $NF}'; } )
python3 - "$peers" <<'PY'
import ipaddress, os, re, sys
try:
nets = [ipaddress.ip_network(c, strict=False) for c in os.environ["CIDRS"].split()]
except ValueError as ex:
sys.exit(f"REFUSED: corp_admin_cidrs has an invalid entry: {ex}")
local = [ipaddress.ip_network(os.environ["PXE_SUBNET"], strict=False),
ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128")]
bad = []
def parse(raw):
for cand in (raw, re.sub(r":\d+$", "", raw)):
cand = cand.strip("[]")
cand = cand[7:] if cand.startswith("::ffff:") else cand
try:
return ipaddress.ip_address(cand)
except ValueError:
pass
return None
for raw in sys.argv[1].split():
ip = parse(raw)
if ip is None:
continue
if any(ip in n for n in local) or any(ip in n for n in nets):
continue
bad.append(str(ip))
if bad:
sys.exit("REFUSED: SSH client(s) %s are not inside corp_admin_cidrs %s; restricting "
"would lock them out. Add their subnet, or run from inside one. Nothing changed."
% (", ".join(sorted(set(bad))), os.environ["CIDRS"]))
print("corp_admin_cidrs cover every current SSH client")
PY
changed_when: false
- name: "SSH: keys required before password logins are turned off on the GE side"
when: not (corp_ssh_password_auth | bool)
shell: |
home=$(getent passwd pxe | cut -d: -f6)
if [ -n "$home" ] && [ -s "$home/.ssh/authorized_keys" ] &&
grep -qE '^(ssh-|ecdsa-|sk-)' "$home/.ssh/authorized_keys"; then
echo "pxe has $(grep -cE '^(ssh-|ecdsa-|sk-)' "$home/.ssh/authorized_keys") key(s)"
else
echo "REFUSED: corp_ssh_password_auth is false but pxe has no ~/.ssh/authorized_keys;" >&2
echo "the GE side would be locked out. Add a key (ssh-copy-id pxe@<corp ip>) first." >&2
exit 1
fi
changed_when: false
- name: "NICs: render the netplan config for the pre-flight check (in /run, not installed)"
template:
src: templates/netplan-pxe.yaml.j2
dest: /run/pxe-server-netplan.check.yaml
mode: '0600'
changed_when: false
- name: "NICs: pre-flight (refuses swapped MACs / mis-cabled NICs before anything changes)"
# Refuses when the NIC pxe_nic_mac names holds the default route, a
# DHCP lease or the route to an SSH client: it would become static
# with no gateway and cut the box off. Runs from the USB copy, so
# nothing on the box has been touched yet.
command: >-
bash {{ usb_mount }}/pxe-server-helpers/pxe-netplan-apply.sh --preflight
/run/pxe-server-netplan.check.yaml
{{ pxe_nic_name }} {{ pxe_nic_mac | lower }}
{{ corp_nic_name }} {{ corp_nic_mac | lower }}
environment:
PXE_SUBNET: "{{ pxe_subnet }}"
PXE_SSH_CLIENT: "{{ pxe_ssh_client }}"
register: netplan_preflight
changed_when: false
- name: "NICs: pre-flight result"
debug:
msg: "{{ netplan_preflight.stdout_lines }}"
- name: "NICs: a network change is needed but imaging is in progress"
fail:
msg: >-
The netplan config / NIC names must change, which bounces the NICs,
but imaging is active ({{ pxe_busy_smb }} SMB sessions,
{{ pxe_busy_leases }} recent leases). Nothing was changed. Re-run
when idle, or with -e allow_disruptive=true.
when:
- pxe_hold_disruptive | bool
- netplan_preflight.stdout_lines | last | default('') == 'NEEDED'
- name: "NICs: stop cloud-init from rewriting the network config"
copy:
dest: /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
@@ -234,6 +415,9 @@
/usr/local/sbin/pxe-netplan-apply.sh /etc/pxe-server-netplan.yaml
{{ pxe_nic_name }} {{ pxe_nic_mac | lower }}
{{ corp_nic_name }} {{ corp_nic_mac | lower }}
environment:
PXE_SUBNET: "{{ pxe_subnet }}"
PXE_SSH_CLIENT: "{{ pxe_ssh_client }}"
register: netplan_apply
changed_when: netplan_apply.stdout_lines | last | default('') == 'CHANGED'
@@ -252,9 +436,12 @@
net.ipv6.conf.all.forwarding = 0
net.ipv6.conf.default.forwarding = 0
# Apache listens on {{ pxe_server_ip }} only (ports 80/4433). Let it
# bind before networkd has put the address on {{ pxe_nic_name }} (no
# carrier at boot, or apache starting first), or apache2 fails to
# start and takes the webapp on 9009 down with it.
# bind before networkd has put the address on {{ pxe_nic_name }}, or
# apache2 fails to start and takes the webapp on 9009 down with it.
# The netplan config sets ignore-carrier on {{ pxe_nic_name }}, so a
# missing link no longer keeps the address off; this stays for the
# boot-order race (apache2 is ordered only after network.target,
# not after networkd has configured the address).
net.ipv4.ip_nonlocal_bind = 1
register: pxe_net_sysctl
@@ -416,6 +603,62 @@
- pxe-nic-check.service
- pxe-nic-check.timer
# Carrier hook: networkd-dispatcher runs pxe-nic-carrier on every link
# state change; it starts pxe-nic-carrier.service, which stops dnsmasq
# when the imaging link drops and starts it again (through the
# ExecStartPre guard) when it comes back. pxe-nic-check, its service
# and pxe-dhcp-hook.sh are (re)copied by the tasks above/below.
- name: "Carrier hook: networkd-dispatcher installed (offline repo)"
apt:
name: networkd-dispatcher
state: present
- name: "Carrier hook: deploy pxe-nic-carrier"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/pxe-nic-carrier"
dest: /usr/local/sbin/pxe-nic-carrier
owner: root
group: root
mode: '0755'
- name: "Carrier hook: install pxe-nic-carrier.service (started by the hook, not enabled)"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/pxe-nic-carrier.service"
dest: /etc/systemd/system/pxe-nic-carrier.service
mode: '0644'
register: carrier_unit
- name: "Carrier hook: systemd daemon-reload"
systemd:
daemon_reload: yes
when: carrier_unit is changed
- name: "Carrier hook: networkd-dispatcher state directories"
file:
path: "/etc/networkd-dispatcher/{{ item }}"
state: directory
owner: root
group: root
mode: '0755'
loop: "{{ carrier_hook_states }}"
notify: restart networkd-dispatcher
- name: "Carrier hook: link the hook into each state"
file:
src: /usr/local/sbin/pxe-nic-carrier
dest: "/etc/networkd-dispatcher/{{ item }}/50-pxe-nic-carrier"
state: link
owner: root
group: root
loop: "{{ carrier_hook_states }}"
notify: restart networkd-dispatcher
- name: "Carrier hook: networkd-dispatcher enabled and running"
systemd:
name: networkd-dispatcher
enabled: yes
state: started
- name: "NIC guard: enable watch timer"
systemd:
name: pxe-nic-check.timer
@@ -458,6 +701,47 @@
- blancco
- memtest
- name: "Menu: the Blancco file names in the menu must match blancco_kver"
# GetPxeScript.aspx names the UEFI Blancco files by suffix
# (vmlinuz-ubuntu-70, kexec-initrd-70.img); the play builds them from
# blancco_kver. A mismatch boots a file that is never built.
shell: |
M="{{ usb_mount }}/ipxe/GetPxeScript.aspx"
found=$(grep -oE '(vmlinuz-ubuntu|kexec-initrd)-[0-9]+' "$M" | sed 's/.*-//' | sort -u | tr '\n' ' ')
echo "menu suffixes: ${found:-none}"
for x in $found; do
if [ "$x" != "{{ blancco_ksuffix }}" ]; then
echo "REFUSED: $M references Blancco files with suffix $x but blancco_kver" >&2
echo "{{ blancco_kver }} builds suffix {{ blancco_ksuffix }}. Change them together" >&2
echo "(playbook var blancco_kver, scripts/download-packages.sh, the menu)." >&2
exit 1
fi
done
register: menu_ksuffix
changed_when: false
- name: "Menu: does the live menu carry a staged Blancco test entry?"
# The Blancco Releases page (webapp/services/blancco_update.py) adds a
# test entry between these markers; overwriting the menu would drop it
# while the page still shows it as added.
shell: |
F="{{ web_root }}/Altiris/iPXE/GetPxeScript.aspx"
[ -f "$F" ] || exit 0
if grep -qF '# BEGIN blancco-staged test entry' "$F" || grep -qE '^item[[:space:]]+blancco_staged[[:space:]]' "$F"; then
echo STAGED-TEST
fi
register: menu_test_entry
changed_when: false
- name: "Menu: NOT replacing the live menu (Blancco test entry present)"
debug:
msg: >-
WARNING: {{ web_root }}/Altiris/iPXE/GetPxeScript.aspx carries the
staged Blancco test entry (Updates > Blancco). The menu was NOT
updated from {{ usb_mount }}/ipxe/GetPxeScript.aspx. Remove the
test entry on that page (or promote the release), then re-run.
when: "'STAGED-TEST' in menu_test_entry.stdout"
- name: "Install GetPxeScript.aspx (iPXE boot menu)"
# The live menu, tracked as a file (playbook/ipxe/GetPxeScript.aspx).
# WinPE skips the Secure Boot warning when iPXE reads Secure Boot as on
@@ -470,6 +754,7 @@
dest: "{{ web_root }}/Altiris/iPXE/GetPxeScript.aspx"
mode: '0644'
backup: yes
when: "'STAGED-TEST' not in menu_test_entry.stdout"
# Boot files (80) and the iPXE menu (4433) are served on the imaging IP
# only, never on the GE NIC. 9009 (webapp) stays on all addresses: admins
@@ -700,6 +985,20 @@
group: pxe
mode: '0775'
# Imaging stage reporter: the bays run it from the shared Applications
# tree and post their stage to the webapp's /imaging dashboard.
- name: "Imaging status: create _shared/Applications/extra/pxe-status"
file:
path: "{{ samba_share }}/_shared/Applications/extra/pxe-status"
state: directory
mode: '0755'
- name: "Imaging status: deploy Send-ImagingStage.ps1"
copy:
src: "{{ usb_mount }}/imaging-status/Send-ImagingStage.ps1"
dest: "{{ samba_share }}/_shared/Applications/extra/pxe-status/Send-ImagingStage.ps1"
mode: '0644'
- name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/"
# Path matches what startnet.cmd reads at WinPE boot:
# net use B: \\172.16.9.1\winpeapps\_shared
@@ -916,6 +1215,27 @@
force: no
loop: "{{ image_types }}"
- name: "Unattend: lint the answer files before they replace the live ones"
# A bad answer file stops every bay imaged with it at "Windows could
# not parse or process unattend answer file". The copies below use
# force: yes, so this gate is the only check. scripts/lint-unattend.py
# rides on the USB as scripts/ next to playbook/ (build-usb.sh), and
# is in the repo at the same relative path.
args:
executable: /bin/bash
shell: |
for L in "{{ usb_mount }}/../scripts/lint-unattend.py" "{{ playbook_dir }}/../scripts/lint-unattend.py"; do
if [ -f "$L" ]; then
exec python3 "$L" --local \
"{{ usb_mount }}/FlatUnattendW10-shopfloor.xml" \
"{{ usb_mount }}/FlatUnattendW10.xml" \
"{{ usb_mount }}/FlatUnattendW10-engineer.xml"
fi
done
echo "ERROR: scripts/lint-unattend.py not found next to {{ usb_mount }} or {{ playbook_dir }}" >&2
exit 1
changed_when: false
- name: "Deploy shopfloor unattend.xml template"
copy:
src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml"
@@ -1006,12 +1326,49 @@
check "$W/ipxeboot/x86_64-sb/snponly.efi" "{{ ipxe_snponly_sha256 }}"
check "$W/ipxeboot/x86_64/ipxe.pxe" "{{ ipxe_bios_sha256 }}"
put() { install -m 0644 -o root -g root "$1" "$T/.$2.new" && mv -f "$T/.$2.new" "$T/$2"; }
put "$U/ipxe-shimx64.efi" ipxe-shim.efi
put "$W/ipxeboot/x86_64-sb/snponly.efi" ipxe.efi
put "$W/ipxeboot/x86_64/ipxe.pxe" ipxe.pxe
# Never downgrade what the webapp's Updates page installed from a
# newer release than ipxe_release_tag: a file whose live sha256 still
# matches its installed.json record with a newer tag is kept. The
# shim and ipxe.efi go together (the shim must trust ipxe.efi's CA).
AHEAD=$(python3 - /var/lib/pxe-webapp/updates/ipxe/installed.json "$T" "{{ ipxe_release_tag }}" <<'PY'
import hashlib, json, os, re, sys
log, tftp, pin = sys.argv[1:4]
def ver(t):
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", t or "")
return tuple(int(x or 0) for x in m.groups()) if m else None
try:
data = json.load(open(log))
except (OSError, ValueError):
data = {}
data = data if isinstance(data, dict) else {}
for name in ("ipxe-shim.efi", "ipxe.efi", "ipxe.pxe"):
rec, path = data.get(name), os.path.join(tftp, name)
if not isinstance(rec, dict) or not os.path.isfile(path):
continue
if hashlib.sha256(open(path, "rb").read()).hexdigest() != rec.get("sha256"):
continue
a, b = ver(rec.get("tag")), ver(pin)
if a and b and a > b:
print(f"{name}={rec.get('tag')}")
PY
)
done=""
if grep -qE '^(ipxe-shim\.efi|ipxe\.efi)=' <<<"$AHEAD"; then
echo "KEPT ipxe-shim.efi + ipxe.efi: the Updates page installed a newer release ($(grep -E '^ipxe(-shim)?\.efi=' <<<"$AHEAD" | tr '\n' ' ')) than the playbook's {{ ipxe_release_tag }}"
else
put "$U/ipxe-shimx64.efi" ipxe-shim.efi
put "$W/ipxeboot/x86_64-sb/snponly.efi" ipxe.efi
done="ipxe-shim.efi, ipxe.efi (snponly)"
fi
if grep -qE '^ipxe\.pxe=' <<<"$AHEAD"; then
echo "KEPT ipxe.pxe: the Updates page installed $(grep -E '^ipxe\.pxe=' <<<"$AHEAD"), newer than {{ ipxe_release_tag }}"
else
put "$W/ipxeboot/x86_64/ipxe.pxe" ipxe.pxe
done="${done:+$done, }ipxe.pxe"
fi
# Rollback boot file: the old Broadcom-signed build (boot-files/ipxe.efi).
if [ -f "$U/ipxe.efi" ]; then put "$U/ipxe.efi" ipxe-bcm.efi; fi
echo "installed ipxe-shim.efi, ipxe.efi (snponly), ipxe.pxe$( [ -f "$U/ipxe.efi" ] && echo ', ipxe-bcm.efi')"
if [ -f "$U/ipxe.efi" ]; then put "$U/ipxe.efi" ipxe-bcm.efi; done="${done:+$done, }ipxe-bcm.efi"; fi
echo "installed: ${done:-nothing}"
args:
executable: /bin/bash
register: ipxe_chain
@@ -1049,17 +1406,42 @@
set_fact:
blancco_kernel_ok: "{{ blancco_kernel_files.results | map(attribute='stat.exists') | min }}"
- name: "Blancco: warn when the Blancco kernel is missing"
- name: "Blancco: are the UEFI Blancco files already served?"
stat:
path: "{{ web_root }}/blancco/{{ item }}"
loop:
- "vmlinuz-ubuntu-{{ blancco_ksuffix }}"
- "kexec-initrd-{{ blancco_ksuffix }}.img"
register: blancco_served
when: not blancco_kernel_ok
# The menu boots these files (checked above: same suffix). Without the
# kernel they cannot be built; with no earlier build in place, every
# UEFI Blancco boot would fail, so that is fatal, not a warning.
- name: "Blancco: the kernel is missing and the menu's files were never built"
fail:
msg: >-
/boot/vmlinuz-{{ blancco_kver }} or /lib/modules/{{ blancco_kver }}
is missing, and {{ web_root }}/blancco has no
vmlinuz-ubuntu-{{ blancco_ksuffix }} /
kexec-initrd-{{ blancco_ksuffix }}.img from an earlier run, but the
iPXE menu boots them. Install linux-image-{{ blancco_kver }} +
linux-modules-{{ blancco_kver }} (offline packages,
scripts/download-packages.sh) or set blancco_kver to an installed
version (ls /lib/modules) AND change the menu to match, then re-run.
when:
- not blancco_kernel_ok
- not (blancco_served.results | map(attribute='stat.exists') | min)
- name: "Blancco: warn when the Blancco kernel is missing (earlier build kept)"
debug:
msg: >-
WARNING: /boot/vmlinuz-{{ blancco_kver }} or
/lib/modules/{{ blancco_kver }} is missing, so the Blancco UEFI
files (vmlinuz-ubuntu-{{ blancco_ksuffix }},
kexec-initrd-{{ blancco_ksuffix }}.img,
kmod-{{ blancco_kver }}.tar.gz) are NOT built. Install
files are NOT rebuilt; the copies from an earlier run stay in
place (they may not match blancco_kver). Install
linux-image-{{ blancco_kver }} + linux-modules-{{ blancco_kver }}
(scripts/download-packages.sh, BLANCCO_KVER) or set blancco_kver
to an installed version (ls /lib/modules), then re-run.
and re-run.
when: not blancco_kernel_ok
- name: "Build Blancco PXE initramfs (kexec-initrd-{{ blancco_ksuffix }}.img)"
@@ -1079,8 +1461,9 @@
creates: "{{ web_root }}/blancco/.kexec-initrd-{{ blancco_ksuffix }}.{{ blancco_kver }}.built"
when: blancco_kernel_ok
shell: |
set -e
set -eo pipefail
WORK=$(mktemp -d)
trap 'rm -rf "$WORK" "$OUT.tmp"' EXIT
KVER="{{ blancco_kver }}"
OUT="{{ web_root }}/blancco/kexec-initrd-{{ blancco_ksuffix }}.img"
mkdir -p "$WORK"/{bin,lib/modules/$KVER/kernel,lib64,sbin,usr/share/udhcpc,etc,run,proc,sys,dev}
@@ -1172,11 +1555,9 @@
find . | cpio -o -H newc --quiet | gzip -1 > "$OUT.tmp"
mv -f "$OUT.tmp" "$OUT"
cd /
rm -rf "$WORK"
rm -f "{{ web_root }}"/blancco/.kexec-initrd-{{ blancco_ksuffix }}.*.built
touch "{{ web_root }}/blancco/.kexec-initrd-{{ blancco_ksuffix }}.{{ blancco_kver }}.built"
echo "Built $(basename "$OUT") for $KVER: $(stat -c %s "$OUT") bytes"
ignore_errors: yes
- name: "Copy Ubuntu kernel for Blancco PXE boot (vmlinuz-ubuntu-{{ blancco_ksuffix }})"
copy:
@@ -1312,15 +1693,25 @@
when: usb_images_dir.stat.exists
- name: "Restart and enable services"
# Held while imaging is active (see "Re-run safety"): started +
# enabled only, so a WinPE client mid-copy keeps its SMB session.
systemd:
name: "{{ item }}"
state: restarted
state: "{{ 'started' if pxe_hold_disruptive | bool else 'restarted' }}"
enabled: yes
loop:
- dnsmasq
- apache2
- smbd
- name: "Services NOT restarted (imaging in progress)"
debug:
msg: >-
dnsmasq, apache2 and smbd were not restarted because imaging is
active; config changes from this run apply at their next restart.
Re-run when idle (or -e allow_disruptive=true) to restart them.
when: pxe_hold_disruptive | bool
# Firewall per interface. Imaging NIC: everything imaging needs. GE
# corporate NIC: SSH and the webapp only. New rules go in first, then the
# old any-interface rules are removed, so SSH is never closed mid-run.
@@ -1335,7 +1726,7 @@
loop_control:
label: "{{ pxe_nic_name }} {{ item.port }}/{{ item.proto }}"
- name: "UFW: allow SSH and the webapp on the GE corporate NIC"
- name: "UFW: allow SSH and the webapp on the GE corporate NIC (from all of GE)"
ufw:
rule: allow
direction: in
@@ -1345,6 +1736,70 @@
loop: "{{ ufw_corp_rules }}"
loop_control:
label: "{{ corp_nic_name }} {{ item.port }}/{{ item.proto }}"
when: corp_admin_cidrs | length == 0
- name: "UFW: WARNING, SSH and the root webapp are open to all of GE"
debug:
msg:
- "!!! WARNING: corp_admin_cidrs is empty. SSH (password auth, shared"
- "!!! 'pxe' password) and the webapp on 9009 (no login, runs as root)"
- "!!! accept connections from ANY host on the GE corporate network via"
- "!!! {{ corp_nic_name }}. Set corp_admin_cidrs in playbook/site-vars.yml"
- "!!! to the admin subnets (docs/SITE-CUSTOMIZATION.md) and re-run."
when: corp_admin_cidrs | length == 0
- name: "UFW: allow SSH and the webapp on the GE corporate NIC from corp_admin_cidrs only"
ufw:
rule: allow
direction: in
interface: "{{ corp_nic_name }}"
from_ip: "{{ item.1 }}"
port: "{{ item.0.port }}"
proto: "{{ item.0.proto }}"
loop: "{{ ufw_corp_rules | product(corp_admin_cidrs) | list }}"
loop_control:
label: "{{ corp_nic_name }} from {{ item.1 }} {{ item.0.port }}/{{ item.0.proto }}"
when: corp_admin_cidrs | length > 0
- name: "UFW: drop the any-source rules on the GE corporate NIC (restricted now)"
ufw:
rule: allow
direction: in
interface: "{{ corp_nic_name }}"
port: "{{ item.port }}"
proto: "{{ item.proto }}"
delete: yes
loop: "{{ ufw_corp_rules }}"
loop_control:
label: "{{ corp_nic_name }} any {{ item.port }}/{{ item.proto }}"
when: corp_admin_cidrs | length > 0
# Only drop the old any-interface rules (allow 22 among them) once both
# named NICs exist on the right MACs and the per-interface SSH rules are
# in UFW. Otherwise those rules would name interfaces that are not
# there and removing the old ones would close SSH.
- name: "UFW: verify both named NICs and their per-interface rules"
args:
executable: /bin/bash
shell: |
set -u
bad=0
for pair in "{{ pxe_nic_name }}={{ pxe_nic_mac | lower }}" "{{ corp_nic_name }}={{ corp_nic_mac | lower }}"; do
n=${pair%%=*}; m=${pair#*=}
if [ "$(cat "/sys/class/net/$n/address" 2>/dev/null)" != "$m" ]; then
echo "ERROR: interface $n with MAC $m is not present" >&2; bad=1
fi
done
added=$(ufw show added 2>/dev/null)
for n in "{{ pxe_nic_name }}" "{{ corp_nic_name }}"; do
if ! grep -E "allow in on $n( |$)" <<<"$added" | grep -qE 'port 22 proto tcp'; then
echo "ERROR: no UFW 'allow in on $n ... port 22 proto tcp' rule" >&2; bad=1
fi
done
[ "$bad" -eq 0 ] || { echo "the old any-interface rules are kept" >&2; exit 1; }
echo "both NICs present, per-interface SSH rules in place"
register: ufw_verify
changed_when: false
- name: "UFW: remove the old any-interface rules"
ufw:
@@ -1377,6 +1832,50 @@
state: enabled
policy: deny
# --- SSH on the GE NIC: password logins (corp_ssh_password_auth) ---
# The drop-in sorts before 50-cloud-init.conf (autoinstall allow-pw), and
# sshd keeps the first value it reads. "Match all" ends the block so the
# rest of sshd_config is not scoped to it.
- name: "SSH: password logins only from the imaging LAN (corp_ssh_password_auth: false)"
copy:
dest: /etc/ssh/sshd_config.d/10-pxe-corp-auth.conf
mode: '0644'
content: |
# Managed by pxe_server_setup.yml (corp_ssh_password_auth: false).
# Keys only from the GE corporate side; the shared password still
# works from the imaging LAN and localhost.
PasswordAuthentication no
KbdInteractiveAuthentication no
Match Address {{ pxe_subnet }},127.0.0.0/8,::1
PasswordAuthentication yes
Match all
register: sshd_dropin_add
when: not (corp_ssh_password_auth | bool)
- name: "SSH: password logins allowed everywhere (corp_ssh_password_auth: true)"
file:
path: /etc/ssh/sshd_config.d/10-pxe-corp-auth.conf
state: absent
register: sshd_dropin_del
when: corp_ssh_password_auth | bool
- name: "SSH: check the sshd config, reload it"
when: (sshd_dropin_add is changed) or (sshd_dropin_del is changed)
block:
- name: "SSH: sshd -t"
command: /usr/sbin/sshd -t
changed_when: false
- name: "SSH: reload sshd"
command: systemctl try-reload-or-restart ssh.service
rescue:
- name: "SSH: bad config, remove the drop-in again"
file:
path: /etc/ssh/sshd_config.d/10-pxe-corp-auth.conf
state: absent
- name: "SSH: fail"
fail:
msg: "sshd -t rejected the config with the corp auth drop-in; it was removed and sshd not reloaded."
- name: "Schedule dnsmasq restart 15s after reboot"
copy:
dest: /etc/cron.d/dnsmasq-restart
@@ -1447,19 +1946,23 @@
- name: "Copy webapp from USB"
# The whole tree (app, config, services, views, templates, static), every
# run, so a re-run with a newer USB updates the app. Was: cp with
# creates: app.py, which never updated an existing install.
# creates: app.py, which never updated an existing install. -i lists
# what changed, so the service restarts only then.
shell: |
set -e
set -eo pipefail
test -f "{{ usb_root }}/webapp/app.py"
rsync -a --delete --exclude __pycache__ "{{ usb_root }}/webapp/" /opt/pxe-webapp/
rsync -ai --delete --exclude __pycache__ "{{ usb_root }}/webapp/" /opt/pxe-webapp/
args:
executable: /bin/bash
register: webapp_rsync
changed_when: webapp_rsync.stdout | trim | length > 0
- name: "Install webapp Python dependencies (offline wheels)"
args:
executable: /bin/bash
shell: |
# Find the pip-wheels directory on the CIDATA mount
# Find the pip-wheels directory on the CIDATA mount. No online
# fallback: an unpinned pip install from PyPI is never wanted here.
export WHEEL_DIR=""
for d in "{{ usb_root }}/pip-wheels" "{{ usb_mount }}/pip-wheels"; do
if [ -d "$d" ] && compgen -G "$d/*.whl" > /dev/null; then
@@ -1467,41 +1970,50 @@
break
fi
done
if [ -n "$WHEEL_DIR" ]; then
# Install wheels directly using Python zipfile (bypasses pip entirely)
# This avoids the pip3/distutils incompatibility between Ubuntu 22.04 debs and Python 3.12
export REQS="{{ usb_root }}/webapp/requirements.txt"
python3 << 'PYEOF'
# Installs only the pinned wheels: the sha256 of every wheel must be in
# webapp/requirements.txt, and each pinned package must have exactly one
# wheel. An older installed version of a package is removed first (by its
# RECORD), so an upgrade does not leave the old files beside the new.
if [ -z "$WHEEL_DIR" ]; then
echo "ERROR: no pip-wheels/*.whl under {{ usb_root }} or {{ usb_mount }}." >&2
echo "Build them with scripts/download-packages.sh and rebuild the USB." >&2
exit 1
fi
# Install wheels directly using Python zipfile (bypasses pip entirely)
# This avoids the pip3/distutils incompatibility between Ubuntu 22.04 debs and Python 3.12
export REQS="{{ usb_root }}/webapp/requirements.txt"
python3 << 'PYEOF'
# Installs only the pinned wheels: the sha256 of each wheel must be one of
# the hashes pinned for THAT package in webapp/requirements.txt, and
# each pinned package must have exactly one wheel. A package already
# installed at the pinned version is left alone; an older installed
# version is removed first (by its RECORD), so an upgrade does not
# leave the old files beside the new. Last line CHANGED / UNCHANGED.
import zipfile, sysconfig, glob, os, re, sys, hashlib, csv, shutil
norm = lambda n: re.sub(r'[-_.]+', '_', n).lower()
site = sysconfig.get_path('platlib')
wheel_dir = os.environ['WHEEL_DIR']
reqs = open(os.environ['REQS']).read()
pins, hashes = {}, set()
pins, hashes = {}, {}
for m in re.finditer(r'^([A-Za-z0-9_.-]+)==([^\s\\]+)((?:\s*\\?\s*--hash=sha256:[0-9a-f]{64})+)', reqs, re.M):
name = re.sub(r'[-_.]+', '_', m.group(1)).lower()
name = norm(m.group(1))
pins[name] = m.group(2)
hashes.update(re.findall(r'sha256:([0-9a-f]{64})', m.group(3)))
hashes[name] = set(re.findall(r'sha256:([0-9a-f]{64})', m.group(3)))
wheels = sorted(glob.glob(os.path.join(wheel_dir, '*.whl')))
seen = {}
for whl in wheels:
h = hashlib.sha256(open(whl, 'rb').read()).hexdigest()
name = re.sub(r'[-_.]+', '_', os.path.basename(whl).split('-')[0]).lower()
if h not in hashes or name not in pins:
sys.exit(f'REFUSED: {os.path.basename(whl)} is not a pinned wheel (sha256 {h[:16]}...)')
parts = os.path.basename(whl).split('-')
name = norm(parts[0])
if name not in pins or h not in hashes[name] or parts[1] != pins[name]:
sys.exit(f'REFUSED: {os.path.basename(whl)} is not the pinned wheel of its package (sha256 {h[:16]}...)')
seen.setdefault(name, []).append(whl)
bad = [n for n in pins if len(seen.get(n, [])) != 1]
if bad:
sys.exit(f'REFUSED: need exactly one wheel for each pinned package; check: {", ".join(bad)}')
changed = False
for name, (whl,) in sorted(seen.items()):
for old in glob.glob(os.path.join(site, '*.dist-info')):
stem = os.path.basename(old)[:-len('.dist-info')]
oname = re.sub(r'[-_.]+', '_', stem.rsplit('-', 1)[0]).lower()
if oname != name:
continue
current = [d for d in glob.glob(os.path.join(site, '*.dist-info'))
if norm(os.path.basename(d)[:-len('.dist-info')].rsplit('-', 1)[0]) == name]
if [os.path.basename(d)[:-len('.dist-info')].rsplit('-', 1)[1] for d in current] == [pins[name]]:
continue
for old in current:
rec = os.path.join(old, 'RECORD')
if os.path.isfile(rec):
for row in csv.reader(open(rec)):
@@ -1514,13 +2026,12 @@
print(f' Installing {os.path.basename(whl)}')
with zipfile.ZipFile(whl) as z:
z.extractall(site)
changed = True
print('All pinned wheels installed to ' + site)
print('CHANGED' if changed else 'UNCHANGED')
PYEOF
else
# Fallback: try system pip (works if system has internet and compatible pip)
python3 -m pip install --break-system-packages flask lxml 2>/dev/null ||
pip3 install --break-system-packages flask lxml
fi
register: webapp_wheels
changed_when: webapp_wheels.stdout_lines | last | default('') == 'CHANGED'
- name: "Create systemd service for PXE webapp"
copy:
@@ -1551,6 +2062,7 @@
[Install]
WantedBy=multi-user.target
register: webapp_unit
- name: "Enable and start PXE webapp service"
systemd:
@@ -1558,6 +2070,17 @@
state: started
enabled: yes
daemon_reload: yes
register: webapp_started
- name: "Restart PXE webapp (new code, wheels or unit on this run)"
# A re-run used to leave the old code running until the next reboot.
systemd:
name: pxe-webapp
state: restarted
daemon_reload: yes
when:
- webapp_rsync is changed or webapp_wheels is changed or webapp_unit is changed
- webapp_started is not changed
# Monthly Blancco CMMC evidence pack (ENT-056/158): on the 1st, save last
# month's ZIP to the reports share's evidence/ folder for the tech to copy
@@ -1602,9 +2125,8 @@
dest: /etc/apache2/sites-available/pxe-server.conf
content: |
# 9009 listens on every address: admins use the webapp from the GE
# corporate NIC. TODO: the webapp has NO authentication; anyone on
# the GE network who can reach this box can drive it. Add auth (or
# restrict 9009 on the corp NIC to an admin subnet in UFW).
# corporate NIC. The webapp has NO authentication: restrict 9009
# (and 22) on the corp NIC with corp_admin_cidrs (UFW). TODO: auth.
Listen 9009
<VirtualHost *:80>
DocumentRoot {{ web_root }}
@@ -1657,3 +2179,8 @@
handlers:
- name: "reload sysctl"
command: sysctl --system
- name: "restart networkd-dispatcher"
systemd:
name: networkd-dispatcher
state: restarted

View File

@@ -17,6 +17,12 @@ corp_nic_mac: "" # e.g. "20:88:10:af:0d:e5"
# pxe_nic_name: enx34c8d6b11010
# corp_nic_name: enp0s31f6
# Recommended: who may reach SSH and the webapp (9009) on the corp NIC.
# Empty = all of GE (a warning every run). See docs/SITE-CUSTOMIZATION.md.
# corp_admin_cidrs: ["10.134.48.0/24"]
# Keys only from the GE side (needs ~pxe/.ssh/authorized_keys first):
# corp_ssh_password_auth: false
# Optional: imaging LAN addressing (defaults shown). 172.16.9.1 is also
# hard-coded in the iPXE menu, startnet.cmd, blancco-preferences.xml and the
# unattend files; see docs/SITE-CUSTOMIZATION.md before changing it.

View File

@@ -5,7 +5,11 @@
#
# Two NICs, each matched by MAC and given a stable name:
# pxe-lan imaging LAN, static, no gateway, no DNS. The ONLY NIC that
# serves DHCP/TFTP/HTTP boot/SMB.
# serves DHCP/TFTP/HTTP boot/SMB. ignore-carrier: the address
# is put on (and kept) without link, so a switch that is off
# or a cable pulled at boot does not leave dnsmasq, Samba and
# Apache without {{ pxe_server_ip }} (networkd
# ConfigureWithoutCarrier= + IgnoreCarrierLoss=).
# ge-corp GE corporate network, DHCP client (SSH + webapp 9009, updates).
network:
version: 2
@@ -17,6 +21,8 @@ network:
set-name: {{ pxe_nic_name }}
dhcp4: false
dhcp6: false
ignore-carrier: true
optional: true
addresses:
- {{ pxe_server_ip }}/{{ pxe_prefix }}
ge-corp: