Files
pxe-server/playbook/pxe_server_setup.yml
cproudlock 3a8b7cabc7 Admin password check works with yescrypt; shorter Blancco version warning
chpasswd and passwd write yescrypt ($y$) hashes, which the openssl $6$
comparison could not read: every re-run set the pxe password again, and a
box whose password was 'pxe' in yescrypt passed the pxe_admin_password
requirement. Both Admin tasks now compare with crypt(), which handles every
scheme. The config.img warning names the two Blancco versions instead of
the staging guard's merge instructions.
2026-10-17 12:57:25 -04:00

2687 lines
116 KiB
YAML

---
- name: PXE Server Setup (Ubuntu with dnsmasq)
hosts: localhost
connection: local
become: yes
gather_facts: yes
pre_tasks:
# Per-site values (NIC MACs first of all). Optional file next to this
# playbook; -e still wins over it.
- name: "Look for per-site vars (playbook/site-vars.yml)"
stat:
path: "{{ playbook_dir }}/site-vars.yml"
register: site_vars_file
- name: "Load per-site vars"
include_vars:
file: "{{ playbook_dir }}/site-vars.yml"
when: site_vars_file.stat.exists
- name: "Verify required packages are installed (pre-installed from offline .debs)"
command: dpkg -s {{ item }}
loop:
- dnsmasq
- apache2
- samba
- unzip
- ufw
- cron
- 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
- curl # Updates page downloads
- shim-signed # shimx64.efi.signed: Blancco with Secure Boot on
register: pkg_check
failed_when: false
changed_when: false
- name: "Warn about missing packages"
debug:
msg: "WARNING: {{ item.item }} is not installed! Install offline .debs first."
loop: "{{ pkg_check.results }}"
when: item.rc != 0
vars:
tftp_dir: "/srv/tftp"
web_root: "/var/www/html"
samba_share: "/srv/samba/winpeapps"
usb_mount: "/mnt/usb/playbook" # playbook location on USB
usb_root: "/mnt/usb" # CIDATA partition root
# Secure Boot iPXE chain, fetched into boot-files/ by scripts/fetch-ipxe.sh
# (keep these pins in step with that script). Every UEFI client gets the
# dual-signed shim (Microsoft UEFI CA 2011 + 2023), which loads ipxe.efi
# from the TFTP root: the release's SNPONLY build (firmware network and
# USB drivers; the full ipxe.efi drives USB itself and kills USB
# keyboards at the menu). The old Broadcom-signed 1.21.1 build stays on
# the box as ipxe-bcm.efi, the rollback.
ipxe_tarball_sha256: "01a526d4cc791fc30362259c609d6c506cc64a7bdff51b9a5eb788354e17eee1" # ipxe v2.0.0 ipxeboot.tar.gz
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"
# The copy the playbook puts in every image (Deploy/Applications).
zscaler_ca_share_glob: "{{ samba_share }}/gea-*/Deploy/Applications/ZscalerCommercialCertificate-2048-SHA256.crt"
# --- The two NICs (site standard) ---
# The imaging NIC (isolated imaging LAN, static pxe_server_ip, the ONLY
# NIC that serves DHCP/TFTP/HTTP boot/SMB) is pinned by MAC. Leave
# pxe_nic_mac empty and the first run picks it (pxe-pick-nic --auto):
# the one wired port with a link, accepted only if pxe-nic-check calls
# it isolated, saved in /etc/pxe-server/nic-pick.conf and pinned from
# then on. So during the install plug in ONLY the imaging switch. If it
# cannot tell, it stops and the console says to run sudo pxe-pick-nic
# (pull the imaging cable and plug it back in).
#
# The GE corporate NIC (DHCP client; SSH and the webapp on 9009) is
# optional:
# corp_nic_mac empty (default): every other wired port (en*, eth*) is
# a DHCP client, so GE can be plugged into any of them, then or
# later. dnsmasq never serves them (interface= pins it to the
# imaging NIC) and they ignore our own DHCP offers.
# corp_nic_mac set: that one port is pinned and named corp_nic_name,
# as on the WJ server; other ports stay unconfigured.
pxe_nic_mac: ""
corp_nic_mac: ""
# Stable names, set by netplan (match macaddress + set-name). dnsmasq,
# UFW and Samba refer to these. With corp_nic_mac empty they must not
# look like en*/eth* (the catch-all would match them by name). With both
# MACs pinned a site may keep its existing names (WJ: pxe_nic_name:
# enx34c8d6b11010, corp_nic_name: enp0s31f6).
pxe_nic_name: "pxe0"
corp_nic_name: "corp0"
# Imaging LAN addressing. NOTE: 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); change those together.
pxe_server_ip: "172.16.9.1"
pxe_subnet: "172.16.9.0/24"
pxe_prefix: "{{ pxe_subnet.split('/')[1] }}"
pxe_dhcp_start: "172.16.9.10"
pxe_dhcp_end: "172.16.9.100"
pxe_dhcp_lease: "12h"
# Route metric for the corp NIC's DHCP default route.
corp_route_metric: 100
# NIC guard (pxe-nic-check): dnsmasq must never serve DHCP/PXE on the GE
# corporate network. These are derived from pxe_nic_mac / corp_nic_mac
# (pxe_ge_mac empty: the guard takes the NIC holding a DHCP lease)
# (one source of truth) and written to /etc/pxe-server/nic.conf; do not
# set them separately (the playbook fails if they disagree). A MAC of a
# bridge port resolves to the bridge.
pxe_imaging_mac: "{{ pxe_nic_mac }}"
pxe_ge_mac: "{{ corp_nic_mac }}"
# A foreign DHCP server handing out one of these domains (whole labels:
# site.geaerospace.net matches, notgeaerospace.net does not) = GE network.
pxe_corp_domain_suffixes: "geaerospace.net, ge.com"
# Only if the IMAGING switch is itself managed and sends LLDP/CDP: its
# LLDP system name / CDP device id / chassis MAC, comma separated, so
# it does not count as a corporate switch. 802.1X always counts.
pxe_nic_allowed_switches: ""
# UFW, per interface (see the UFW tasks). Imaging NIC: SSH, DHCP, TFTP,
# HTTP boot, iPXE menu, SMB (+ NetBIOS for older clients), webapp.
ufw_pxe_rules:
- { port: "22", proto: tcp }
- { port: "67", proto: udp }
- { port: "69", proto: udp }
- { port: "80", proto: tcp }
- { port: "4433", proto: tcp }
- { port: "445", proto: tcp }
- { port: "139", proto: tcp }
- { port: "137", proto: udp }
- { port: "138", proto: udp }
- { port: "9009", proto: tcp }
# GE corporate NIC: SSH and the webapp, nothing else.
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.12.34.0/24", "10.12.40.5/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
# the Blancco rootfs. The kernel, its modules and the initramfs are
# built from this version, which must be installed on the box
# (linux-generic-hwe-24.04 from the offline packages; see
# scripts/download-packages.sh BLANCCO_KVER). The file suffix is
# 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] }}"
# Samba account for the [clonezilla] share only (Clonezilla back up /
# restore by machine number). The iPXE menu carries it in Clonezilla's
# ocs_repository URI (smb3://clonezilla:<password>@server/clonezilla/),
# so any imaging client can read it: it opens that one share and
# nothing else. Set a site value in site-vars.yml. Allowed characters:
# letters, digits . _ ~ - (it sits in a URI, a kernel command line and
# a mount option list), 8 to 64 of them.
clonezilla_smb_password: "clonezilla-backup"
# The two other Samba accounts. pxe-upload: WinPE and Shop Floor setup
# on every imaging PC, image and app uploads. blancco: Blancco's report
# share. Same rules as clonezilla_smb_password. Empty: the shipped
# default (pxe / blancco). All three only SEED the webapp's Tools >
# Share accounts on the first run that sees them; after that the webapp
# owns them and a re-run keeps what it holds.
pxe_upload_password: ""
blancco_smb_password: ""
# The server's Linux login (pxe: SSH and sudo). Required while the box
# still has the shipped password "pxe"; set on every run it differs.
pxe_admin_password: ""
# The site, as stamped on Blancco erasure reports and the monthly CMMC
# evidence packs (webapp BLANCCO_SITE). Empty: "Unnamed site", so a
# missing name shows instead of another site's. Letters, digits, spaces
# and . , - ( ) & / only (it goes into a systemd unit).
site_name: ""
# Time zone of the server clock and of every time the webapp shows
# (evidence-pack month boundaries included). A tz database name.
site_timezone: "America/New_York"
# Wi-Fi Blancco joins to license erasures in the GE Blancco cloud (the
# imaging LAN has no internet). The SSID is GE-wide; the password can
# differ per site. Empty password: Blancco cannot license (warned).
blancco_wifi_ssid: "INTERNETACCESS"
blancco_wifi_password: ""
image_types:
- gea-standard
- gea-engineer
- gea-shopfloor
shopfloor_types:
- gea-shopfloor
# Image variants that share the generic Win10/Win11 unattend
# (gea-standard and gea-engineer both deploy Win11 Pro 24H2 now; the same
# unattend file drives both because the RunSynchronous entries are
# OS-version-agnostic).
standard_types:
- gea-standard
- gea-engineer
deploy_subdirs:
- Applications
- Control
- "Operating Systems"
- "Out-of-box Drivers"
- Packages
- Tools
tasks:
# --- The NICs: pick / validate, then pin by MAC (netplan) ---
# Replaces the old guess (first en* without 172.16.9.1 / the default
# route), which on a two-NIC box could pick the GE NIC. The pick runs
# from the USB copies, before anything on the box changes, and only
# ever at install time: once pinned, nothing re-guesses at boot.
# Installed first (they change no network config): when the pick
# refuses, the play stops here and the console tells the operator to
# run sudo pxe-pick-nic, which needs both on the box (first-boot.sh
# unmounts the USB).
- name: "NICs: install pxe-nic-check and pxe-pick-nic before the pick"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/{{ item }}"
dest: "/usr/local/sbin/{{ item }}"
mode: '0755'
loop: [pxe-nic-check, pxe-pick-nic]
- name: "NICs: pick the imaging NIC (pxe_nic_mac is not set in site-vars)"
command: /usr/local/sbin/pxe-pick-nic --auto
environment:
PXE_PICK_SUFFIXES: "{{ pxe_corp_domain_suffixes }}"
PXE_PICK_ALLOWED: "{{ pxe_nic_allowed_switches }}"
register: nic_pick
changed_when: false
failed_when: false
when: pxe_nic_mac | length == 0
- name: "NICs: pick result"
debug:
msg: "{{ nic_pick.stdout_lines }}"
when: pxe_nic_mac | length == 0
- name: "NICs: the imaging NIC could not be picked"
fail:
msg: >-
Could not pick the imaging NIC, nothing was changed:
{{ nic_pick.stdout_lines | select('search', 'REFUSED|verdict|gone|ERROR') | join(' ') }}
During the install plug in ONLY the imaging switch, or log in and
run: sudo pxe-pick-nic (pull the imaging cable and plug it back in).
Or set pxe_nic_mac in playbook/site-vars.yml.
when:
- pxe_nic_mac | length == 0
- nic_pick.rc != 0 or not (nic_pick.stdout_lines | last | default('') is match('^([0-9a-f]{2}:){5}[0-9a-f]{2}$'))
- name: "NICs: pin the picked imaging NIC"
set_fact:
pxe_nic_mac: "{{ nic_pick.stdout_lines | last }}"
when: pxe_nic_mac | length == 0
- name: "NICs: check the per-site NIC vars"
assert:
that:
- pxe_nic_mac is match('^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
- corp_nic_mac | length == 0 or corp_nic_mac is match('^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
- pxe_nic_mac | lower != corp_nic_mac | lower
- pxe_imaging_mac | lower == pxe_nic_mac | lower
- pxe_ge_mac | lower == corp_nic_mac | lower
- pxe_nic_name | length > 0 and pxe_nic_name | length <= 15
- corp_nic_name | length > 0 and corp_nic_name | length <= 15
- pxe_nic_name != corp_nic_name
- corp_nic_mac | length > 0 or not (pxe_nic_name is match('^(en|eth)'))
fail_msg: >-
pxe_nic_mac (imaging NIC) must be a MAC (aa:bb:cc:dd:ee:ff);
corp_nic_mac (GE NIC) empty or a different MAC. Set them in
playbook/site-vars.yml (copy site-vars.example.yml) or with -e,
then re-run. Do not set pxe_imaging_mac / pxe_ge_mac yourself;
they follow these (an -e pxe_nic_mac="" overrides the pick too:
drop it). Names (pxe_nic_name, corp_nic_name) must differ
and be 1-15 chars, and with corp_nic_mac empty pxe_nic_name must
not start with en or eth (the catch-all for the other ports
matches those). Now: pxe_nic_mac='{{ pxe_nic_mac }}'
corp_nic_mac='{{ corp_nic_mac }}' pxe_nic_name='{{ pxe_nic_name }}'.
quiet: yes
- name: "NICs: list the physical NICs on this box"
shell: |
for d in /sys/class/net/*; do
[ -e "$d/device" ] || continue
echo "$(basename "$d") $(cat "$d/address")"
done
register: nic_list
changed_when: false
- name: "NICs: the pinned MACs must be on this box"
vars:
box_macs: "{{ nic_list.stdout_lines | map('regex_replace', '^.* ', '') | map('lower') | list }}"
assert:
that:
- pxe_nic_mac | lower in box_macs
- corp_nic_mac | length == 0 or corp_nic_mac | lower in box_macs
fail_msg: >-
{{ 'pxe_nic_mac ' ~ pxe_nic_mac ~ ' is not on this box. ' if pxe_nic_mac | lower not in box_macs else '' }}{{ 'corp_nic_mac ' ~ corp_nic_mac ~ ' is not on this box. ' if corp_nic_mac and corp_nic_mac | lower not in box_macs else '' }}Nothing was changed (the network config would have left that NIC
unconfigured). Physical NICs here (name MAC):
{{ nic_list.stdout_lines | join('; ') }}.
Fix playbook/site-vars.yml and re-run.
quiet: yes
- name: "Site: check site_name and site_timezone"
assert:
that:
- site_name is string
- site_name | length == 0 or site_name is match('^[A-Za-z0-9 .,()&/-]{1,60}$')
- site_timezone is match('^[A-Za-z0-9_+/-]{1,64}$')
- ('/usr/share/zoneinfo/' ~ site_timezone) is file
fail_msg: >-
site_name must be up to 60 characters from letters, digits, spaces
and . , - ( ) & / (no quotes); site_timezone a time zone name such
as America/New_York, Europe/London or Asia/Kolkata. Now:
site_name='{{ site_name }}' site_timezone='{{ site_timezone }}'.
quiet: yes
- name: "Site: WARNING, site_name is not set"
debug:
msg:
- "!!! site_name is empty: Blancco erasure reports and the monthly evidence"
- "!!! packs will say 'Unnamed site'. Set site_name in playbook/site-vars.yml."
when: site_name | length == 0
- name: "Site: set the time zone"
command: timedatectl set-timezone {{ site_timezone }}
register: tz_set
changed_when: false
- name: "Clonezilla: check the clonezilla Samba password"
assert:
that:
- clonezilla_smb_password is string
- clonezilla_smb_password is match('^[A-Za-z0-9._~-]{8,64}$')
fail_msg: >-
clonezilla_smb_password must be 8-64 characters from A-Z a-z 0-9 . _ ~ -
(it goes into Clonezilla's smb3:// URI on the kernel command line).
Fix it in playbook/site-vars.yml and re-run.
quiet: yes
- name: "Clonezilla: WARNING - the default Samba password is in use"
# The password is readable from the boot menu by anything on the imaging
# LAN and the default is the same at every site. It opens only the
# clonezilla share, but each site should set its own.
debug:
msg: >-
!!! WARNING: clonezilla_smb_password is the shipped default. Set a
site-specific value in playbook/site-vars.yml and re-run.
when: clonezilla_smb_password == "clonezilla-backup"
- name: "Shares: check the pxe-upload and blancco Samba passwords"
assert:
that:
- pxe_upload_password is string and blancco_smb_password is string
- pxe_upload_password | length == 0 or pxe_upload_password is match('^[A-Za-z0-9._~-]{8,64}$')
- blancco_smb_password | length == 0 or blancco_smb_password is match('^[A-Za-z0-9._~-]{8,64}$')
fail_msg: >-
pxe_upload_password and blancco_smb_password must be empty or 8-64
characters from A-Z a-z 0-9 . _ ~ - (they go into startnet.cmd and
the Blancco preferences). Fix them in playbook/site-vars.yml and re-run.
quiet: yes
# MATCH / DIFF / UNKNOWN: does pxe's shadow hash match $PW? crypt()
# handles every scheme (the autoinstall hash is $6$, chpasswd and passwd
# write yescrypt $y$). Used by the two Admin tasks below.
- name: "Admin: is the Linux pxe login still the shipped password?"
shell: &pxe_pw_compare |
python3 -W ignore - <<'PY'
import os, subprocess
try:
import crypt
except ImportError:
print("UNKNOWN"); raise SystemExit
h = subprocess.run(["getent", "shadow", "pxe"], capture_output=True, text=True).stdout.split(":")
h = h[1] if len(h) > 1 else ""
print("MATCH" if h.startswith("$") and crypt.crypt(os.environ["PW"], h) == h else
"DIFF" if h.startswith("$") else "UNKNOWN")
PY
environment:
PW: "pxe"
register: pxe_admin_state
changed_when: false
- name: "Admin: check pxe_admin_password"
assert:
that:
- pxe_admin_password is string
- pxe_admin_password | length > 0 or pxe_admin_state.stdout != 'MATCH'
- pxe_admin_password | length == 0 or (pxe_admin_password | length >= 8 and pxe_admin_password | length <= 128)
- pxe_admin_password | length == 0 or pxe_admin_password is not search('[\x00-\x1f]')
- pxe_admin_password != 'pxe'
fail_msg: >-
Set pxe_admin_password in playbook/site-vars.yml (8-128 characters,
no line breaks): the Linux login pxe (SSH and sudo) still has the
shipped password "pxe", the same on every server. Fix the file 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) if corp_nic_mac else '' }}
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
mode: '0644'
content: |
# Managed by pxe_server_setup.yml: /etc/netplan/50-cloud-init.yaml
# is the playbook's two-NIC config, not cloud-init's.
network: {config: disabled}
- name: "NICs: deploy the netplan apply helper"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/pxe-netplan-apply.sh"
dest: /usr/local/sbin/pxe-netplan-apply.sh
mode: '0755'
- name: "NICs: stage the two-NIC netplan config"
template:
src: templates/netplan-pxe.yaml.j2
dest: /etc/pxe-server-netplan.yaml
mode: '0600'
# The catch-all ports (corp_nic_mac empty) are DHCP clients on whatever
# they are cabled to. Cabled to the imaging switch by mistake, they would
# take an address from our own dnsmasq and put a second interface in the
# imaging subnet; DenyList makes them ignore offers from pxe_server_ip.
- name: "NICs: drop-in folders for the catch-all ports"
file:
path: "/etc/systemd/network/10-netplan-{{ item }}.network.d"
state: directory
mode: '0755'
loop: [zz-en, zz-eth]
when: corp_nic_mac | length == 0
- name: "NICs: catch-all ports ignore our own DHCP offers"
copy:
dest: "/etc/systemd/network/10-netplan-{{ item }}.network.d/90-pxe-deny-own-dhcp.conf"
mode: '0644'
content: |
# Managed by pxe_server_setup.yml. Never take a lease from this
# server's own dnsmasq (an imaging switch on a non-imaging port).
[DHCPv4]
DenyList={{ pxe_server_ip }}
loop: [zz-en, zz-eth]
when: corp_nic_mac | length == 0
- name: "NICs: no catch-all drop-ins with a pinned GE NIC"
file:
path: "/etc/systemd/network/10-netplan-{{ item }}.network.d"
state: absent
loop: [zz-en, zz-eth]
when: corp_nic_mac | length > 0
- name: "NICs: create /etc/pxe-server"
file:
path: /etc/pxe-server
state: directory
mode: '0755'
- name: "NICs: record the pinned imaging NIC for pxe-pick-nic --show"
copy:
dest: /etc/pxe-server/nic-pick.conf
mode: '0644'
content: |
# Written by pxe_server_setup.yml. The imaging NIC the playbook pins.
imaging_mac={{ pxe_nic_mac | lower }}
when: nic_pick is not skipped and nic_pick.rc == 0
- name: "NICs: validate, install and apply netplan (rolls back on failure)"
command: >-
/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) if corp_nic_mac else '' }}
environment:
PXE_SUBNET: "{{ pxe_subnet }}"
PXE_SSH_CLIENT: "{{ pxe_ssh_client }}"
register: netplan_apply
changed_when: netplan_apply.stdout_lines | last | default('') == 'CHANGED'
- name: "NICs: result"
debug:
msg: "{{ netplan_apply.stdout_lines }}"
- name: "Pin IP forwarding off (the imaging LAN is never routed)"
copy:
dest: /etc/sysctl.d/99-pxe-net.conf
mode: '0644'
content: |
# Managed by pxe_server_setup.yml. The server never routes between
# the imaging LAN and the GE corporate network.
net.ipv4.ip_forward = 0
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 }}, 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
- name: "Apply the network sysctls now (Apache restarts before the handlers)"
command: sysctl -p /etc/sysctl.d/99-pxe-net.conf
when: pxe_net_sysctl is changed
- name: "Expand root partition and filesystem to use full disk"
args:
executable: /bin/bash
shell: |
# Find the root LV device
ROOT_DEV=$(findmnt -n -o SOURCE /)
ROOT_DISK=$(lsblk -n -o PKNAME $(readlink -f "$ROOT_DEV") | tail -1)
if [ -z "$ROOT_DISK" ]; then
echo "Could not determine root disk, skipping"
exit 0
fi
# Find the partition number for the LVM PV
PV_PART=$(pvs --noheadings -o pv_name | tr -d ' ' | head -1)
if [ -z "$PV_PART" ]; then
echo "No LVM PV found, skipping"
exit 0
fi
PART_NUM=$(echo "$PV_PART" | grep -o '[0-9]*$')
echo "Expanding /dev/${ROOT_DISK} partition ${PART_NUM} (${PV_PART})..."
growpart "/dev/${ROOT_DISK}" "${PART_NUM}" 2>&1 || true
pvresize "$PV_PART" 2>&1
lvextend -l +100%FREE "$ROOT_DEV" 2>&1 || true
resize2fs "$ROOT_DEV" 2>&1
echo "Disk: $(df -h / | tail -1)"
register: disk_expand
changed_when: "'CHANGED' in disk_expand.stdout or 'resized' in disk_expand.stdout"
- name: "Deploy dnsmasq dhcp-script for per-lease state cleanup"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/pxe-dhcp-hook.sh"
dest: /usr/local/sbin/pxe-dhcp-hook.sh
mode: '0755'
- name: "Configure dnsmasq for DHCP and TFTP"
copy:
dest: /etc/dnsmasq.conf
backup: yes
content: |
port=0
listen-address={{ pxe_server_ip }}
bind-dynamic
# Interface pinning (serve {{ pxe_nic_name }} only, never
# {{ corp_nic_name if corp_nic_mac else 'the other ports' }}) is in /etc/dnsmasq.d/pxe-nic-only.conf.
dhcp-range={{ pxe_dhcp_start }},{{ pxe_dhcp_end }},{{ pxe_dhcp_lease }}
# No default gateway (option 3) and no DNS (option 6) handed out:
# the PXE network is isolated and the PXE server does not forward
# internet traffic. Previously we set both, which made imaged PCs
# add a default route via 172.16.9.1 and prefer it over WiFi (lower
# interface metric). PPKG / Intune enrollment then black-holed
# internet-bound traffic. The fix used to be migrate-to-wifi.ps1
# disabling the wired NIC during first-logon, which created an
# eDNC race (10022 socket errors until the SYSTEM task re-enabled
# the wired NIC much later). Removing these options entirely lets
# Windows route internet via WiFi and same-subnet PXE/SMB traffic
# via wired, no migration script needed.
#
# Important: dnsmasq DEFAULTS to sending its own listening address as
# both router and DNS when these options are unset. Commenting them
# out is NOT the same as disabling - imaged PCs (and Blancco PXE
# clients) end up with {{ pxe_server_ip }} as gateway. The empty-value form
# below explicitly suppresses both options.
dhcp-option=3
dhcp-option=6
enable-tftp
tftp-root={{ tftp_dir }}
# Arch-aware NBP for Blancco/WinPE PXE clients:
# - legacy BIOS (vendorclass PXEClient:Arch:00000) -> ipxe.pxe, a
# BIOS full-feature iPXE build with an HTTP/TCP stack.
# - iPXE-running clients (userclass "iPXE") -> chain to the HTTP menu
# - every UEFI client -> ipxe-shim.efi, iPXE's Microsoft-signed shim
# (dual-signed, UEFI CA 2011 + 2023). Over TFTP it always loads
# ipxe.efi from the TFTP root, which is the iPXE snponly build
# (see the ipxe_* vars). Rollback: point the last line back at
# ipxe-bcm.efi (the old Broadcom-signed 1.21.1 build).
dhcp-vendorclass=set:bios,PXEClient:Arch:00000
dhcp-userclass=set:ipxe,iPXE
dhcp-boot=tag:bios,tag:!ipxe,ipxe.pxe
dhcp-boot=tag:ipxe,http://{{ pxe_server_ip }}:4433/Altiris/iPXE/GetPxeScript.aspx
dhcp-boot=tag:!bios,tag:!ipxe,ipxe-shim.efi
log-dhcp
# Per-lease state cleanup: flush conntrack + port-445 sockets for
# the client IP on add/del. Prevents "System error 53" when a PXE
# client re-images the same machine and hits a stale SMB session.
# Script runs as root by default (dnsmasq --dhcp-scriptuser default).
dhcp-script=/usr/local/sbin/pxe-dhcp-hook.sh
# Also call it when a client comes back to its existing lease (a
# re-image of the same PC), which dnsmasq otherwise reports only if
# the hostname or MAC changed. The hook acts on those only for PXE
# firmware / iPXE requests.
script-on-renewal
- name: "Pin dnsmasq to the imaging NIC (never DHCP/PXE on the GE NIC)"
template:
src: templates/dnsmasq-pxe-nic-only.conf.j2
dest: /etc/dnsmasq.d/pxe-nic-only.conf
mode: '0644'
backup: yes
# The second layer under interface=: dnsmasq offers only on an interface
# with an address inside a dhcp-range. With every range in the imaging
# subnet, a GE port (10.x) matches none even if the pin were lost. A
# proxy range or one outside the subnet would remove that layer.
- name: "dnsmasq: every dhcp-range is in the imaging subnet, none is proxy"
shell: |
set -u
bad=0
# Files the dnsmasq conf-dir skips (dot files, ~ backups, .dpkg-*,
# .bak) are skipped here too, so a backup never refuses the run.
FILES=(/etc/dnsmasq.conf)
for f in /etc/dnsmasq.d/*; do
b=${f##*/}
case "$b" in .*|*~|*.dpkg-*|*.bak|*.bak.*) continue ;; esac
[ -f "$f" ] && FILES+=("$f")
done
while IFS= read -r line; do
range=${line#*dhcp-range=}
first=$(printf '%s' "$range" | tr ',' '\n' | grep -Em1 '^[0-9]+(\.[0-9]+){3}$')
if printf '%s' "$range" | grep -qi proxy; then
echo "REFUSED proxy DHCP: $line" >&2; bad=1
elif [ -z "$first" ] || ! python3 -c 'import ipaddress,sys; sys.exit(ipaddress.ip_address(sys.argv[1]) not in ipaddress.ip_network(sys.argv[2]))' "$first" "{{ pxe_subnet }}"; then
echo "REFUSED dhcp-range outside {{ pxe_subnet }}: $line" >&2; bad=1
fi
done < <(grep -Hs '^[[:space:]]*dhcp-range=' "${FILES[@]}" | sed 's/[[:space:]]*#.*//')
[ "$bad" -eq 0 ] || exit 1
echo "dhcp-range ok"
args:
executable: /bin/bash
changed_when: false
# --- NIC guard: keep dnsmasq off the GE corporate network ---
# pxe-nic-check classifies each NIC from what it hears (LLDP/CDP/802.1X
# from managed switches, foreign DHCP offers and their domain). The
# dnsmasq drop-in runs it as ExecStartPre (--guard: dnsmasq refuses to
# start if the imaging NIC is on the GE network or next to another DHCP
# server); the timer re-checks every 5 min (--watch: stops dnsmasq) and
# writes /run/pxe-nic-status.json for the webapp. Operator notes and
# the floor tests: docs/pxe-nic-guard.md.
- name: "NIC guard: deploy pxe-nic-check"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/pxe-nic-check"
dest: /usr/local/sbin/pxe-nic-check
mode: '0755'
- name: "NIC guard: create /etc/pxe-server"
file:
path: /etc/pxe-server
state: directory
mode: '0755'
- name: "NIC guard: write /etc/pxe-server/nic.conf"
copy:
dest: /etc/pxe-server/nic.conf
mode: '0644'
content: |
# Managed by pxe_server_setup.yml (vars pxe_nic_mac, corp_nic_mac,
# pxe_server_ip, pxe_corp_domain_suffixes). Read by
# /usr/local/sbin/pxe-nic-check.
[nic]
imaging_mac = {{ pxe_imaging_mac | lower }}
ge_mac = {{ pxe_ge_mac | lower }}
corp_domain_suffixes = {{ pxe_corp_domain_suffixes }}
own_ip = {{ pxe_server_ip }}
allowed_switches = {{ pxe_nic_allowed_switches }}
- name: "NIC guard: create dnsmasq drop-in directory"
file:
path: /etc/systemd/system/dnsmasq.service.d
state: directory
mode: '0755'
- name: "NIC guard: install dnsmasq ExecStartPre drop-in"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/dnsmasq.service.d/nic-guard.conf"
dest: /etc/systemd/system/dnsmasq.service.d/nic-guard.conf
mode: '0644'
- name: "NIC guard: install watch service and timer"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: '0644'
loop:
- 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
state: started
enabled: yes
daemon_reload: yes
- name: "Create TFTP directory"
file:
path: "{{ tftp_dir }}"
state: directory
mode: '0755'
owner: nobody
group: nogroup
- name: "Create Win11 directory structure"
file:
path: "{{ web_root }}/win11/{{ item }}"
state: directory
mode: '0755'
loop:
- "EFI/Boot"
- "EFI/Microsoft/Boot"
- "Boot"
- "sources"
- name: "Create Altiris iPXE directory"
file:
path: "{{ web_root }}/Altiris/iPXE"
state: directory
mode: '0755'
- name: "Create boot tool directories"
file:
path: "{{ web_root }}/{{ item }}"
state: directory
mode: '0755'
loop:
- clonezilla
- clonezilla-i686
- blancco
- name: "Remove the retired Memtest86+ web dir (memory tests: Dell F12 Diagnostics)"
file:
path: "{{ web_root }}/memtest"
state: absent
- 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
# (iPXE 2.0 ${efi/SecureBoot}); Blancco starts through the Ubuntu shim
# (iPXE shim command) so it runs with Secure Boot on. The Blancco
# Releases page edits this file (test entry), so keep exactly one
# "item blancco" line and the :blancco_efi kernel+initrd block.
# The Clonezilla back up / restore entries carry the clonezilla
# Samba password: the tracked file has the placeholder
# __CLONEZILLA_SMB_PASSWORD__, filled in here (content, so a re-run
# with the same password changes nothing).
copy:
content: "{{ lookup('file', usb_mount ~ '/ipxe/GetPxeScript.aspx', rstrip=False) | replace('__CLONEZILLA_SMB_PASSWORD__', clonezilla_smb_password) }}"
dest: "{{ web_root }}/Altiris/iPXE/GetPxeScript.aspx"
mode: '0644'
backup: yes
diff: false # the content carries clonezilla_smb_password
when: "'STAGED-TEST' not in menu_test_entry.stdout"
- name: "Menu: NOT replaced, so check the live menu's Clonezilla password"
# The Blancco test entry blocked the menu update above: a changed
# clonezilla_smb_password would leave the live menu with the old one
# (Clonezilla back up / restore then cannot mount the share).
shell: |
F="{{ web_root }}/Altiris/iPXE/GetPxeScript.aspx"
grep -q 'ocs_repository=' "$F" || { echo "no Clonezilla repository entries in the live menu"; exit 0; }
if grep -qF "smb3://clonezilla:${CZ_PW}@" "$F"; then echo "password matches"; else
echo "WARNING: the live menu's Clonezilla entries do not carry clonezilla_smb_password;"
echo "remove the Blancco test entry and re-run to update the menu."
fi
environment:
CZ_PW: "{{ clonezilla_smb_password }}"
register: menu_cz_pw
changed_when: false
no_log: true
when: "'STAGED-TEST' in menu_test_entry.stdout"
- name: "Menu: Clonezilla password check result"
debug:
msg: "{{ menu_cz_pw.stdout }}"
when: "'STAGED-TEST' in menu_test_entry.stdout"
- name: "Install the Clonezilla back up / restore scripts (fetched by Clonezilla live)"
# GetPxeScript.aspx: ocs_prerun downloads
# http://<server>/clonezilla-scripts/pxe-cz.sh, ocs_live_run runs it.
copy:
src: "{{ usb_mount }}/clonezilla-scripts/"
dest: "{{ web_root }}/clonezilla-scripts/"
mode: '0644'
directory_mode: '0755'
# 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
# reach it from the GE side (see pxe-server.conf below).
- name: "Apache: serve port 80 on the imaging IP only"
lineinfile:
path: /etc/apache2/ports.conf
regexp: '^\s*Listen\s+(\S+:)?80\s*$'
line: "Listen {{ pxe_server_ip }}:80"
backup: yes
state: present
- name: "Apache: serve port 4433 (iPXE menu) on the imaging IP only"
lineinfile:
path: /etc/apache2/ports.conf
regexp: '^\s*Listen\s+(\S+:)?4433\s*$'
line: "Listen {{ pxe_server_ip }}:4433"
backup: yes
state: present
- name: "Create VirtualHost for Altiris iPXE on 4433"
copy:
dest: /etc/apache2/sites-available/altiris-ipxe.conf
backup: yes
content: |
<VirtualHost *:4433>
DocumentRoot {{ web_root }}
<Directory "{{ web_root }}/Altiris/iPXE">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
AddType text/plain .aspx
</Directory>
</VirtualHost>
- name: "Enable Altiris iPXE site"
command: a2ensite altiris-ipxe.conf
args:
creates: /etc/apache2/sites-enabled/altiris-ipxe.conf
- name: "Reload Apache to apply changes"
systemd:
name: apache2
state: reloaded
- name: "Create Samba share root"
file:
path: "{{ samba_share }}"
state: directory
mode: '0777'
- name: "Create Clonezilla backup share directory"
file:
path: /srv/samba/clonezilla
state: directory
mode: '0777'
- name: "Create Blancco reports share directory"
file:
path: /srv/samba/blancco-reports
state: directory
mode: '0777'
- name: "Create enrollment share with internal taxonomy"
file:
path: "/srv/samba/enrollment/{{ item }}"
state: directory
mode: '0777'
loop:
- ""
- ppkgs
- scripts
- config
- shopfloor-setup
- pre-install
- pre-install/bios
- pre-install/installers
- blancco
- logs
- name: "Deploy enrollment share README"
copy:
dest: /srv/samba/enrollment/README.md
mode: '0644'
content: |
# Enrollment Share Layout
Single SMB share mounted by WinPE as Y: during imaging. Subdir layout:
- ppkgs/ GCCH bulk-enrollment PPKGs
- scripts/ run-enrollment.ps1, wait-for-internet.ps1, migrate-to-wifi.ps1
- config/ FlatUnattendW10*.xml, per-site overrides
- shopfloor-setup/ Shopfloor setup scripts (gea-shopfloor-standard)
- pre-install/ preinstall.json + postppkg.json (site app lists) and installers/
- blancco/ Blancco custom images / configs
- logs/ Client log uploads
- name: "Deploy PPKG enrollment packages to ppkgs/"
shell: |
set +e
# Copy any whole PPKGs (small enough to fit on FAT32)
cp -f {{ usb_root }}/enrollment/*.ppkg /srv/samba/enrollment/ppkgs/ 2>/dev/null
# Reassemble any split files (foo.ppkg.part.00, .01, ... -> foo.ppkg)
for first in {{ usb_root }}/enrollment/*.part.00; do
[ -e "$first" ] || continue
base="${first%.part.00}"
name="$(basename "$base")"
echo "Reassembling $name from chunks..."
cat "${base}.part."* > "/srv/samba/enrollment/ppkgs/$name"
done
ls -lh /srv/samba/enrollment/ppkgs/*.ppkg 2>/dev/null
ignore_errors: yes
- name: "Deploy enrollment scripts to scripts/"
copy:
src: "{{ item.src }}"
dest: "/srv/samba/enrollment/scripts/{{ item.dest }}"
mode: '0644'
loop:
- { src: "{{ usb_mount }}/shopfloor-setup/run-enrollment.ps1", dest: "run-enrollment.ps1" }
- { src: "{{ usb_mount }}/wait-for-internet.ps1", dest: "wait-for-internet.ps1" }
- { src: "{{ usb_mount }}/migrate-to-wifi.ps1", dest: "migrate-to-wifi.ps1" }
- { src: "{{ usb_mount }}/winpe-status-push.ps1", dest: "winpe-status-push.ps1" }
ignore_errors: yes
- name: "Deploy Dell driver packs to shared Out-of-box Drivers"
args:
executable: /bin/bash
shell: |
set +e
SRC="{{ usb_root }}/drivers"
DEST="/srv/samba/winpeapps/_shared/Out-of-box Drivers/Dell_11"
if [ ! -d "$SRC" ]; then
echo "No drivers/ on USB - skipping"
exit 0
fi
mkdir -p "$DEST"
# Copy everything except split chunks
rsync -a --exclude='*.part.*' "$SRC/" "$DEST/"
# Reassemble any split driver files
while IFS= read -r first; do
base="${first%.part.00}"
rel="${base#$SRC/}"
out="$DEST/$rel"
mkdir -p "$(dirname "$out")"
echo "Reassembling $rel from chunks..."
cat "${base}.part."* > "$out"
done < <(find "$SRC" -name '*.part.00')
echo "Deployed Dell drivers from USB"
ignore_errors: yes
- name: "Deploy shopfloor setup scripts to enrollment share"
# rsync, not copy: the tree is ~4 GB and the copy module checksums every
# file one by one, so a re-run spent minutes here. rsync compares size
# and mtime and transfers only what changed. Merge, no --delete: files
# a site added on the share stay. Skipped if the USB has no tree.
shell: |
set -euo pipefail
SRC="{{ usb_mount }}/shopfloor-setup/"
[ -d "$SRC" ] || { echo "SKIP: no shopfloor-setup on the USB"; exit 0; }
mkdir -p /srv/samba/enrollment/shopfloor-setup
rsync -rlt -i --chmod=D0755,F0755 "$SRC" /srv/samba/enrollment/shopfloor-setup/
args:
executable: /bin/bash
register: shopfloor_rsync
changed_when: shopfloor_rsync.stdout | trim | length > 0 and 'SKIP:' not in shopfloor_rsync.stdout
# The site app lists (both empty in the generic build). force: no - once
# a site has filled in its lists on the share, a playbook re-run must not
# put the empty templates back.
- name: "Deploy the site app lists to pre-install/ (only if missing)"
copy:
src: "{{ usb_mount }}/preinstall/{{ item }}"
dest: "/srv/samba/enrollment/pre-install/{{ item }}"
mode: '0644'
force: no
loop: [preinstall.json, postppkg.json]
# Installers the imaging flow needs (PowerShell 7, which the shopfloor
# unattend installs before the PPKG) plus any the site adds. Merge, no
# delete: a site's own installers on the share stay.
- name: "Deploy pre-install installers (PowerShell 7 MSI)"
shell: |
set -e
SRC="{{ usb_mount }}/preinstall/installers/"
[ -d "$SRC" ] || { echo "SKIP: no preinstall/installers on the USB"; exit 0; }
mkdir -p /srv/samba/enrollment/pre-install/installers
rsync -rlt -i --chmod=D0755,F0644 "$SRC" /srv/samba/enrollment/pre-install/installers/
args:
executable: /bin/bash
register: preinstall_rsync
changed_when: preinstall_rsync.stdout | trim | length > 0 and 'SKIP:' not in preinstall_rsync.stdout
- name: "Ensure winpeapps/_shared/BIOS directory exists"
file:
path: /srv/samba/winpeapps/_shared/BIOS
state: directory
mode: '0755'
- name: "Ensure driver store is owned by pxe (download-drivers.py pushes here over ssh as the pxe user; root/webapp still write it fine)"
file:
path: "/srv/samba/winpeapps/_shared/Out-of-box Drivers"
state: directory
owner: pxe
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'
# Engineering Layer wrapper (tracked in the repo; the 650 MB payload it
# installs - engineering_layer/ and prereqs/ - is site content beside it).
# The engineer unattend stages it in specialize and runs it at first logon.
- name: "Engineering Layer: create gea-engineer extra_engineering"
file:
path: "{{ samba_share }}/gea-engineer/Deploy/Applications/extra_engineering"
state: directory
mode: '0755'
- name: "Engineering Layer: deploy Install-EngineeringLayer.ps1"
copy:
src: "{{ usb_mount }}/engineering/Install-EngineeringLayer.ps1"
dest: "{{ samba_share }}/gea-engineer/Deploy/Applications/extra_engineering/Install-EngineeringLayer.ps1"
mode: '0644'
# Site content (scripts/capture-site-content.sh on the build PC): what
# neither the repo nor the MCL image import brings - _shared/Applications/
# extra (Office, Adobe, OpenText, Java, fonts, Wi-Fi, Zscaler), the GE CA
# certificates and the Engineering Layer payload in each image's
# Deploy/Applications, and the links images keep there. Without it a new
# server cannot build standard or engineer. Checked against SHA256SUMS
# first: a bad copy stops the play instead of imaging PCs from it.
- name: "Site content: check the USB copy against SHA256SUMS"
shell: |
set -e
SC="{{ usb_root }}/site-content"
if [ ! -d "$SC" ]; then echo "SKIP: no site-content on the USB"; exit 0; fi
cd "$SC"
sha256sum --quiet --strict -c SHA256SUMS
LISTED=$(grep -c . SHA256SUMS)
FOUND=$(find . -type f ! -name SHA256SUMS | wc -l)
if [ "$LISTED" -ne "$FOUND" ]; then
echo "site-content holds $FOUND files, SHA256SUMS lists $LISTED" >&2; exit 1
fi
echo "VERIFIED $LISTED files"
args:
executable: /bin/bash
register: site_content_check
changed_when: false
- name: "Site content: restore into winpeapps (merge, nothing deleted)"
when: "'VERIFIED' in site_content_check.stdout"
shell: |
set -euo pipefail
SC="{{ usb_root }}/site-content"
S="{{ samba_share }}"
LINES=""
[ -f "$SC/links.txt" ] && LINES=$(grep -v '^#' "$SC/links.txt" | grep . || true)
# Check every link line before anything is copied.
while IFS=$'\t' read -r img name target; do
[ -n "$img" ] || continue
case "$img/$name/$target" in *..*) echo "REFUSED odd link line: $img $name $target" >&2; exit 1 ;; esac
case "$img$name" in */*) echo "REFUSED odd link line: $img $name" >&2; exit 1 ;; esac
case "$target" in _shared/*) ;; *) echo "REFUSED link outside _shared: $target" >&2; exit 1 ;; esac
done <<< "$LINES"
# Count files written (^>f), not folders whose time was touched.
if [ -d "$SC/shared" ]; then
N=$(rsync -rt --chmod=D0755,F0755 -i "$SC/shared/" "$S/_shared/" | { grep -c '^>f' || true; })
echo "shared: wrote $N files"
fi
if [ -d "$SC/images" ]; then
for d in "$SC/images"/*/; do
img=$(basename "$d")
mkdir -p "$S/$img/Deploy"
N=$(rsync -rt --chmod=D0755,F0755 -i "$d" "$S/$img/Deploy/" | { grep -c '^>f' || true; })
echo "$img: wrote $N files"
done
fi
# Links into _shared, as the captured server had them. Only to a
# folder that exists: an empty _shared/Applications/BPRT made here
# would stop the first MCL import from seeding it (bprt_sync).
while IFS=$'\t' read -r img name target; do
[ -n "$img" ] || continue
link="$S/$img/Deploy/Applications/$name"
if [ ! -d "$S/$target" ]; then echo "link later: $img/$name -> $target (not there yet)"; continue; fi
if [ -L "$link" ] && [ "$(readlink "$link")" = "$S/$target" ]; then continue; fi
if [ -e "$link" ] && [ ! -L "$link" ]; then echo "KEPT real folder $link (not replaced by a link)"; continue; fi
mkdir -p "$S/$img/Deploy/Applications"
ln -sfn "$S/$target" "$link"
echo "link: $img/$name -> $target"
done <<< "$LINES"
args:
executable: /bin/bash
register: site_content_restore
changed_when: site_content_restore.stdout is search('wrote [1-9]|^link:', multiline=True)
- name: "Site content: what was restored"
debug:
var: site_content_restore.stdout_lines
when: site_content_restore.stdout_lines is defined
- 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
# if exist B:\BIOS\check-bios.cmd ...
# Earlier deploy targeted enrollment/pre-install/bios/ (different share)
# which startnet.cmd never read, so BIOS_STATUS perma-stuck on
# "No BIOS check (share unavailable)". Corrected 2026-04-28.
copy:
src: "{{ usb_mount }}/shopfloor-setup/BIOS/{{ item }}"
dest: "/srv/samba/winpeapps/_shared/BIOS/{{ item }}"
mode: '0644'
loop:
- check-bios.cmd
- models.txt
- hardware-report.ps1
ignore_errors: yes
- name: "Deploy Dell Flash64W.exe to winpeapps/_shared/BIOS/"
copy:
src: "{{ usb_root }}/bios/Flash64W.exe"
dest: /srv/samba/winpeapps/_shared/BIOS/Flash64W.exe
mode: '0644'
ignore_errors: yes
- name: "Deploy BIOS update binaries from USB to winpeapps/_shared/BIOS/"
shell: >
if [ -d "{{ usb_root }}/bios" ]; then
cp -f {{ usb_root }}/bios/*.exe /srv/samba/winpeapps/_shared/BIOS/ 2>/dev/null || true
# models.txt from the captured server pairs models with these files;
# the repo copy installed above is older (the Dell update rewrites it).
[ -f "{{ usb_root }}/bios/models.txt" ] && cp -f "{{ usb_root }}/bios/models.txt" /srv/samba/winpeapps/_shared/BIOS/models.txt
count=$(find /srv/samba/winpeapps/_shared/BIOS -name '*.exe' | wc -l)
echo "Deployed $count BIOS binaries"
else
echo "No bios/ on USB - skipping"
fi
ignore_errors: yes
- name: "Create image upload staging directory"
file:
path: /home/pxe/image-upload
state: directory
mode: '0777'
owner: pxe
group: pxe
- name: "Enable Samba symlink following (shared image dirs)"
blockinfile:
path: /etc/samba/smb.conf
backup: yes
marker: "# {mark} MANAGED - GLOBAL SYMLINKS"
insertafter: '\[global\]'
block: |
follow symlinks = yes
wide links = yes
unix extensions = no
# Modern Samba silently disables wide links WITHOUT this. The shared
# image dirs are served via symlinks into _shared, so it is required
# or WinPE clients cannot follow them.
allow insecure wide links = yes
# Runs after the symlinks block so it lands first under [global], as on
# the live server.
- name: "Bind Samba to the imaging NIC only"
blockinfile:
path: /etc/samba/smb.conf
backup: yes
marker: "# {mark} MANAGED - PXE NIC BIND"
insertafter: '\[global\]'
block: |
# Bind Samba to the PXE LAN only. The second NIC reaches the GE corporate
# network, and these shares (blancco-reports, enrollment, image-upload,
# clonezilla) use shared, well-known imaging passwords and are writable by
# design. Without this they are offered to the corporate network the moment
# the cable is plugged in.
interfaces = 127.0.0.0/8 {{ pxe_nic_name }}
bind interfaces only = yes
- name: "Samba SMB session handling for WinPE re-image robustness"
blockinfile:
path: /etc/samba/smb.conf
backup: yes
marker: "# {mark} MANAGED - PXE REIMAGE FIX"
insertafter: "# END MANAGED - GLOBAL SYMLINKS"
block: |
# Reduce the chance a WinPE client rebooting mid-imaging leaves a
# stale session on the server that blocks its next connection
# attempt with "System error 53 network path not found". Combined
# with /etc/sysctl.d/99-pxe-conntrack.conf (shorter nf_conntrack
# TCP timeouts) this keeps the conntrack + smbd state in sync with
# the short-lived flows that PXE imaging produces.
socket options = TCP_NODELAY SO_KEEPALIVE IPTOS_LOWDELAY
keepalive = 30
# deadtime=0 (disabled): WinPE maps the enrollment share early then
# idles for minutes during the WIM apply. A non-zero deadtime drops
# that idle session, so the post-apply staging copies failed (bay
# left with only site-config.json). 0 = never auto-disconnect idle.
deadtime = 0
- name: "Configure Samba shares"
blockinfile:
path: /etc/samba/smb.conf
backup: yes
block: |
[winpeapps]
path = {{ samba_share }}
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload
force user = root
[clonezilla]
path = /srv/samba/clonezilla
browseable = yes
read only = no
guest ok = no
# clonezilla: the menu's back up / restore entries (this share
# only); pxe-upload: admins and manual Clonezilla runs.
valid users = pxe-upload clonezilla
force user = root
comment = Clonezilla backup images
[blancco-reports]
path = /srv/samba/blancco-reports
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload blancco
force user = root
comment = Blancco Drive Eraser reports
[enrollment]
path = /srv/samba/enrollment
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload
force user = root
comment = GCCH bulk enrollment packages
[image-upload]
path = /home/pxe/image-upload
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload
force user = pxe
force group = pxe
comment = PXE image upload staging area
oplocks = no
level2 oplocks = no
strict sync = yes
[winpeapps_bios]
path = /srv/samba/winpeapps/_shared/BIOS
browseable = no
read only = yes
guest ok = no
valid users = pxe-upload
comment = Dell BIOS update bundle (read-only, served to WinPE)
# The Samba passwords belong to the webapp (Tools > Share accounts):
# here the accounts are only created, with the site-vars value or the
# default; "Shares: apply the stored passwords" near the end sets them.
- name: "Create Samba users (pxe-upload, blancco, clonezilla)"
# clonezilla: system user, no home, no login shell; only [clonezilla]
# lists it in valid users.
shell: |
add() { # user password
pdbedit -L 2>/dev/null | grep -q "^$1:" && return 0
printf '%s\n%s\n' "$2" "$2" | smbpasswd -a -s "$1" >/dev/null && echo "created $1"
}
id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload
id blancco >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin blancco
id clonezilla >/dev/null 2>&1 || useradd -r -M -d /nonexistent -s /usr/sbin/nologin clonezilla
add pxe-upload "${UP_PW:-pxe}"
add blancco "${BL_PW:-blancco}"
add clonezilla "$CZ_PW"
environment:
UP_PW: "{{ pxe_upload_password }}"
BL_PW: "{{ blancco_smb_password }}"
CZ_PW: "{{ clonezilla_smb_password }}"
args:
executable: /bin/bash
register: smb_users
changed_when: "'created' in smb_users.stdout"
no_log: true
- name: "Admin: does the Linux pxe password already match pxe_admin_password?"
shell: *pxe_pw_compare
environment:
PW: "{{ pxe_admin_password }}"
register: pxe_admin_cur
changed_when: false
when: pxe_admin_password | length > 0
no_log: true
- name: "Admin: set the Linux pxe password from pxe_admin_password"
shell: printf 'pxe:%s\n' "$PW" | chpasswd
environment:
PW: "{{ pxe_admin_password }}"
when: pxe_admin_password | length > 0 and pxe_admin_cur.stdout != 'MATCH'
no_log: true
- name: "Deploy nf_conntrack TCP timeout sysctl for PXE workload"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/99-pxe-conntrack.conf"
dest: /etc/sysctl.d/99-pxe-conntrack.conf
mode: '0644'
notify: reload sysctl
- name: "Load nf_conntrack at boot so the conntrack sysctl applies"
# systemd-sysctl runs before ufw loads nf_conntrack, so without this the
# net.netfilter.* keys do not exist yet at boot and the 1 h timeout is
# silently skipped (the kernel default of 5 days stays in force).
copy:
dest: /etc/modules-load.d/pxe-conntrack.conf
content: "nf_conntrack\n"
mode: '0644'
- name: "Deploy SMB diagnostic + soft-reset helper scripts"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/{{ item }}"
dest: "/usr/local/sbin/{{ item }}"
mode: '0755'
loop:
- smb-diag.sh
- smb-soft-reset.sh
- name: "Create image-type top-level directories"
file:
path: "{{ samba_share }}/{{ item }}"
state: directory
mode: '0777'
loop: "{{ image_types }}"
- name: "Create Deploy subdirectories for each image type"
file:
path: "{{ samba_share }}/{{ item.0 }}/Deploy/{{ item.1 }}"
state: directory
mode: '0777'
with_nested:
- "{{ image_types }}"
- "{{ deploy_subdirs }}"
- name: "Create Media.tag for FlatSetupLoader.exe drive detection"
copy:
content: ""
dest: "{{ samba_share }}/{{ item }}/Deploy/Control/Media.tag"
mode: '0644'
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"
dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
mode: '0644'
# force: yes - repo is source of truth. force: no let the live shopfloor
# unattend go stale (missing the Fetch + Verify-And-Heal staging steps),
# and a playbook run never repaired it. Keep it in sync like the standard
# /engineer unattend below.
force: yes
loop: "{{ shopfloor_types }}"
ignore_errors: yes
# gea-standard and gea-engineer each get their OWN unattend. They are not
# interchangeable: the engineer file carries FirstLogonCommands Order 11,
# which installs the Engineering Layer, and nothing else differs. Deploying
# one shared template to both - which this task used to do - silently
# stripped that entry from gea-engineer on every playbook run.
#
# force: yes because drift between these files and the share is what caused
# the Win10/Win11 search-cleanup regression (d49f516). That only works if
# the repo copies are genuinely current: on 2026-08-19 this template was
# found to be missing specialize Orders 39-48 - including Order 39, which
# registers the bulk-enrollment orchestrator - so a playbook run would have
# removed enrollment from every standard and engineer bay. Both files are
# now reconciled against the live share. Re-check before trusting force: yes.
- name: "Deploy standard/engineer unattend.xml (per-type template)"
copy:
src: "{{ usb_mount }}/{{ item.src }}"
dest: "{{ samba_share }}/{{ item.dest }}/Deploy/FlatUnattendW10.xml"
mode: '0644'
force: yes
loop:
- { src: 'FlatUnattendW10.xml', dest: 'gea-standard' }
- { src: 'FlatUnattendW10-engineer.xml', dest: 'gea-engineer' }
ignore_errors: yes
- name: "Daily cron to create/refresh Media.tag for all images"
copy:
content: |
# Create Media.tag in any image with Deploy/Control/ and refresh existing ones
@reboot root for d in {{ samba_share }}/*/Deploy/Control; do [ -d "$d" ] && touch "$d/Media.tag"; done
0 0 * * * root for d in {{ samba_share }}/*/Deploy/Control; do [ -d "$d" ] && touch "$d/Media.tag"; done
dest: /etc/cron.d/media-tag-refresh
mode: '0644'
- name: "Copy WinPE & boot files from USB (skipped if not present)"
copy:
src: "{{ usb_root }}/{{ item.src }}"
dest: "{{ web_root }}/win11/{{ item.dest }}"
mode: '0644'
loop:
- { src: "wimboot", dest: "wimboot" }
- { src: "boot.stl", dest: "EFI/Microsoft/Boot/boot.stl" }
- { src: "BCD", dest: "EFI/Microsoft/Boot/BCD" }
- { src: "bootx64.efi", dest: "EFI/Boot/bootx64.efi" }
- { src: "boot.sdi", dest: "Boot/boot.sdi" }
- { src: "boot.wim", dest: "sources/boot.wim" }
ignore_errors: yes
- name: "Inject startnet.cmd into boot.wim (virtual BOOT/MEDIA volumes)"
shell: |
WIM="{{ web_root }}/win11/sources/boot.wim"
STARTNET="{{ usb_mount }}/startnet.cmd"
if [ -f "$WIM" ] && [ -f "$STARTNET" ]; then
echo "add $STARTNET /Windows/System32/startnet.cmd" | wimupdate "$WIM" 1
echo "Updated startnet.cmd in boot.wim"
else
echo "Skipped: boot.wim or startnet.cmd not found"
fi
args:
executable: /bin/bash
ignore_errors: yes
- name: "Install the Secure Boot iPXE chain into the TFTP root (pinned, verified)"
# Not ignore_errors: without these files no client can boot. Files come
# from boot-files/ via scripts/fetch-ipxe.sh; each is checked against
# its pin before anything is replaced, and each is put in place by
# rename so a client that is booting never reads half a file.
shell: |
set -euo pipefail
U="{{ usb_root }}"; T="{{ tftp_dir }}"
check() { echo "$2 $1" | sha256sum -c --status || { echo "sha256 mismatch or missing: $1" >&2; exit 1; }; }
check "$U/ipxeboot.tar.gz" "{{ ipxe_tarball_sha256 }}"
check "$U/ipxe-shimx64.efi" "{{ ipxe_shim_sha256 }}"
W=$(mktemp -d); trap 'rm -rf "$W"' EXIT
tar -xzf "$U/ipxeboot.tar.gz" -C "$W" ipxeboot/x86_64-sb/snponly.efi ipxeboot/x86_64/ipxe.pxe
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"; }
# 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; done="${done:+$done, }ipxe-bcm.efi"; fi
echo "installed: ${done:-nothing}"
args:
executable: /bin/bash
register: ipxe_chain
changed_when: true
- name: "Show the installed iPXE chain"
debug:
msg: "{{ ipxe_chain.stdout }}"
- name: "Copy boot tool files from USB (Clonezilla 64/32-bit, Blancco)"
# Clonezilla includes shimx64.efi (Debian's Microsoft-signed shim), which
# the menu uses to boot its Debian-signed kernel with Secure Boot on.
shell: >
cp -r "{{ usb_root }}/boot-tools/{{ item }}/"* "{{ web_root }}/{{ item }}/" 2>/dev/null || true
loop:
- clonezilla
- clonezilla-i686
- blancco
# --- Blancco PXE boot via Ubuntu kernel + switch_root ---
# Blancco's own kernel freezes on Dell Precision towers. Workaround:
# Boot Ubuntu kernel, download Blancco rootfs, overlay mount, switch_root.
# Kernel, modules and initramfs all come from blancco_kver (the HWE
# kernel), not the server's running kernel: the server may run 6.8 while
# Blancco boots 7.0. Output names carry the version so a changed
# blancco_kver rebuilds instead of being skipped by the creates: guards.
- name: "Blancco: is the Blancco kernel ({{ blancco_kver }}) installed?"
stat:
path: "{{ item }}"
loop:
- "/boot/vmlinuz-{{ blancco_kver }}"
- "/lib/modules/{{ blancco_kver }}/modules.dep"
register: blancco_kernel_files
- name: "Blancco: kernel check result"
set_fact:
blancco_kernel_ok: "{{ blancco_kernel_files.results | map(attribute='stat.exists') | min }}"
- 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 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 }}
and re-run.
when: not blancco_kernel_ok
- name: "Build Blancco PXE initramfs (kexec-initrd-{{ blancco_ksuffix }}.img)"
# The narrow hand-picked NIC driver list used before 2026-04-22 produced
# a 2 MB initramfs that hung on "waiting for network interface" for any
# hardware outside e1000e/igb/tg3/bnx2/bnxt_en/b44. This rewrite sweeps
# the full drivers/net/ tree (ethernet + phy + mdio + usb + fddi + wan)
# plus overlay / squashfs / loop / ptp / libphy / mii deps, runs depmod
# so blancco-init.sh can use modprobe with proper dependency resolution,
# and produces a ~20 MB initramfs. Size isn't a concern - HTTP loads it
# in under two seconds at gigabit, versus the many minutes of hangtime
# the narrow build cost us when a NIC was unsupported.
args:
executable: /bin/bash
# Stamp named after the full version: 7.0.0-34 -> 7.0.0-40 keeps the
# file name (suffix 70) but must rebuild.
creates: "{{ web_root }}/blancco/.kexec-initrd-{{ blancco_ksuffix }}.{{ blancco_kver }}.built"
when: blancco_kernel_ok
shell: |
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}
# Busybox (static) - bundled on USB at playbook/busybox-static
if [ -f /bin/busybox ]; then
cp /bin/busybox "$WORK/bin/busybox"
elif [ -f "{{ usb_root }}/playbook/busybox-static" ]; then
cp "{{ usb_root }}/playbook/busybox-static" "$WORK/bin/busybox"
else
echo "ERROR: No busybox available (not at /bin/busybox or on USB)"
exit 1
fi
chmod +x "$WORK/bin/busybox"
# All applets blancco-init.sh uses: modprobe, insmod, dmesg, find, env
# and export added vs the old narrow list.
for cmd in sh ash awk cat chmod cp dd echo grep gunzip ifconfig ip insmod ln losetup ls mkdir mknod modprobe mount mv reboot rm rmdir route sed sleep switch_root tar udhcpc umount wget cpio dmesg env export find; do
ln -sf busybox "$WORK/bin/$cmd"
done
# Full drivers/net/ tree - ethernet + phy + mdio + usb + fddi + wan.
# Preserve path under /lib/modules/$KVER so depmod can resolve deps.
NETDIR=/lib/modules/$KVER/kernel/drivers/net
for sub in ethernet mdio phy usb fddi wan; do
if [ -d "$NETDIR/$sub" ]; then
mkdir -p "$WORK/lib/modules/$KVER/kernel/drivers/net/$sub"
cp -r "$NETDIR/$sub/"* "$WORK/lib/modules/$KVER/kernel/drivers/net/$sub/" 2>/dev/null || true
fi
done
# Overlay + squashfs + loop + usb + hid + ptp + net/core
for modpath in fs/overlayfs fs/squashfs drivers/block drivers/usb/core drivers/usb/host drivers/hid drivers/ptp net/core; do
if [ -e "/lib/modules/$KVER/kernel/$modpath" ]; then
mkdir -p "$WORK/lib/modules/$KVER/kernel/$(dirname $modpath)"
cp -r "/lib/modules/$KVER/kernel/$modpath" "$WORK/lib/modules/$KVER/kernel/$modpath" 2>/dev/null || true
fi
done
# Pull in every module the copied ones depend on, from the host's
# modules.dep. A fixed list misses them: drivers/net/mii.ko was named
# here but ships as mii.ko.zst, so the test skipped it and usbnet,
# r8152 (USB-C to RJ45 adapters) and cdc_ether failed with "Unknown
# symbol mii_*"; igb likewise lacked dca and i2c-algo-bit.
python3 - "$KVER" "$WORK" <<'PYDEP'
import os, shutil, sys
kver, work = sys.argv[1], sys.argv[2]
src = f"/lib/modules/{kver}"
dst = f"{work}/lib/modules/{kver}"
dep = {}
for line in open(f"{src}/modules.dep"):
k, v = line.split(":", 1)
dep[k] = v.split()
have = {os.path.relpath(os.path.join(r, f), dst)
for r, _, fs in os.walk(dst) for f in fs if ".ko" in f}
todo, added = list(have), 0
while todo:
for d in dep.get(todo.pop(), []):
if d not in have:
have.add(d); todo.append(d); added += 1
os.makedirs(os.path.dirname(f"{dst}/{d}"), exist_ok=True)
shutil.copy2(f"{src}/{d}", f"{dst}/{d}")
print(f"added {added} dependency modules")
PYDEP
# NIC firmware the drivers request at probe (r8152/r8169 rtl_nic
# patches, tg3, bnx2/bnx2x, e100). The kernel loads .zst firmware.
for fw in rtl_nic tigon bnx2 bnx2x e100; do
if [ -d "/lib/firmware/$fw" ]; then
mkdir -p "$WORK/lib/firmware"
cp -r "/lib/firmware/$fw" "$WORK/lib/firmware/"
fi
done
# Decompress zstd modules in-place (busybox insmod can't handle .zst)
find "$WORK/lib/modules" -name '*.ko.zst' -print0 | \
xargs -0 -I {} sh -c 'zstd -d --rm -o "${1%.zst}" "$1" 2>/dev/null' _ {} || true
# Preserve modules.builtin / modules.order, regenerate modules.dep so
# modprobe can resolve dependencies inside the initramfs.
cp /lib/modules/$KVER/modules.builtin "$WORK/lib/modules/$KVER/" 2>/dev/null || true
cp /lib/modules/$KVER/modules.order "$WORK/lib/modules/$KVER/" 2>/dev/null || true
(cd "$WORK" && depmod -b . $KVER) 2>/dev/null || echo "depmod warning (non-fatal)"
# Init script
cp "{{ usb_root }}/playbook/blancco-init.sh" "$WORK/init"
chmod +x "$WORK/init"
# Build CPIO
cd "$WORK"
# Build beside the target and rename: a PC may be booting the old one.
find . | cpio -o -H newc --quiet | gzip -1 > "$OUT.tmp"
mv -f "$OUT.tmp" "$OUT"
cd /
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"
- name: "Copy Ubuntu kernel for Blancco PXE boot (vmlinuz-ubuntu-{{ blancco_ksuffix }})"
copy:
src: "/boot/vmlinuz-{{ blancco_kver }}"
dest: "{{ web_root }}/blancco/vmlinuz-ubuntu-{{ blancco_ksuffix }}"
remote_src: yes
mode: '0644'
when: blancco_kernel_ok
# Secure Boot on: iPXE 2.0 starts the Canonical-signed kernel above
# through Ubuntu's Microsoft-signed shim (GetPxeScript.aspx: shim
# http://${server}/blancco/shimx64.efi). From the shim-signed package.
- name: "Blancco: find Ubuntu's signed shim"
stat:
path: /usr/lib/shim/shimx64.efi.signed
register: ubuntu_shim
- name: "Blancco: publish Ubuntu's signed shim as blancco/shimx64.efi"
copy:
src: /usr/lib/shim/shimx64.efi.signed
dest: "{{ web_root }}/blancco/shimx64.efi"
remote_src: yes
mode: '0644'
when: ubuntu_shim.stat.exists
- name: "Blancco: warn when the signed shim is missing"
debug:
msg: >-
WARNING: /usr/lib/shim/shimx64.efi.signed not found (package
shim-signed). Blancco with Secure Boot ON will not start; with
Secure Boot off it still boots (the menu's shim line fails over).
when: not ubuntu_shim.stat.exists
- name: "Create TFTP blancco directory"
file:
path: "{{ tftp_dir }}/blancco"
state: directory
owner: nobody
group: nogroup
mode: '0755'
- name: "Create TFTP symlinks for Blancco kernel/initrd (GRUB HTTP times out on large files; TFTP is reliable)"
file:
src: "{{ web_root }}/blancco/{{ item }}"
dest: "{{ tftp_dir }}/blancco/{{ item }}"
state: link
force: yes
owner: nobody
group: nogroup
loop:
- vmlinuz-bde-linux
- initramfs-bde-linux.img
- intel-ucode.img
- amd-ucode.img
- config.img
- name: "Build Ubuntu kernel modules tarball for Blancco (kmod-{{ blancco_kver }}.tar.gz)"
when: blancco_kernel_ok
shell: |
set -e
KVER="{{ blancco_kver }}"
OUT="{{ web_root }}/blancco/kmod-$KVER.tar.gz"
# Ship the host's iwlwifi firmware with its modules. The UEFI path
# runs this Ubuntu kernel on Blancco's rootfs, and Blancco 7.20 only
# carries API 89 ucode for AX201/AX210/AX211, which the 6.8 iwlwifi
# (max API 86) cannot load, so WiFi licensing never came up. Files
# only, no lib/ entry: on the Blancco rootfs lib is a symlink to
# usr/lib and a directory entry would replace it.
cd /
# blancco-init.sh fetches kmod-$(uname -r).tar.gz (the booted kernel).
tar czf "$OUT.tmp" -C / "lib/modules/$KVER" $(ls -d lib/firmware/iwlwifi-*)
mv -f "$OUT.tmp" "$OUT"
echo "Built $(basename "$OUT"): $(du -h "$OUT" | cut -f1)"
args:
creates: "{{ web_root }}/blancco/kmod-{{ blancco_kver }}.tar.gz"
- name: "Extract config-clean.xml from config.img (strip null bytes)"
shell: |
if [ -f "{{ web_root }}/blancco/config.img" ]; then
WORK=$(mktemp -d)
cd "$WORK"
cpio -id < "{{ web_root }}/blancco/config.img" 2>/dev/null
tr -d '\000' < config.xml > "{{ web_root }}/blancco/config-clean.xml"
rm -rf "$WORK"
fi
args:
creates: "{{ web_root }}/blancco/config-clean.xml"
# Idempotent file copy (was bundled into the previous shell task with a
# 'creates:' gate, so playbook re-runs never picked up edits to the
# source XML - stale prefs survived deploys).
# The repo's preferences carry no site values. They are installed as
# preferences.repo.xml; the served preferences.xml is rendered from it
# plus the site settings (business location, licensing Wi-Fi) by the
# webapp's services/blancco_prefs.py, after the webapp is installed
# below. The settings are edited on Updates > Blancco releases.
- name: "Deploy Blancco preferences (repo copy, no site values)"
copy:
src: "{{ usb_root }}/playbook/blancco-preferences.xml"
dest: "{{ web_root }}/blancco/preferences.repo.xml"
mode: '0644'
# blancco-init.sh fetches this at boot and runs it as a service in the
# Blancco rootfs: erase progress for the webapp's /imaging dashboard.
- name: "Deploy Blancco progress reporter"
copy:
src: "{{ usb_root }}/playbook/blancco-progress.py"
dest: "{{ web_root }}/blancco/blancco-progress.py"
mode: '0644'
- name: "Check for WinPE deployment content on USB"
stat:
path: "{{ usb_root }}/images"
register: usb_images_dir
- name: "Import WinPE deployment content from USB (if present)"
shell: >
cp -rn "{{ usb_root }}/images/{{ item }}/"* "{{ samba_share }}/{{ item }}/" 2>/dev/null || true
loop: "{{ image_types }}"
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: "{{ '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.
- name: "UFW: allow imaging services on the imaging NIC only"
ufw:
rule: allow
direction: in
interface: "{{ pxe_nic_name }}"
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop: "{{ ufw_pxe_rules }}"
loop_control:
label: "{{ pxe_nic_name }} {{ item.port }}/{{ item.proto }}"
- name: "UFW: allow SSH and the webapp on the GE corporate NIC (from all of GE)"
ufw:
rule: allow
direction: in
interface: "{{ corp_nic_name }}"
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop: "{{ ufw_corp_rules }}"
loop_control:
label: "{{ corp_nic_name }} {{ item.port }}/{{ item.proto }}"
when: corp_admin_cidrs | length == 0 and corp_nic_mac | length > 0
# No pinned GE NIC (corp_nic_mac empty): the GE port has no fixed name,
# so SSH and the webapp are allowed by source, on any interface. The
# imaging NIC already allows both, so this opens nothing new there.
- name: "UFW: allow SSH and the webapp from all of GE (no pinned GE NIC)"
ufw:
rule: allow
direction: in
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop: "{{ ufw_corp_rules }}"
loop_control:
label: "any {{ item.port }}/{{ item.proto }}"
when: corp_admin_cidrs | length == 0 and corp_nic_mac | 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 if corp_nic_mac else 'any GE port' }}. 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 and corp_nic_mac | length > 0
- name: "UFW: allow SSH and the webapp from corp_admin_cidrs only (no pinned GE NIC)"
ufw:
rule: allow
direction: in
from_ip: "{{ item.1 }}"
port: "{{ item.0.port }}"
proto: "{{ item.0.proto }}"
loop: "{{ ufw_corp_rules | product(corp_admin_cidrs) | list }}"
loop_control:
label: "any from {{ item.1 }} {{ item.0.port }}/{{ item.0.proto }}"
when: corp_admin_cidrs | length > 0 and corp_nic_mac | 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 and corp_nic_mac | 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
PAIRS=("{{ pxe_nic_name }}={{ pxe_nic_mac | lower }}")
NAMES=("{{ pxe_nic_name }}")
{% if corp_nic_mac %}
PAIRS+=("{{ corp_nic_name }}={{ corp_nic_mac | lower }}")
NAMES+=("{{ corp_nic_name }}")
{% endif %}
for pair in "${PAIRS[@]}"; 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 "${NAMES[@]}"; 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
{% if not corp_nic_mac %}
# No pinned GE NIC: GE-side SSH is an any-interface rule, by source.
if ! grep -E '^ufw allow' <<<"$added" | grep -v ' on ' | grep -qE '(port 22( |$)| 22/tcp( |$))'; then
echo "ERROR: no any-interface UFW rule for port 22 (GE side)" >&2; bad=1
fi
{% endif %}
[ "$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
# With no pinned GE NIC and corp_admin_cidrs empty, "allow 22" and
# "allow 9009" on any interface ARE the GE-side rules: keep them.
- name: "UFW: remove the old any-interface rules"
ufw:
rule: allow
port: "{{ item.port }}"
proto: "{{ item.proto }}"
delete: yes
when: >-
corp_nic_mac | length > 0 or corp_admin_cidrs | length > 0
or item.port not in (ufw_corp_rules | map(attribute='port') | list)
loop:
- { port: "22", proto: tcp }
- { port: "67", proto: udp }
- { port: "69", proto: udp }
- { port: "80", proto: tcp }
- { port: "4433", proto: tcp }
- { port: "445", proto: tcp }
- { port: "9009", proto: tcp }
loop_control:
label: "any {{ item.port }}/{{ item.proto }}"
- name: "UFW: remove the stale 9011 rule (old webapp port)"
ufw:
rule: allow
direction: in
interface: "{{ pxe_nic_name }}"
port: "9011"
proto: tcp
delete: yes
- name: "Enable UFW firewall"
ufw:
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
mode: '0644'
content: |
@reboot root /bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service
# --- Updates pages (Dell drivers/BIOS, iPXE, Blancco) ---
# The webapp downloads from downloads.dell.com and GitHub through the
# corporate NIC and unpacks with 7z/zip; sbverify checks iPXE signatures.
- name: "Install tools the webapp Updates pages need (if missing)"
apt:
name:
- sbsigntool
- p7zip-full
- zip
- unzip
- curl
state: present
ignore_errors: yes
- name: "Find the Zscaler Root CA (playbook copy, else the image share)"
shell: |
for f in "{{ zscaler_ca_src }}" {{ zscaler_ca_share_glob }}; do
if [ -f "$f" ] && grep -q "BEGIN CERTIFICATE" "$f"; then
echo "$f"
exit 0
fi
done
register: zscaler_ca
changed_when: false
failed_when: false
- name: "Install the Zscaler Root CA into the system trust store"
copy:
src: "{{ zscaler_ca.stdout_lines[0] }}"
dest: /usr/local/share/ca-certificates/zscaler-root-ca.crt
owner: root
group: root
mode: '0644'
when: zscaler_ca.stdout_lines | length > 0
register: zscaler_ca_copy
- name: "Rebuild the system CA bundle"
command: update-ca-certificates
when: zscaler_ca_copy is changed
- name: "Warn when no Zscaler Root CA was found"
debug:
msg: "WARNING: no Zscaler Root CA at {{ zscaler_ca_src }} or {{ zscaler_ca_share_glob }}; GitHub (iPXE update check) will fail TLS until it is installed."
when: zscaler_ca.stdout_lines | length == 0
- name: "Create webapp Updates state directory"
file:
path: /var/lib/pxe-webapp/updates
state: directory
owner: root
group: root
mode: '0755'
# --- Web Management App (Flask) ---
- name: "Create webapp directory"
file:
path: /opt/pxe-webapp
state: directory
mode: '0755'
- 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. -i lists
# what changed, so the service restarts only then.
shell: |
set -eo pipefail
test -f "{{ usb_root }}/webapp/app.py"
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. 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
export WHEEL_DIR="$(cd "$d" && pwd)"
break
fi
done
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 = {}, {}
for m in re.finditer(r'^([A-Za-z0-9_.-]+)==([^\s\\]+)((?:\s*\\?\s*--hash=sha256:[0-9a-f]{64})+)', reqs, re.M):
name = norm(m.group(1))
pins[name] = m.group(2)
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()
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()):
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)):
if not row:
continue
f = os.path.normpath(os.path.join(site, row[0]))
if f.startswith(site + os.sep) and os.path.isfile(f):
os.remove(f)
shutil.rmtree(old, ignore_errors=True)
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
register: webapp_wheels
changed_when: webapp_wheels.stdout_lines | last | default('') == 'CHANGED'
# Blancco site settings: seeded from site-vars only while none exist
# (after that the webapp page owns them), then the served preferences.xml
# is rendered from preferences.repo.xml. Password via env, not argv.
- name: "Blancco: render preferences.xml with the site settings"
command: python3 -m services.blancco_prefs render --seed
args:
chdir: /opt/pxe-webapp
environment:
WEB_ROOT: "{{ web_root }}"
BLANCCO_SEED_LOCATION: "{{ site_name }}"
BLANCCO_SEED_SSID: "{{ blancco_wifi_ssid }}"
BLANCCO_SEED_PASSWORD: "{{ blancco_wifi_password }}"
no_log: true
register: blancco_render
changed_when: "'CHANGED' in blancco_render.stdout_lines"
- name: "Blancco: render result"
debug:
msg: "{{ blancco_render.stdout_lines | select('search', 'CHANGED|WARNING|seeded') | list }}"
# The Samba passwords: seeded from site-vars only for accounts the
# webapp does not hold yet (Tools > Share accounts owns them after
# that), then set in Samba, startnet.cmd in boot.wim (this run injected
# the USB copy), the iPXE menu's Clonezilla entries (rendered from
# site-vars above) and the Blancco preferences. Passwords via env.
- name: "Shares: apply the stored passwords"
command: python3 -m services.share_accounts apply --seed
args:
chdir: /opt/pxe-webapp
environment:
WEB_ROOT: "{{ web_root }}"
SHARE_SEED_PXE_UPLOAD: "{{ pxe_upload_password }}"
SHARE_SEED_BLANCCO: "{{ blancco_smb_password }}"
SHARE_SEED_CLONEZILLA: "{{ clonezilla_smb_password }}"
no_log: true
register: share_apply
changed_when: share_apply.stdout_lines | select('search', 'updated|seeded') | list | length > 0
- name: "Shares: apply result"
debug:
msg: "{{ share_apply.stdout_lines }}"
# Hard gate: blancco-init.sh in initramfs drops to shell if the served
# preferences.xml is malformed or missing the SMB target. Fail the
# playbook here too so the problem surfaces at deploy time, not at the
# next erasure attempt.
- name: "Validate deployed preferences.xml is well-formed + targets the right SMB share"
shell: |
set -e
python3 -c 'import xml.etree.ElementTree as ET; ET.parse("{{ web_root }}/blancco/preferences.xml")'
grep -q '<hostname>{{ pxe_server_ip }}</hostname>' "{{ web_root }}/blancco/preferences.xml"
grep -q '<path>blancco-reports</path>' "{{ web_root }}/blancco/preferences.xml"
grep -q '<business_location>[^<]' "{{ web_root }}/blancco/preferences.xml"
changed_when: false
- name: "Create systemd service for PXE webapp"
copy:
dest: /etc/systemd/system/pxe-webapp.service
content: |
[Unit]
Description=PXE Server Web Management
After=network.target apache2.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/pxe-webapp
Environment=SAMBA_SHARE={{ samba_share }}
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
Environment=WEB_ROOT={{ web_root }}
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
Environment=ENROLLMENT_SHARE=/srv/samba/enrollment
Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
# Lock TZ so report/backup mtimes and audit-log timestamps render
# in the site's time zone regardless of how the host's
# /etc/localtime ends up. Without this, a Python process started
# before timedatectl finishes can cache UTC for its lifetime.
Environment=TZ={{ site_timezone }}
Environment=DISPLAY_TZ={{ site_timezone }}
Environment="BLANCCO_SITE={{ site_name if site_name else 'Unnamed site' }}"
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
register: webapp_unit
- name: "Enable and start PXE webapp service"
systemd:
name: pxe-webapp
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
# into Box. Persistent catches a run missed while the server was off.
- name: "Create Blancco evidence pack service and timer"
copy:
dest: "/etc/systemd/system/{{ item.name }}"
content: "{{ item.content }}"
loop:
- name: pxe-blancco-evidence.service
content: |
[Unit]
Description=Save last month's Blancco CMMC evidence pack to the reports share
[Service]
Type=oneshot
WorkingDirectory=/opt/pxe-webapp
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
Environment=TZ={{ site_timezone }}
Environment=DISPLAY_TZ={{ site_timezone }}
Environment="BLANCCO_SITE={{ site_name if site_name else 'Unnamed site' }}"
ExecStart=/usr/bin/python3 /opt/pxe-webapp/blancco_evidence_monthly.py
- name: pxe-blancco-evidence.timer
content: |
[Unit]
Description=Monthly Blancco CMMC evidence pack
[Timer]
OnCalendar=*-*-01 06:00:00
Persistent=true
[Install]
WantedBy=timers.target
- name: "Enable Blancco evidence pack timer"
systemd:
name: pxe-blancco-evidence.timer
state: started
enabled: yes
daemon_reload: yes
- name: "Configure unified Apache site (static files + webapp proxy)"
copy:
dest: /etc/apache2/sites-available/pxe-server.conf
content: |
# 9009 listens on every address: admins use the webapp from the GE
# 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 }}
<Directory "{{ web_root }}">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
ProxyPreserveHost On
ProxyPass /manage http://127.0.0.1:9010/
ProxyPassReverse /manage http://127.0.0.1:9010/
</VirtualHost>
<VirtualHost *:9009>
Alias /static /opt/pxe-webapp/static
<Directory "/opt/pxe-webapp/static">
Require all granted
Options -Indexes
</Directory>
ProxyPreserveHost On
ProxyPass /static !
ProxyPass / http://127.0.0.1:9010/
ProxyPassReverse / http://127.0.0.1:9010/
</VirtualHost>
- name: "Enable Apache proxy modules"
command: a2enmod proxy proxy_http
args:
creates: /etc/apache2/mods-enabled/proxy.load
- name: "Disable default Apache site"
command: a2dissite 000-default.conf
args:
removes: /etc/apache2/sites-enabled/000-default.conf
- name: "Enable unified PXE server site"
command: a2ensite pxe-server.conf
args:
creates: /etc/apache2/sites-enabled/pxe-server.conf
- name: "Reload Apache after site changes"
systemd:
name: apache2
state: reloaded
# The network config (two NICs pinned by MAC) is applied near the top of
# the play, "NICs: ..." tasks, so dnsmasq/UFW/Samba see the final names.
# The old br-pxe bridge layout (onboard + USB NIC bonded) is gone: one NIC
# per network, see templates/netplan-pxe.yaml.j2.
handlers:
- name: "reload sysctl"
command: sysctl --system
- name: "restart networkd-dispatcher"
systemd:
name: networkd-dispatcher
state: restarted