Improving iPXE configuration
This post is about migrating my iPXE configuration from SaltStack to Ansible, combining a number of others. Most recently automated Debian install, which I had not realised touched on picking up on using Ansible to configure iPXE from 13 months ago. The configuration of iPXE using Ansible I last left off in March when I migrated my monitoring configuration to Ansible. This also relates to previous posts I made on targeted PXE booting and remote image fetching.
iPXE
My existing SaltStack configuration consists of pillar data for each boot option which is then used to build the iPXE configuration file. I will replicate this with a pxe_options
variable but this time I will separate Linux installers from utilities and sort each list into alphabetical order by displayed name. I also intend to make the choice of uefi/bios version of memtest+ automatic (rather than presenting both options) based on which version of iPXE was loaded and automatic loading of host/platform specific sub-options, which I intend to include an option to select the “normal” menu too.
---
pxe_options:
- name: debian_11
display: Debian 11 (Bullseye) installer
menu_class: linux_installer
set:
release: bullsye
goto: debian_installer
- name: debian_11_rescue
display: Debian 11 (Bullseye) installer (rescue)
menu_class: linux_installer
set:
release: bullseye
extra_kernel_options: rescue/enable=true
goto: debian_installer
- name: debian_11_preseed
display: Debian 11 (Bullseye) installer (preseeded)
menu_class: linux_installer
set:
release: bullseye
# Full hostname required for url as the Debian installer might not detect the correct domain name.
extra_kernel_options: netcfg/get_hostname=unconfigured-hostname${sp}netcfg/get_domain=unconfigured-domain${sp}auto-install/enable=true${sp}preseed/url=debian-installer.{{ ansible_facts.domain }}
goto: debian_installer
- name: debian_12
display: Debian 12 (Bookworm) installer
menu_class: linux_installer
set:
release: bookworm
goto: debian_installer
- name: debian_12_rescue
display: Debian 12 (Bookworm) installer (rescue)
menu_class: linux_installer
set:
release: bookworm
extra_kernel_options: rescue/enable=true
goto: debian_installer
- name: debian_12_preseed
display: Debian 12 (Bookworm) installer (preseeded)
menu_class: linux_installer
set:
release: bookworm
# Full hostname required for url as the Debian installer might not detect the correct domain name.
extra_kernel_options: netcfg/get_hostname=unconfigured-hostname${sp}netcfg/get_domain=unconfigured-domain${sp}auto-install/enable=true${sp}preseed/url=debian-installer.{{ ansible_facts.domain }}
goto: debian_installer
- name: debian_installer
initrd: "{{ local_mirror.debian | default('http://deb.debian.org/debian') }}/dists/${release}/main/installer-${deb_arch}/current/images/netboot/debian-installer/${deb_arch}/initrd.gz"
chain: "{{ local_mirror.debian | default('http://deb.debian.org/debian') }}/dists/${release}/main/installer-${deb_arch}/current/images/netboot/debian-installer/${deb_arch}/linux initrd=initrd.gz ${extra_kernel_options}"
- name: memtest
display: Memtest86+
menu_class: utility
required_packages:
- memtest86+
required_symlinks:
memtest.pcbios: /boot/memtest86+x64.bin
memtest.efi: /boot/memtest86+x64.efi
chain: memtest.${platform}
...
I created a pxe
role whose main.yaml
entry point deploys the main configuration, installs any required packages and creates required symlinks in the tftp_root
directory. It’s argument specification looks like this:
---
argument_specs:
main:
tftp_root:
description: Root of the tftp server, all files will be created here.
type: str
default: /srv/tftp
pxe_options:
description: PXE boot options
type: list
required: true
elements: dict
options:
name:
description: Name of the option for label in the iPXE configuration file
required: true
type: str
display_name:
description: Display name of the option for the menu (will not be displayed if absent)
type: str
menu_class:
short_description: Menu class the option will be displayed under (will not be displayed if absent).
description: |
Menu class (which list) the option will be displayed under (will not be displayed if absent).
One of:
* linux_installer
* utility.
type: str
choices:
- linux_installer
- utility
set:
description: Dictionary of variables to set
type: dict
goto:
short_description: Which item to goto after doing everything else.
description: >-
Which item to goto after doing everything else. Note that
options which launch another boot loader are unlikely to
reach this statement.
default: '${menu}'
initrd:
description: Location of the initial ramdisk (mostly used by Linuxes)
chain:
description: Load another bootloader - for Linuxes this can be a kernel image (which can be booted directly).
required_packages:
description: List of additional packages to install on the PXE server.
type: list
elements: str
required_symlinks:
short_description: 'Dictionary of symlinks to create in the form `link: target`'
description: >-
Dictionary of symlinks to create in the form `link: target`.
Relative link names will be created relative to the tftp root directory
type: dict
...
defaults/main.yaml
is very simple:
---
tftp_root: /srv/tftp
...
The main.yaml
tasks file looks like:
---
- name: iPXE is installed
become: true
ansible.builtin.package:
name: ipxe
state: present
- name: iPXE bootloader exists in tftp root
become: true
# tftp-hpa runs chrooted, so doesn't support out-of-tftp-root symlinks
# dnsmasq's tftp server does
#ansible.builtin.file:
ansible.builtin.copy:
#path: '{{ tftp_root }}/{{ item }}'
dest: '{{ tftp_root }}/{{ item }}'
mode: 00444
src: /usr/lib/ipxe/{{ item }}
#state: link
remote_src: true
owner: root
loop:
- ipxe.efi
- snponly.efi
- undionly.kpxe
- name: Packages for iPXE configuration are installed
become: true
ansible.builtin.package:
name: "{{ pxe_options | selectattr('required_packages', 'defined') | map(attribute='required_packages') | flatten | unique }}"
state: present
- name: Symlinks for iPXE configuration are present
become: true
# tftp-hpa runs chrooted, so doesn't support out-of-tftp-root symlinks
# dnsmasq's tftp server does
#ansible.builtin.file:
ansible.builtin.copy:
#path: '{{ tftp_root }}/{{ item.key }}'
dest: '{{ tftp_root }}/{{ item.key }}'
mode: 00444
src: '{{ item.value }}'
#state: link
remote_src: true
owner: root
loop: "{{ pxe_options | selectattr('required_symlinks', 'defined') | map(attribute='required_symlinks') | map('dict2items') | flatten }}"
- name: iPXE configuration file
become: true
ansible.builtin.template:
owner: root
src: ipxe_main.cfg.j2
mode: 00444
dest: '{{ tftp_root }}/ipxe.cfg'
...
The main iPXE configuration template, ipxe_main.cfg.j2
:
#!ipxe
# Detect CPU architecture
cpuid --ext 29 && set arch x86_64 || set arch x86
# For Debian - who refers to x86_64 as amd64
cpuid --ext 29 && set deb_arch amd64 || set deb_arch i386
# See https://forum.ipxe.org/showthread.php?tid=7960 for how to add spaces to variables...
set sp:hex 20 && set sp ${sp:string}
set menu main_menu
# Allow overrides before displaying the default menu
chain serial-${serial}.ipxe ||
chain mac-${mac:hexhyp}.ipxe ||
chain host-${hostmame}.ipxe ||
chain product-${product}.ipxe ||
goto ${menu}
:main_menu
menu Please choose a boot option
item --gap Linux installers:
{% for item in pxe_options | selectattr('menu_class', 'defined') | selectattr('menu_class', 'equalto', 'linux_installer') | sort(attribute='display') %}
item itm_{{ item.name }} {{ item.display }}
{% endfor %}
item --gap Utilities:
{% for item in pxe_options | selectattr('menu_class', 'defined') | selectattr('menu_class', 'equalto', 'utility') | sort(attribute='display') %}
item itm_{{ item.name }} {{ item.display }}
{% endfor %}
item --gap iPXE options:
item shell Start iPXE shell
item exit Exit and try next boot device
choose --default exit option && goto ${option}
goto ${menu}
:shell
shell
goto ${menu}
:exit
exit 1
{# Process all of the options #}
{% for item in pxe_options +%}
:itm_{{ item.name }}
{% for (set_item, value) in (item.set | default({})).items() %}
set {{ set_item }} {{ value }}
{% endfor %}
{% if item.initrd | default(false) %}
initrd {{ item.initrd }}
{% endif %}
{% if item.chain | default(false) %}
chain {{ item.chain }}
{% endif %}
goto {% if item.goto | default(false) %}{% if pxe_options | selectattr('name', 'equalto', item.goto) %}itm_{% endif %}{{ item.goto }}{% else %}${menu}{% endif %}
{% endfor %}
I did have to do some creative poking of the inventory to not apply everything (e.g. firewall) to the server in the live network currently hosting the tftp service, as this is still a SaltStack managed system hosting a lot of services I’ve not setup in Ansible yet.
Debian installer preseed
I migrated the Debian preseed source configuration into being managed by Ansible. This is essentially a set of static files served up by a webserver, albeit with some templating expanding on my previous automating Debian install work. I also modified the old recipe to create a dummy LVM logical volume and a post-install script to delete it so as to leave some unallocated space in the volume group (which the Debian installer will not do - it always expends the logical volumes to fully consume the volume group, even in violation of maximum sizes specified in the preseed configuration).
A new debian-installer
role was added. Its meta/argument_specs.yaml
looks like:
---
argument_specs:
main:
short_description: Configures a webserver to share pre-seed files for Debian Installer.
description: |
Configures a webserver to share pre-seed files for Debian Installer.
At the time of writing, only x86_64 (amd64 in Debian) is supported - the preseed
is hardcoded to install a 64-bit kernel.
author: Laurence Alexander Hurst
options:
d_i_root:
desciption: Location from which to share `/d-i/` on the webserver
type: str
default: /srv/debian-installed
debian_releases:
short_description: List of Debian releases (codenames) to deploy kickstarts for.
description: |
List of Debian releases (codenames) to deploy kickstarts for.
Note that the default for this option may change between role
releases to remain current with Debian old-stable/stable releases.
type: list
elements: str
default:
- bullseye
- bookworm
initial_crypted_root_password:
description: Encrypted root password (suitable to be passed to preseed's `passwd/root-password-crypted` option).
type: str
required: true
initial_root_ssh_keys:
description: Initial ssh keys to allow root access on initial boot.
type: str
required: true
...
The defaults/main.yaml
defaults to old-stable and stable and a sensible (/srv
based) location for the web root:
---
d_i_root: /srv/debian-installer
debian_releases:
- bullseye
- bookworm
...
The main tasks (tasks/main.yaml
) is concerned with creating the files and ensuring they are served from the right location:
---
- name: Debian installer folder exists
become: true
ansible.builtin.file:
path: '{{ d_i_root }}'
owner: root
group: root
mode: 00755
state: directory
- name: debian-installer site exists
ansible.builtin.include_role:
name: webserver
tasks_from: add_site
vars:
site_name: debian-installer
server_name: debian-installer.{{ ansible_facts.domain }}
nginx:
locations:
- location: /d-i/
configuration: |
alias {{ d_i_root }}/;
autoindex on; # Allow directory listings
- name: Releases directories exist
become: True
ansible.builtin.file:
path: '{{ d_i_root }}/{{ release }}'
owner: root # XXX would www-data be a) better and b) mean become_user: www-data would work (least privilege?)
state: directory
loop: '{{ debian_releases }}'
vars:
release: '{{ item }}'
- name: preseed scripts exist (static)
become: true
ansible.builtin.copy:
dest: '{{ d_i_root }}/{{ release }}/{{ file }}'
owner: root # XXX would www-data be a) better and b) mean become_user: www-data would work (least privilege?)
src: '{{ file }}'
mode: 00444
loop: >-
{{
[
'preseed-script-headers',
'preseed-crypto-key',
'preseed-remove-dummy-lv'
]
| product(debian_releases)
}}
vars:
file: '{{ item[0] }}'
release: '{{ item[1] }}'
- name: preseed scripts exist (dynamic)
become: true
ansible.builtin.template:
dest: '{{ d_i_root }}/{{ release }}/{{ file }}'
owner: root # XXX would www-data be a) better and b) mean become_user: www-data would work (least privilege?)
src: '{{ file }}.j2'
mode: 00444
loop: >-
{{
[
'preseed-ssh-setup',
]
| product(debian_releases)
}}
vars:
file: '{{ item[0] }}'
release: '{{ item[1] }}'
- name: preseed configurations exists
become: true
ansible.builtin.template:
dest: '{{ d_i_root }}/{{ release }}/preseed.cfg'
owner: root # XXX would www-data be a) better and b) mean become_user: www-data would work (least privilege?)
src: preseed.cfg.j2
mode: 00444
loop: '{{ debian_releases }}'
vars:
release: '{{ item }}'
...
The templated files are the preseed configuration itself and the ssh key deployment script. This violates my original intent of having a static base install and doing customisation post-install, but in order to do that we need to be able to login which means having known initial credentials and those need to be stored securely (i.e. not committed to revision control!). This does maintain a single source that has these initial, shared, credentials and the per-host configuration is still done post-install. “Why not just take this a step further and do per-host preseed to cut out a step?” is a good question and I struggle with whether to do this or not.
Firstly the preseed configuration, templates/preseed.cfg.j2
:
# Localisation
d-i debian-installer/locale string en_GB.UTF-8
d-i keyboard-configuration/xkb-keymap select gb
# Time
d-i time/zone select Europe/London
# HW clock set to UTC?
d-i clock-setup/utc boolean true
# Use NTP during install
clock-setup clock-setup/ntp boolean true
clock-setup clock-setup/ntp-server string ntp
# Network
#d-i netcfg/get_domain string dev.lahtest.local
#d-i netcfg/get_hostname string unassigned-hostname
# Mirror
#d-i mirror/country string GB
#d-i mirror/http/mirror select ftp.uk.debian.org
d-i mirror/country string manual
d-i mirror/protocol string http
d-i mirror/http/hostname string {{ local_mirror.debian | default('http://deb.debian.org/debian') | urlsplit('hostname') }}
d-i mirror/http/directory string {{ local_mirror.debian | default('http://deb.debian.org/debian') | urlsplit('path') }}
d-i mirror/http/proxy string
# Only enable main
d-i apt-setup/non-free boolean false
d-i apt-setup/non-free-firmware boolean false
d-i apt-setup/contrib boolean false
d-i apt-setup/services-select multiselect security, updates
d-i apt-setup/security_host string {{ local_mirror['debian-security'] | default('http://security.debian.org/debian-security') | urlsplit('hostname') }}
d-i apt-setup/security_path string {{ local_mirror['debian-security'] | default('http://security.debian.org/debian-security') | urlsplit('path') }}
# Users
# Skip creation of a normal user account
d-i passwd/make-user boolean false
# Vault stored initial root password
{# XXX fetch this from vault or an argument (populated from vault) #}
d-i passwd/root-password-crypted password {{ initial_crypted_root_password }}
# Partitioning
d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman/choose_partition select finish
d-i partman/confirm boolean true
d-i partman/confirm_nooverwrite boolean true
# Skip doing a full erase on underlying crypto volumes
d-i partman-auto-crypto/erase_disks boolean false
d-i partman-auto/method string crypto
d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman-auto-lvm/guided_size string max
d-i partman-lvm/confirm boolean true
d-i partman-lvm/confirm_nooverwrite boolean true
d-i partman-auto/expert_recipe string \
lah_local_scheme :: \
500 500 500 free \
$iflabel{ gpt } \
$reusmethod{ } \
method{ efi } \
format{ } . \
500 500 500 ext4 \
$defaultignore{ } \
method{ format } \
format{ } \
use_filesystem{ } \
filesystem{ ext4 } \
mountpoint{ /boot } . \
4000 4000 4000 linux-swap \
$lvmok{ } \
$reusemethod{ } \
method{ swap } \
lv_name{ swap } \
format{ } . \
4000 4000 4000 ext4 \
$lvmok{ } \
method{ format } \
format{ } \
use_filesystem{ } \
filesystem{ ext4 } \
mountpoint{ / } . \
1000 1000 1000 ext4 \
$lvmok{ } \
method{ format } \
format{ } \
use_filesystem{ } \
filesystem{ ext4 } \
mountpoint{ /home } . \
1000 1000 1000 ext4 \
$lvmok{ } \
method{ format } \
format{ } \
use_filesystem{ } \
filesystem{ ext4 } \
mountpoint{ /opt } . \
1000 1000 1000 ext4 \
$lvmok{ } \
method{ format } \
format{ } \
use_filesystem{ } \
filesystem{ ext4 } \
mountpoint{ /srv } . \
4000 4000 4000 ext4 \
$lvmok{ } \
method{ format } \
format{ } \
use_filesystem{ } \
filesystem{ ext4 } \
mountpoint{ /var } . \
4000 4000 4000 ext4 \
$lvmok{ } \
method{ format } \
format{ } \
use_filesystem{ } \
filesystem{ ext4 } \
mountpoint{ /var/log } . \
1 1 -1 ext4 \
$lvmok{ } \
lv_name{ deleteme } \
method{ keep } .
# Skip (see, e.g., https://serverfault.com/a/1062217):
# No filesystem is specified for partition #1 of LVM VG <hostname>-vg, LV deleteme.
# If you do not go back to the partitioning menu and assign a file system to this partition, it won't be used at all.
d-i partman-basicmethods/method_only false
d-i preseed/include_command string \
for file in \
preseed-script-headers \
preseed-crypto-key \
preseed-ssh-setup \
preseed-remove-dummy-lv \
; do \
wget -P /tmp $( dirname $( debconf-get preseed/url ) )/$file && \
chmod 500 /tmp/$file && \
/tmp/$file; \
done;
d-i preseed/early_command string \
if [ -f /tmp/early-command-script ]; \
then \
/bin/sh /tmp/early-command-script; \
fi;
d-i preseed/late_command string \
if [ -f /tmp/late-command-script ]; \
then \
/bin/sh /tmp/late-command-script; \
fi;
# Base system install
d-i base-installer/kernel/image select linux-image-amd64
# Package selection
# No 'task' selections
tasksel tasksel/first multiselect
# Don't partake in the popularity contest
popularity-contest popularity-contest/participate boolean false
# Bootloader
# This is fairly safe to set, it makes grub install automatically to the MBR
# if no other operating system is detected on the machine.
d-i grub-installer/only_debian boolean true
# Avoid that last message about the install being complete.
d-i finish-install/reboot_in_progress note
...
The ssh key script, templates/preseed-ssh-setup.j2
:
#!/bin/sh
# Install and allow remote-root SSH for post-install config
cat - >>/tmp/late-command-script <<EOF
## BEGIN ADDED BY preseed-ssh-setup preseed/include_command
in-target apt-get install -y openssh-server
# Setup initial root ssh keys
mkdir -m 700 /target/root/.ssh
umask 0077
cat - >/target/root/.ssh/authorized_keys <<KEYS_EOF
{{ initial_root_ssh_keys }}
KEYS_EOF
## END ADDED BY preseed-ssh-setup preseed/include_command
EOF
The static files, to generate a dynamic key, add headers to the preseed scripts, and remove the dummy logical volume are stright files pushed out.
The headers script, files/preseed-script-headers
:
#!/bin/sh
cat - >/tmp/late-command-script <<EOF
#!/bin/sh
# Late command script
# Header generated by script-headers preseed/include_command
EOF
cat - >/tmp/early-command-script <<EOF
#!/bin/sh
# Early command script
# Header generated by script-headers preseed/include_command
EOF
The initial random crypt key setup:
#!/bin/sh
# ^^ no bash in installer environment, only BusyBox
# I couldn't get this to work with using debconf-set to set
# the passphrase (so this has to be run via
# `preseed/include_command`)
# Die on error
set -e
# Currently (2023-08-10) the Debian installer only supports
# passphrases, GnuPG key files or random key files which are
# regenerated on each reboot. So, to use a random keyfile, we
# initially have to provide the installer with a passphrase.
# Create a passphrase by pulling only characters in the range
# `!` to `~` (ASCII 0x21 to 0x7e) from /dev/random.
TMP_PASSPHRASE_FILE=$( mktemp )
umask 0077
grep -o '[!-~]' /dev/random | tr -d '\n' | head -c64 > $TMP_PASSPHRASE_FILE
# Create an include file for debian-installer with the passphrase as answers to the questions
DEB_INSTALLER_CRYPT_INC_FILE=$( mktemp )
echo -n "d-i partman-crypto/passphrase string " | \
cat - $TMP_PASSPHRASE_FILE > $DEB_INSTALLER_CRYPT_INC_FILE
# Need a newline between the entries
echo >>$DEB_INSTALLER_CRYPT_INC_FILE
echo -n "d-i partman-crypto/passphrase-again string " | \
cat - $TMP_PASSPHRASE_FILE >>$DEB_INSTALLER_CRYPT_INC_FILE
# Newline probably not needed at end of file, but it is neat to add it.
echo >>$DEB_INSTALLER_CRYPT_INC_FILE
# Echo the file to be included, so debian-installer will do
# that - assuming this command is being run via
# `preseed/include_command`. Without file:// will try and fetch
# from the webserver this preseed was served from.
echo "file://$DEB_INSTALLER_CRYPT_INC_FILE"
# Add extra commands to the file that should be run using
# `preseed/late_command` to ensure passphrase is included in
# new install (or the encryption will be un-unlockable as the
# random passphrase will be lost when the installer reboots).
IN_TARGET_KEY_FILE=/etc/keys/luks-lvm.key
cat - >>/tmp/late-command-script <<LATE_EOF
## BEGIN ADDED BY preseed-crypto-key preseed/include_command
umask 0077
mkdir -p /target$( dirname ${IN_TARGET_KEY_FILE} )
cp $TMP_PASSPHRASE_FILE /target$IN_TARGET_KEY_FILE
# Use /root as /tmp might be noexec
cat - >/target/root/configure-crypt-unlock <<EOF
#!/usr/bin/bash
# Standard bash safety features
set -eufo pipefail
if grep -q UMASK /etc/initramfs-tools/initramfs.conf
then
sed -i 's-^#\?UMASK.*\\\$-UMASK=0077-' /etc/initramfs-tools/initramfs.conf
else
echo -e "# Secure initramfs while it contains unlock keys for root filesystem\nUMASK=0077" >>/etc/initramfs-tools/initramfs.conf
fi
# Include keyfile in initramfs
sed -i 's-^#\?KEYFILE_PATTERN=.*\\\$-KEYFILE_PATTERN=$( dirname ${IN_TARGET_KEY_FILE} )/*.key-' /etc/cryptsetup-initramfs/conf-hook
# Configure crypt to use keyfile to unlock encypted partition(s)
sed -i 's#\(UUID=[^ ]\+\) none#\1 ${IN_TARGET_KEY_FILE}#' /etc/crypttab
# Updater initramfs with key file
update-initramfs -u
EOF
chmod 500 /target/root/configure-crypt-unlock
in-target /root/configure-crypt-unlock
rm /target/root/configure-crypt-unlock
## END ADDED BY preseed-crypto-key preseed/include_command
LATE_EOF
Finally, the script to remove the dummy logical volume:
#!/bin/sh
# ^^ no bash in installer environment, only BusyBox
# Die on error
set -e
# Add extra commands to the file that should be run using
# `preseed/late_command` to remove dummy LVM partition.
cat - >>/tmp/late-command-script <<EOF
## BEGIN ADDED BY preseed-remove-dummy-lv preseed/include_command
lvchange -a n $( hostname -s )-vg/deleteme
lvremove $( hostname -s )-vg/deleteme
## END ADDED BY preseed-remove-dummy-lv preseed/include_command
EOF
Finishing up
I added values to read the security configuration from my hashicorp vault, in group_vars/debian_installer_sources.yaml
, the recipe for ansble.builtin.password_hash
is stright from the documentation and makes the hash generation idempotent but using a unique seed for each host:
---
initial_crypted_root_password: "{{ lookup('community.hashi_vault.vault_read', 'kv/install/initial_root_password').data.password | ansible.builtin.password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) }}"
initial_root_ssh_keys: "{{ lookup('community.hashi_vault.vault_read', 'kv/install/initial_root_keys').data.ssh_keys }}"
...
For ansible.builtin.password_hash
, I need to add passlib
to requirements.txt
:
# For encryption (e.g. ansible.builtin.password_hash)
passlib
I added both (iPXE Servers and Debian Installer Sources) groups to inventory.yaml
:
ipxe_servers:
hosts:
starfleet-command:
debian_installer_sources:
hosts:
starfleet-command:
And specified the roles should be applied to these groups in site.yaml
:
- hosts: ipxe_servers
roles:
# Uses pxe_options from ipxe_servers group vars
- pxe
- hosts: debian_installer_sources
roles:
# Uses these variable from from debian_installer_source group vars:
# - initial_crypted_root_password
# - initial_root_ssh_keys
- debian-installer