--- - name: PXE Server Setup (Ubuntu with dnsmasq) hosts: localhost connection: local become: yes gather_facts: yes pre_tasks: - 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 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 image_types: - gea-standard - gea-engineer - gea-shopfloor - ge-standard - ge-engineer - ge-shopfloor-lockdown - ge-shopfloor-mce shopfloor_types: - gea-shopfloor deploy_subdirs: - Applications - Control - "Operating Systems" - "Out-of-box Drivers" - Packages - Tools tasks: - name: "Gather minimal network facts" ansible.builtin.setup: filter: - ansible_interfaces - ansible_default_ipv4 - name: "Bring up all ethernet-like interfaces" command: ip link set dev {{ item }} up loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}" ignore_errors: yes - name: "Find interface with 10.9.100.1 already configured" set_fact: preconfigured_iface: >- {{ ansible_interfaces | select('match','^e(th|n)') | map('regex_replace','^(.*)$','ansible_\1') | map('extract', hostvars[inventory_hostname]) | selectattr('ipv4','defined') | selectattr('ipv4.address','equalto','10.9.100.1') | map(attribute='device') | list | first | default('') }} ignore_errors: yes - name: "Determine PXE interface" set_fact: pxe_iface: >- {{ preconfigured_iface | default('',true) or (ansible_interfaces | select('match','^e(th|n)') | reject('equalto','lo') | reject('equalto', ansible_default_ipv4.interface | default('')) | list ) | first | default(ansible_default_ipv4.interface | default( ansible_interfaces | select('match','^e(th|n)') | first | default('eth0') )) }} - name: "Debug: final pxe_iface choice" debug: msg: "Using {{ pxe_iface }} for DHCP/TFTP" - 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 interface={{ pxe_iface }} bind-interfaces dhcp-range=10.9.100.10,10.9.100.100,12h dhcp-option=3,10.9.100.1 dhcp-option=6,8.8.8.8 enable-tftp tftp-root={{ tftp_dir }} dhcp-boot=ipxe.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 - 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 - blancco - memtest - name: "Create GetPxeScript.aspx (iPXE boot menu)" copy: dest: "{{ web_root }}/Altiris/iPXE/GetPxeScript.aspx" backup: yes content: | #!ipxe set server 10.9.100.1 :menu menu GE Aerospace PXE Boot Menu item --gap -- ---- Windows Deployment ---- item winpe Windows PE (Image Deployment) item --gap -- ---- Utilities (Secure Boot must be DISABLED) ---- item blancco Blancco Drive Eraser item clonezilla Clonezilla Live (Disk Imaging) item memtest Memtest86+ (Memory Diagnostics) item --gap -- ---- item reboot Reboot item exit Exit to BIOS choose --default winpe --timeout 30000 target && goto ${target} :winpe echo echo Windows deployment requires Secure Boot to be ENABLED. echo If you disabled it for Blancco/Clonezilla, re-enable it now. echo prompt --timeout 5000 Press any key to continue (auto-boot in 5s)... && goto winpe_boot || goto winpe_boot :winpe_boot kernel http://${server}/win11/wimboot gui initrd http://${server}/win11/EFI/Microsoft/Boot/boot.stl EFI/Microsoft/Boot/Boot.stl initrd http://${server}/win11/EFI/Microsoft/Boot/BCD EFI/Microsoft/Boot/BCD initrd http://${server}/win11/EFI/Boot/bootx64.efi EFI/Boot/bootx64.efi initrd http://${server}/win11/Boot/boot.sdi Boot/boot.sdi initrd http://${server}/win11/sources/boot.wim sources/boot.wim boot :clonezilla set base http://${server}/clonezilla kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs || goto secureboot_warn initrd ${base}/initrd.img boot :blancco chain http://${server}/blancco/grubx64.efi || goto secureboot_warn :memtest kernel http://${server}/memtest/memtest.efi || goto secureboot_warn boot :secureboot_warn echo echo ====================================================== echo This option requires Secure Boot to be DISABLED. echo echo 1. Reboot this machine echo 2. Press F2 / Del to enter BIOS Setup echo 3. Disable Secure Boot echo 4. Save and exit BIOS echo 5. PXE boot again and select this option echo echo Re-enable Secure Boot after completing the task. echo ====================================================== echo prompt Press any key to return to menu... goto menu :reboot reboot :exit exit - name: "Ensure Apache listens on port 4433" lineinfile: path: /etc/apache2/ports.conf line: "Listen 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: | DocumentRoot {{ web_root }} Options Indexes FollowSymLinks AllowOverride None Require all granted AddType text/plain .aspx - 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 - installers-post - installers-post/cmm - 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/ site-config.json, FlatUnattendW10*.xml, per-site overrides - shopfloor-setup/ Per-PC-type post-imaging scripts - pre-install/ WinPE-phase content (bios/, installers/, preinstall.json) - installers-post/ Post-OOBE app installers (cmm/PCDMIS, etc.) - 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" } ignore_errors: yes - name: "Deploy site-config.json to config/" copy: src: "{{ usb_mount }}/shopfloor-setup/site-config.json" dest: /srv/samba/enrollment/config/site-config.json mode: '0644' 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" copy: src: "{{ usb_mount }}/shopfloor-setup/" dest: /srv/samba/enrollment/shopfloor-setup/ mode: '0755' directory_mode: '0755' ignore_errors: yes - name: "Deploy preinstall.json to pre-install/" copy: src: "{{ usb_mount }}/preinstall/preinstall.json" dest: /srv/samba/enrollment/pre-install/preinstall.json mode: '0644' ignore_errors: yes - name: "Deploy BIOS check script and manifest to pre-install/bios/" copy: src: "{{ usb_mount }}/shopfloor-setup/BIOS/{{ item }}" dest: "/srv/samba/enrollment/pre-install/bios/{{ item }}" mode: '0644' loop: - check-bios.cmd - models.txt ignore_errors: yes - name: "Deploy BIOS update binaries from USB to pre-install/bios/" shell: > if [ -d "{{ usb_root }}/bios" ]; then cp -f {{ usb_root }}/bios/*.exe /srv/samba/enrollment/pre-install/bios/ 2>/dev/null || true count=$(find /srv/samba/enrollment/pre-install/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 - 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 = 5 - 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 valid users = pxe-upload 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 - name: "Create Samba users (pxe-upload and blancco)" shell: | id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload echo -e 'pxe\npxe' | smbpasswd -a pxe-upload -s id blancco >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin blancco echo -e 'blancco\nblancco' | smbpasswd -a blancco -s args: executable: /bin/bash changed_when: false - 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: "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: "Deploy shopfloor unattend.xml template" copy: src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml" dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml" mode: '0644' force: no loop: "{{ shopfloor_types }}" 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: "Copy iPXE binaries from USB (skipped if not present)" copy: src: "{{ usb_root }}/{{ item }}" dest: "{{ tftp_dir }}/{{ item }}" mode: '0755' loop: - ipxe.efi ignore_errors: yes - name: "Copy boot tool files from USB (Clonezilla, Blancco, Memtest)" shell: > cp -r "{{ usb_root }}/boot-tools/{{ item }}/"* "{{ web_root }}/{{ item }}/" 2>/dev/null || true loop: - clonezilla - blancco - memtest # --- 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. - name: "Build Blancco PXE initramfs" args: executable: /bin/bash creates: "{{ web_root }}/blancco/kexec-initrd.img" shell: | set -e WORK=$(mktemp -d) mkdir -p "$WORK"/{bin,lib/modules,lib64,sbin,usr/share/udhcpc} # Busybox (static) - bundled on USB at playbook/busybox-static if [ -f /bin/busybox ]; then cp /bin/busybox "$WORK/bin/" elif [ -f "{{ usb_root }}/playbook/busybox-static" ]; then cp "{{ usb_root }}/playbook/busybox-static" "$WORK/bin/busybox" chmod +x "$WORK/bin/busybox" else echo "ERROR: No busybox available (not at /bin/busybox or on USB)" exit 1 fi for cmd in sh awk cat chmod echo grep gunzip ifconfig ip ln losetup ls mkdir mknod mount reboot route sed sleep switch_root tar udhcpc umount wget cpio; do ln -sf busybox "$WORK/bin/$cmd" done # NIC drivers (common server NICs) KVER=$(uname -r) KMOD="/lib/modules/$KVER/kernel/drivers/net/ethernet" for drv in intel/e1000e/e1000e.ko.zst intel/igb/igb.ko.zst broadcom/tg3.ko.zst broadcom/bnx2.ko.zst broadcom/bnxt/bnxt_en.ko.zst broadcom/b44.ko.zst; do if [ -f "$KMOD/$drv" ]; then zstd -d "$KMOD/$drv" -o "$WORK/lib/modules/$(basename ${drv%.zst})" 2>/dev/null fi done # Overlay module OVMOD="/lib/modules/$KVER/kernel/fs/overlayfs/overlay.ko.zst" if [ -f "$OVMOD" ]; then zstd -d "$OVMOD" -o "$WORK/lib/modules/overlay.ko" 2>/dev/null fi # Init script cp "{{ usb_root }}/playbook/blancco-init.sh" "$WORK/init" chmod +x "$WORK/init" # Build CPIO cd "$WORK" find . | cpio -o -H newc 2>/dev/null | gzip > "{{ web_root }}/blancco/kexec-initrd.img" rm -rf "$WORK" echo "Built kexec-initrd.img: $(stat -c %s '{{ web_root }}/blancco/kexec-initrd.img') bytes" ignore_errors: yes - name: "Copy Ubuntu kernel for Blancco PXE boot" copy: src: "/boot/vmlinuz-{{ ansible_kernel }}" dest: "{{ web_root }}/blancco/vmlinuz-ubuntu" remote_src: yes mode: '0644' - 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" shell: | set -e KVER=$(uname -r) tar czf "{{ web_root }}/blancco/kmod.tar.gz" -C / "lib/modules/$KVER" echo "Built kmod.tar.gz: $(du -h '{{ web_root }}/blancco/kmod.tar.gz' | cut -f1)" args: creates: "{{ web_root }}/blancco/kmod.tar.gz" - name: "Deploy Blancco config and preferences (null-stripped)" shell: | # Strip null bytes from config.img files and deploy 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 # Deploy preferences from playbook (pre-configured with network share) cp "{{ usb_root }}/playbook/blancco-preferences.xml" "{{ web_root }}/blancco/preferences.xml" args: creates: "{{ web_root }}/blancco/config-clean.xml" - name: "Ensure Samba user for Blancco reports exists (idempotent)" shell: | id blancco >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin blancco pdbedit -L 2>/dev/null | grep -q '^blancco:' || (echo -e "blancco\nblancco" | smbpasswd -a -s blancco) changed_when: false - 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" systemd: name: "{{ item }}" state: restarted enabled: yes loop: - dnsmasq - apache2 - smbd - name: "Allow necessary firewall ports (UFW)" ufw: rule: allow port: "{{ item }}" proto: "{{ 'udp' if item in ['67','69'] else 'tcp' }}" loop: - "22" - "67" - "69" - "80" - "4433" - "445" - "9009" - name: "Enable UFW firewall" ufw: state: enabled policy: deny - 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 # --- Web Management App (Flask) --- - name: "Create webapp directory" file: path: /opt/pxe-webapp state: directory mode: '0755' - name: "Copy webapp from USB" shell: > cp -r "{{ usb_root }}/webapp/"* /opt/pxe-webapp/ 2>/dev/null || true args: creates: /opt/pxe-webapp/app.py - name: "Install webapp Python dependencies (offline wheels)" args: executable: /bin/bash shell: | # Find the pip-wheels directory on the CIDATA mount 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 [ -n "$WHEEL_DIR" ]; then # Install wheels directly using Python zipfile (bypasses pip entirely) # This avoids the pip3/distutils incompatibility between Ubuntu 22.04 debs and Python 3.12 python3 << 'PYEOF' import zipfile, sysconfig, glob, os site = sysconfig.get_path('platlib') wheel_dir = os.environ['WHEEL_DIR'] for whl in sorted(glob.glob(os.path.join(wheel_dir, '*.whl'))): name = os.path.basename(whl) print(f' Installing {name}') with zipfile.ZipFile(whl) as z: z.extractall(site) print('All wheels installed to ' + site) PYEOF else # Fallback: try system pip (works if system has internet and compatible pip) python3 -m pip install --break-system-packages flask lxml 2>/dev/null || pip3 install --break-system-packages flask lxml fi - 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 ExecStart=/usr/bin/python3 app.py Restart=always RestartSec=5 [Install] WantedBy=multi-user.target - name: "Enable and start PXE webapp service" systemd: name: pxe-webapp 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: | Listen 9009 DocumentRoot {{ web_root }} Options Indexes FollowSymLinks AllowOverride None Require all granted ProxyPreserveHost On ProxyPass /manage http://127.0.0.1:9010/ ProxyPassReverse /manage http://127.0.0.1:9010/ Alias /static /opt/pxe-webapp/static Require all granted Options -Indexes ProxyPreserveHost On ProxyPass /static ! ProxyPass / http://127.0.0.1:9010/ ProxyPassReverse / http://127.0.0.1:9010/ - 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 - name: "Configure static IP for PXE interface" copy: dest: /etc/netplan/50-cloud-init.yaml backup: yes content: | network: version: 2 renderer: networkd ethernets: {{ pxe_iface }}: dhcp4: no addresses: [10.9.100.1/24] notify: "Apply netplan" handlers: - name: "Apply netplan" command: netplan apply - name: "reload sysctl" command: sysctl --system