This is another of those posts that I started and some time (measured in months) later I split up due to not having completed what I set out to do. My overall goal is to have a fully automated install that results in a system with disk encryption setup up but can be remotely unlocked and managed. The intention is that, in most circumstances, any of my systems could be reinstalled headlessly (that is, without plugging in a keyboard or monitor).

This does have various similarities to previous preseed work and pxe work I have done.

Preparation

I was somewhat surprised (and annoyed by the download time) to find that with the Bookworm (12) release of Debian, the net-install image has almost doubled in size to 738MB from Bullseye (11) release’s 389MB. Bullseye’s was a more modest increase from Buster (10) release’s 337MB and that was a similar jump from Stretch (9) releases’s 292MB. The “mini” iso image has steadily increased in size to 62MB, from 51MB, 48MB, 39MB for Bookworm, Bullseye, Buster and Stretch releases respectively.

In VirtualBox (which I was planning to use to accelerate working on this, rather than swapping keyboard and monitor between physical machines constantly), I was unable to get the Bookworm installer to load, with either the net-install or mini CD images - I got the boot loader menu, a couple of kernel messages then a grey screen with no text at all on screen. I found a bug on Debian which has a workaround: “Disabling paravirtualization in virtualbox acceleration or switching it to legacy results in a properly booting installer.”

With a working installer, I did a minimal install on the VM that will be my core system for this work. After installing, I set the VM’s paravirtualisation back to Default and it booted fine. I installed Ansible to do the rest of the configuration. For simplicity’s sake, I set the core to proxy to Debian’s mirrors as this reflects the setup in my air gapped lab which has a local mirror.

Networking

With ansible, I configured a second interface on an internal-only network (the primary interface is NAT through the host) with /etc/network/interfaces.d/enp0s8:

auto enp0s8
iface enp0s8 inet static
  address 192.168.10.250/24

I also installed some extra packages:

  • git
  • man
  • tmux
  • vim

Traditionally I have always used ISC’s DHCP server, although I had just discovered that went end of life at the end of 2022, for DHCP and bind for DNS. For ease I decided to install Dnsmasq, which provides both DHCP and DNS (and TFTP and PXE) services and I have previously used it to provide client-side split DNS.

Dnsmasq required relatively little configuration changes:

interface=enp0s8
bind-interfaces # Do not listen on any other interfaces
domain=dev.internal.lah
# Will not respond to DHCP broadcast requests without this
dhcp-authoritative
dhcp-range=192.168.10.100,192.168.10.200,12h

Mirror (webserver)

For the mirror and hosting various configuration files (which I will come on to), I installed nginx. I replaced the default site file with this configuration:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    root /srv/mirror;
    index index.html index.htm;
    server_name _;
    location / {
        autoindex on; # Enable showing index of directories
        try_files $uri $uri/ =404;
    }
    location /debian/ {
        proxy_pass http://deb.debian.org/debian/;
    }
    location /debian-security/ {
        proxy_pass http://security.debian.org/debian-security/;
    }
}

For the physical environment, the only changes were that a USB hard disk was mounted at /media/usb-disk and bind-mounted to /srv/mirror. The proxying was removed from nginx by simply deleting the location /debian/ and location /debian-security/ blocks. This resulted in zero changes from the client perspective regardless of environment.

In both environments, the preseed.cfg file for the automated install was placed in the web root, /srv/mirror, to be served up by the webserver.

NTP

By default, the Bookworm installer tries to sync the clock via NTP. This fails in a network with no external connectivity but takes a long time to timeout (in the sense that it was longer than I had the patience to wait for it to time out - the progress bar loops, so it it doesn’t time out when it gets to the end of that). However, having clocks in sync (at least with each other) is useful so I installed chrony and configured it to act as an NTP server for the internal network:

allow 192.168.10.250/24
bindaddress 192.168.10.250

I added ntp aliases to /etc/hosts for this system’s IP:

192.168.10.250 ntp.dev.internal.lah ntp

Then I restarted dnsmasq, which made ntp appear in dnsmasq’s DNS.

Automating the Debian install

Lately, my approach to automated installs (of Debian or Red Hat based systems) has been to do a minimal install then hand over to the configuration management tool to do the full installation and configuration of any additional software - this is the same tool that will then be used to keep those things in sync and prevent configuration drift. Until recently this has been SaltStack, so my existing preseed installed the salt-minion package which then allowed Salt to take over managing the system and do everything else to configure/customise it. However, I am now leaning towards Ansible as my tool of choice for this which does not require a client-side agent (one of its attractions).

My original recipe was to install Ansible into the newly installed OS, fetch a “bootstrap” playbook that then configured the newly installed OS - running it from within the installer. I was hesitant about this approach, as Ansible is not needed on the machines it manages but this method meant the system came up already configured and secured from the first boot. I abandoned it after encountering issues getting Ansible to install packages within the target chroot due to the “CDROM” source entries for the USB media I was using (on the physical hosts) - a solvable problem. I’m wan’t sure doctoring the /etc/apt/sources.list at this stage, as those same entries get commented out by the installer before the install is finished, would be advisable. I also considered moving the package installations out of Ansible into the preseed itself, but then I will end up maintaining multiple lists of packages for those installed during install and those managed after install. It began to feel like I was jumping through hoops I did not need to.

Instead, I opted to revert to the method of doing the base install and have the new system booted ready to be managed but not yet “adopted” (i.e. configured). This does create a window of opportunity when the not-yet-configured system is waiting to be configured that it could be easier to compromise but fully automating the reinstall (as I plan to do - more on this later…) will minimise this risk, as does the default setup only allowing key-less login (default setting for the OpenSSH server). This can be further mitigated by doing the install in a dedicate, secured, VLAN and only connecting the host to a network with other systems (and internet access) post install - in a way, this is what I am doing with my VirtualMachines during development as only one VM has access to the live network, the rest are on a private “internal only” network with no network route to the live network.

For posterity, the complete preseed recipe to install Ansible, download a playbook and then run it in the target is this (I think it’s pretty short and neat, so worthy of preservation):

# Install Ansible to be able to run the bootstrap
d-i pkgsel/include string ansible
# No wget inside the target, use the one in the installer environment.
d-i preseed/late_command string \
  wget -P /target/tmp http://debian-installer/bootstrap-playbook.yaml && \
  in-target ansible-playbook /tmp/bootstrap-playbook.yaml;

Initial preseed settings

I added debian-installer as an aliases to /etc/hosts for this system’s IP, before restarting dnsmasq, which made debian-installer appear in dnsmasq’s DNS.

192.168.10.250 debian-installer.dev.internal.lah debian-installer

In /srv/mirror/d-i/bookworm I created preseed.cfg, starting with the general settings:

# 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

# Mirror
d-i mirror/country string manual
d-i mirror/protocol string http
d-i mirror/http/hostname string 192.168.10.250
d-i mirror/http/directory string /debian
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 192.168.10.250

# Users
# Skip creation of a normal user account
d-i passwd/make-user boolean false
# Obviously this has an actual password hash here, in the real config
d-i passwd/root-password-crypted password some_crypted_password

# 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

I did setup the network console in the preseed in the hope that would allow remote progress monitoring but unfortunately it did not easily, so I removed it again. This does have the consequence that SSH will not start listening until after the OS has been fully installed and the device rebooted, making automation a little easier.

Stacking late-command

As I worked through this, I discovered that it was neater to build the preseed/late_command script at various points - for example, so that the encryption script could add the commands to inject the key it generates - rather than disconnect related bits from a single script. To do this, I added a simple check to see if /tmp/late-command-script exists and, if it does, run it as the late command:

d-i preseed/late_command string       \
  if [ -f /tmp/late-command-script ]; \
  then                                \
    /bin/sh /tmp/late-command-script; \
  fi;

The script file is started by a script that is fetched from the same location as the preseed file and run through preseed/include_command (one might be tempted to try using preseed/early_command, which is “run as early as possible, just after preseeding is read” but that command runs after the include commands, so after other content might have been added by preseed/include_command):

d-i preseed/include_command string                                  \
  for file in preseed-script-headers;                               \
  do                                                                \
    wget -P /tmp $( dirname $( debconf-get preseed/url ) )/$file && \
    chmod 500 /tmp/$file &&                                         \
    /tmp/$file;                                                     \
  done;

Note that this is preseed/include_command command is designed for more, modular, include_command scripts to be included.

Although I don’t need it immediately, I also added support for the preseed/early_command to be built up in the same way:

d-i preseed/early_command string       \
  if [ -f /tmp/early-command-script ]; \
  then                                 \
    /bin/sh /tmp/early-command-script; \
  fi;

The preseed-script-headers script simply starts these script files:

#!/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

Partitioning

It has long been my custom to split the filesystem amongst several partitions on my Linux installs, including a separate /usr which has caused some issues upgrading systems with the unification of / and /usr:

In February 2021, the Technical Committee has resolved that Debian ‘bookworm’ should support only the merged-usr root filesystem layout, dropping support for the non-merged-usr layout. (978636)

When it came to the sizes of partitions, the sizes I selected always “felt” right (and I have almost never had to add more space to one, the exception being my mail server when the mail store grew beyond the originally allocated space). I have listed a number of layouts in previous blog posts, e.g. my home lab router, my laptop, test Gentoo install and my HP Microservers.

Since it is possible to do an online resize of ext4, my plan is to create a sensible partitioning scheme for a generic Debian base install which can be expanded post install if needed. I did consider doing a per-host preseed configuration with a specific partition scheme based on expected usage but keeping all the per-host customisation in Ansible seemed like it might be cleaner (even if such per-host preseed files might be generated on the fly by Ansible during a (re)install playbook run). This is one of those decisions I think I may revisit in the future as I am not convinced either approach is clearly better.

In order to try and come up with an evidence-based scheme for the generic, base, setup I looked at my existing systems to guage what I typically use currently. From that information, I plan to infer appropriate sizes for the partitions. I looked at a total of 9 systems, 2 of which had desktop environments installed and the rest were “servers” with no gui installed. I used a combination of df and du to work out the sizes of various directories, as these systems are not all partitioned the same (in the case of my VMs, there were all single-root filesystems). From these systems, I determined the following usage (“max” indicates the most space consumed across these systems, “min” the least) after adjusting to consider these as though they were separate partitions:

  • swap - max: 4.6G, min: 0B (Only one system, my HP Microserver acting as physical host for my VMs, using using more than 623M and this system will be retired once I have a Proxmox cluster up and running in my live network)
  • / - (desktop) max: 6.3G, min: 2.7G / (server) max: 2.8G, min: 2.1G
  • /boot - max: 125M, min: 56M
  • /boot/efi - max: 38M, min: 6M (only one system, my laptop, out of 5 EFI systems has >6M consumed - suspect due to Windows dual boot)
  • /boot/firmware - 128M (only my 1 Raspberry Pi has this directory)
  • /home - (desktop) max: 41G, min: 1.2G / (server) max: 15G, min: 232K (only one “server” system has >825M consumed as it has a copy of my 14G sync-n-share folder for backing up purposes)
  • /opt - max: 11M, min: 4K (only 2 systems have >1M consumed)
  • /srv - max: 41G, min: 4K (I ignored my laptop, which has a number of mirrors locally consuming 432G. Only my mail-server has >581M consumed)
  • /tmp - max: 716K, min: 48K
  • /var - max: 3.2G, min: 384M
  • /var/lib - max: 3.3G, min:170M
  • /var/lib/docker - max: 4G, min: 1.4G (only 3 systems have this directory)
  • /var/lib/libvirt - 56G (only my HP Microserver acting as physical host for my VMs has this directory)
  • /var/log - max: 3.1G, min: 723M

Based on these values, I settled on this as a starting partitioning scheme for my installs:

  • swap - 4G
  • / - 4G
  • /boot - 500M
  • /boot/efi - 500M
  • /home - 1G
  • /opt - 1G
  • /srv - 1G
  • /tmp - tmpfs (no longer persistent partition)
  • /var - 4G
  • /var/log - 4G

Post install, Ansible will make the following changes:

  • On desktop systems, / will be increased to 8G and /home to 20G
  • On systems with docker, /var/lib/docker will be created with 10G
  • /srv will be increased on systems that need it (on the server that syncs my sync-n-share locally for backing up, this folder will be moved into /srv)

Documentation for creating preseed partitioning recipes is a bit disjointed. The main documentation suggests one reads the debian-installer source repository which in turn makes reference to partman-auto/recipe-amd64-efi/atomic(“The EFI example above was taken from partman-auto/recipe-amd64-efi/atomic.”) but without explaining how to find this file (Google was how I did). In the end, the Debian installer recipe I created for this is:

d-i partman-auto/method string crypto
d-i partman-partitioning/confirm_write_new_label boolean true
# Skip doing a full erase on underlying crypto volumes
d-i partman-auto-crypto/erase_disks boolean false
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 } .

Encryption

As you can see, I selected the crypto recipe for this. In order to setup the encryption, the preseed file needs to supply a passphrase for the encryption. The documentation only refers to either passphrase or random key, the latter regenerated on each boot so the previous filesystem’s contents are irretrievable - intended for swap partitions - although there is some reference in the source code to a key file, this seems intended for GnuPG keys not generic key files and options for configuring it are absent from the reference (full) preseed file.

In the end, I scripted generating a random key locally, early in the install to feed it into the preseed, before injecting it into the installed system (and its initramfs) towards the end of the install so the temporary, random, key will automatically unlock the filesystem on initial boot at which point a post-install configuration tool (although I am using Ansible and migrating away from SaltStack, which tool is used is irrelevant - Puppet or Chef would also work perfectly well for this process) takes over, configures a known key via a secure channel, such as SSH, and removes the random key. In my environment, this known key will be host-specific and manged in HasiCorp Vault.

The Debian installer part of this recipe is pretty simple, it just involves using preseed/include_command to:

  1. Download and run the script that generates the random key
  2. Generate the debian installer options in a file
  3. Printing the file name with the debian installer options (so it gets included)
  4. Adding the commands to inject it into the installed OS to the script that gets run by preseed/late_command.

Downloading the script just involves adding it to the list in my modular preseed/include_command script:

d-i preseed/include_command string                                  \
  for file in                                                       \
    preseed-script-headers                                          \
    preseed-crypto-key                                              \
  ; do                                                              \
    wget -P /tmp $( dirname $( debconf-get preseed/url ) )/$file && \
    chmod 500 /tmp/$file &&                                         \
    /tmp/$file;                                                     \
  done;

The script itself does all of the rest:

#!/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

Installing and configuring SSH server

Although Ansible doesn’t use an agent, it does use SSH to remotely login to manage targeted systems. So, in place of a agent, one does have to install and configure the SSH server service. I did this like with the encryption, using preseed/include_command to add to the preseed/late_command script to do the work. Rather than ask the Debian installer to install the openssh-server package, by emitting a file to be included and printing its name, I did this with apt-get in the target OS via the late command script. There can only be one entry in the debian installer for packages to install, so this way avoid potentially conflicting with another.

To include this script, I just added it to the list:

d-i preseed/include_command string                                  \
  for file in                                                       \
    preseed-script-headers                                          \
    preseed-crypto-key                                              \
    preseed-ssh-setup                                               \
  ; do                                                              \
    wget -P /tmp $( dirname $( debconf-get preseed/url ) )/$file && \
    chmod 500 /tmp/$file &&                                         \
    /tmp/$file;                                                     \
  done;

And the script itself is very simple:

#!/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
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGELav9hG7S1Kohs5QyEsrBIXLbT18tdTZCFg5rUITwxXg1JDKlzuR7v+8zLmbzWCBs0IR8QA9EBw0099h8QW3A= laurence@core
KEYS_EOF

## END ADDED BY preseed-ssh-setup preseed/include_command
EOF

Network (PXE) booting the install

I started by booting from USB (physical machine) or CD (VirtualBox environment) and manually editing the boot options each time. This quickly became tiresome (and in any case, I want to be PXE booting in the live environment). I have set this up numerous times with the ISC DHCP server but this was my first time using dnsmasq (at all). The key parts were enabling TFTP (to allow it to load the boot image) and carefully crafting the options so BIOS devices are given the BIOS version, UEFI devices the UEFI version and iPXE is told where to get its configuration from.

For TFTP, I made a folder /srv/tftp which is shared. I installed the ipxe package (from the Debian repositories) and symlinked ipxe.efi, undionly.kpxe and snponly.efi from /usr/lib/ipxe/ to /tmp/tftp/ipxe/.

I originally tried to use dnsmasq’s built in PXE menu, however it was not working with my UEFI systems so I reverted to directly loading iPXE (without any menu through dnsmasq) and using that to provide the menu. This does have the advantage of making the menu agnostic to the DHCP server solution. I also tried specifying the types by client architecture number (e.g. 7, 9) but did not get it working.

For posterity, this is the non-working configuration for dnsmasq (I get the pxe prompt, just no menu):

# Menu options based on https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2020q1/013669.html
# Do not offer menu to ipxe requests
tag-if=set:skipmenu,tag:ipxe

pxe-prompt=tag:!skipmenu,"Select boot option or wait to boot from local disk...",15

# Common menu
pxe-service=tag:!skipmenu,x86PC,"Boot from local disk"
pxe-service=tag:!skipmenu,X86-64_EFI,"Boot from local disk"

# BIOS menu
pxe-service=tag:!skipmenu,x86PC,"ipxe - kpxe",ipxe/undionly.kpxe

# UEFI menu
pxe-service=tag:!skipmenu,X86-64_EFI,"ipxe - UEFI",ipxe/ipxe.efi

The working dnsmasq configuration is:

enable-tftp
tftp-root=/srv/tftp

# Set the tag "ipxe" if the request has the "iPXE" user class
dhcp-userclass=set:ipxe,iPXE

# Identify VirtualBox VMs (due to buggy Intel 1000 EFI driver)
dhcp-mac=set:virtualbox,08:00:27:*:*:*

# From https://wiki.archilinux.org/title/dnsmasq#PXE_server
dhcp-match=set:efi-x86_64,option:client-arch,7
dhcp-match=set:efi-x86_64,option:client-arch,9
dhcp-match=set:efi-x86,option:client-arch,6
dhcp-match=set:bios,option:client-arch,0

dhcp-boot=tag:efi-x86_64,tag:!virtualbox,ipxe/ipxe.efi
# VirtualBox Intel 1000 EFI driver is buggy, so use
# simple networking version for these hosts. See:
# https://github.com/ipxe/ipxe/issues/977
dhcp-boot=tag:efi-x86_64,tag:virtualbox,ipxe/snponly.efi
dhcp-boot=tag:efi-x86,tag:!virtualbox,ipxe/ipxe.efi
dhcp-boot=tag:efi-x86,tag:virtualbox,ipxe/snponly.efi
dhcp-boot=tag:bios,ipxe/undionly.kpxe

# Tell iPXE where to find its configuration
# Order is important, this must be last to override
# the above options.
dhcp-boot=tag:ipxe,"http://192.168.10.250/ipxe.cfg"

The iPXE configuration file I placed in /srv/mirror as that was already configured as the web root for this test box. For now (while I am testing in VirtualBox) I had this present a menu:

#!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

menu Please choose an operating system to boot
item debian_12 Debian 12 (Bookworm) installer
item debian_12_preseed Debian 12 (Bookworm) installer (preseeded)
item exit      Exit and try next boot device
choose os && goto ${os}

:debian_12
set release bookworm
goto install_debian

:debian_12_preseed
set release bookworm
# See https://forum.ipxe.org/showthread.php?tid=7960 for how to add spaces to variables...
set sp:hex 20 && set sp ${sp:string}
# Full hostname required as the Debian installer might not detect the correct domain name.
set extra_kernel_options netcfg/get_hostname=unconfigured-hostname${sp}netcfg/get_domain=unconfigured-domain${sp}auto-install/enable=true${sp}preseed/url=debian-installer.dev.internal.lah
goto install_debian

:install_debian
initrd http://192.168.10.250/debian/dists/${release}/main/installer-${deb_arch}/current/images/netboot/debian-installer/${deb_arch}/initrd.gz
chain http://192.168.10.250/debian/dists/${release}/main/installer-${deb_arch}/current/images/netboot/debian-installer/${deb_arch}/linux initrd=initrd.gz ${extra_kernel_options}

:exit
exit 1

To be continued…

This will be followed up by another post that picks up from the point of having the Debian OS installed but not configured.