--- - 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 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 - gea-shopfloor-mce - ge-standard - ge-engineer - ge-shopfloor-lockdown - ge-shopfloor-mce 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: "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 - 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 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: "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 [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 - 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: "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: "Daily cron to refresh Media.tag (PESetup.exe 30-day expiry check)" copy: content: "0 0 * * * root find {{ samba_share }}/*/Deploy/Control -name Media.tag -exec touch {} +\n" 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 - name: "Create TFTP blancco directory for GRUB boot" file: path: "{{ tftp_dir }}/blancco" state: directory mode: '0755' - name: "Symlink Blancco boot files to TFTP (GRUB loads via TFTP)" file: src: "{{ web_root }}/blancco/{{ item }}" dest: "{{ tftp_dir }}/blancco/{{ item }}" state: link force: yes loop: - vmlinuz-bde-linux - intel-ucode.img - amd-ucode.img - config.img - initramfs-bde-linux.img - 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" cron: name: "Restart dnsmasq after reboot" user: root special_time: "reboot" job: "/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=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