3. Part 1: Lab Setup (Single Version)
This section walks through setting up PXE boot for a single Nodegrid device with a single OS version. By the end, you will be able to power cycle the device and have it install the OS from the network.
3.1 Install Services
Install the three required services. Commands vary by distribution:
Alpine Linux:
apk add kea-dhcp4 tftp-hpa apache2
Ubuntu / Debian:
apt install kea-dhcp4-server tftpd-hpa apache2
RHEL / Rocky / AlmaLinux:
dnf install kea-dhcp4 tftp-server httpd
Create the TFTP root and Kea log directories if they do not exist:
mkdir -p <TFTP_ROOT>
mkdir -p /var/log/kea
mkdir -p /var/lib/kea
Upload the NetBoot tarball to the server and extract it to a staging area:
mkdir -p /var/staging
tar xzf Nodegrid_NetBoot_v<VERSION>_*.tar.gz -C /var/staging/
Examine the extracted contents. You should see a directory structure similar to:
/var/staging/<extracted-dir>/
├── boot/
│ └── grub/
│ ├── grub.cfg
│ ├── grub.cfg.sig
│ ├── fonts/
│ ├── i386-pc/
│ │ ├── core.0 GRUB binary for Legacy BIOS boot
│ │ └── modinfo.sh
│ └── x86_64-efi/
│ └── core.efi GRUB binary for UEFI boot
└── nodegrid-<VERSION>/
├── version
├── vmlinuz
├── vmlinuz.sig
├── initrd
├── initrd.sig
├── rootfs_main.img.gz
└── rootfs_main.img.gz.sig
BIOS vs UEFI: Each tarball ships with both bootloader binaries. i386-pc/core.0 is used for Legacy BIOS devices and x86_64-efi/core.efi is used for UEFI devices. Both share the same grub.cfg, kernel, initrd, and rootfs. The boot mode is selected at provisioning time -- there is no need to choose separate tarballs.
Move the extracted directory to the TFTP root:
mv /var/staging/<extracted-dir> <TFTP_ROOT>/
rm -rf /var/staging
Important: The extracted directory becomes the top level under <TFTP_ROOT>. After this step, the GRUB bootloader should be at <TFTP_ROOT>/boot/grub/i386-pc/core.0 and the version-specific files at <TFTP_ROOT>/nodegrid-<VERSION>/.
Verify the critical files exist:
test -f <TFTP_ROOT>/boot/grub/i386-pc/core.0 && echo "OK: core.0 (BIOS)"
test -f <TFTP_ROOT>/boot/grub/x86_64-efi/core.efi && echo "OK: core.efi (UEFI)"
test -f <TFTP_ROOT>/boot/grub/grub.cfg && echo "OK: grub.cfg found"
test -f <TFTP_ROOT>/nodegrid-<VERSION>/version && echo "OK: version file found"
test -f <TFTP_ROOT>/nodegrid-<VERSION>/vmlinuz && echo "OK: kernel found"
test -f <TFTP_ROOT>/nodegrid-<VERSION>/initrd && echo "OK: initrd found"
test -f <TFTP_ROOT>/nodegrid-<VERSION>/rootfs_main.img.gz && echo "OK: rootfs found"
Configure tftpd-hpa to serve from <TFTP_ROOT> with a chroot (-s flag).
Alpine Linux (/etc/conf.d/in.tftpd):
INTFTPD_OPTS="-s <TFTP_ROOT>"
Ubuntu / Debian (/etc/default/tftpd-hpa):
TFTP_USERNAME="tftp"
TFTP_DIRECTORY="<TFTP_ROOT>"
TFTP_ADDRESS="0.0.0.0:69"
TFTP_OPTIONS="--secure"
RHEL (/etc/sysconfig/tftpd or systemd override):
TFTP_FLAGS="-s <TFTP_ROOT>"
Why -s (secure / chroot)? The TFTP server chroots to <TFTP_ROOT>, so all client-requested paths are relative to this directory. A request for boot/grub/i386-pc/core.0 resolves to <TFTP_ROOT>/boot/grub/i386-pc/core.0. This also means symlinks within the chroot must use relative paths that resolve entirely within <TFTP_ROOT>.
Apache serves the large OS files (kernel, initrd, rootfs) over HTTP. Create symlinks in the HTTP document root that point to the TFTP root:
ln -s <TFTP_ROOT>/boot <HTTP_DOCROOT>/boot
ln -s <TFTP_ROOT>/nodegrid-<VERSION> <HTTP_DOCROOT>/nodegrid-<VERSION>
Ensure Apache has FollowSymLinks enabled for the document root. In your Apache config (e.g., /etc/apache2/httpd.conf or a vhost config):
<Directory "<HTTP_DOCROOT>">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
Why symlinks? The real files live under <TFTP_ROOT> (single source of truth). The HTTP docroot contains only symlinks. This avoids file duplication and ensures both TFTP and HTTP serve the same files.
Verify HTTP will serve the files:
# After starting Apache (step 3.7), test from the server itself:
curl -s -o /dev/null -w "%{http_code}" http://localhost/nodegrid-<VERSION>/version
# Expected: 200
Create the Kea DHCPv4 configuration file at <KEA_CONF>:
{
"Dhcp4": {
"loggers": [
{
"name": "kea-dhcp4",
"output_options": [
{ "output": "<KEA_LOG>" }
],
"severity": "DEBUG",
"debuglevel": 55
}
],
"interfaces-config": {
"interfaces": ["<IFACE>"]
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv"
},
"valid-lifetime": 600,
"renew-timer": 300,
"rebind-timer": 525,
"subnet4": [
{
"id": 1,
"subnet": "<PXE_SUBNET>",
"pools": [
{ "pool": "<POOL_START> - <POOL_END>" }
],
"option-data": [
{ "name": "routers", "data": "<GATEWAY>" },
{ "name": "domain-name-servers", "data": "<DNS_SERVER>" },
{ "name": "domain-name", "data": "<DOMAIN>" }
],
"reservations": [
{
"hw-address": "<DEVICE_MAC>",
"ip-address": "<DEVICE_IP>",
"next-server": "<SERVER_IP>",
"boot-file-name": "boot/grub/i386-pc/core.0"
}
]
}
]
}
}
Key fields in the reservation:
| Field | BIOS Value | UEFI Value | Purpose |
|---|
hw-address | Device's MAC address | (same) | Identifies the device |
ip-address | Static IP for this device | (same) | Assigned via DHCP reservation |
next-server | IP of the TFTP server | (same) | Tells PXE ROM where to download the bootloader |
boot-file-name | boot/grub/i386-pc/core.0 | boot/grub/x86_64-efi/core.efi | Path to the bootloader on TFTP |
Note: boot-file-name is relative to the TFTP root. Do not include a leading /. The TFTP server's chroot makes the path resolve to a file under <TFTP_ROOT>. The boot mode (BIOS vs UEFI) is determined entirely by which bootloader path is specified here.
3.6 Verify the File Layout
Before starting services, verify the complete file layout:
echo "=== TFTP Root ==="
ls -la <TFTP_ROOT>/
ls -la <TFTP_ROOT>/boot/grub/
ls -la <TFTP_ROOT>/boot/grub/i386-pc/core.0
ls -la <TFTP_ROOT>/boot/grub/x86_64-efi/core.efi
ls -la <TFTP_ROOT>/nodegrid-<VERSION>/
echo "=== HTTP Docroot ==="
ls -la <HTTP_DOCROOT>/
# Should show symlinks pointing to <TFTP_ROOT>
echo "=== Kea Config ==="
cat <KEA_CONF> | python3 -m json.tool
# Should parse without errors (valid JSON)
Expected results:
core.0 exists and is ~200KB (BIOS bootloader)
core.efi exists and is ~800-900KB (UEFI bootloader)
grub.cfg exists under boot/grub/
vmlinuz, initrd, rootfs_main.img.gz exist under nodegrid-<VERSION>/
- HTTP docroot symlinks resolve correctly
- Kea config is valid JSON
3.7 Start Services and Test
Start all three services:
Alpine Linux (OpenRC):
rc-service in.tftpd start
rc-service apache2 start
rc-service kea-dhcp4 start
Ubuntu / Debian / RHEL (systemd):
systemctl start tftpd-hpa # or tftp.socket on RHEL
systemctl start apache2 # or httpd on RHEL
systemctl start kea-dhcp4-server
Verify all services are running:
# Check TFTP
echo "get boot/grub/i386-pc/core.0 /dev/null" | tftp localhost
# Expected: no error, or "Received <N> bytes"
# Check HTTP
curl -s -o /dev/null -w "%{http_code}\n" http://localhost/nodegrid-<VERSION>/version
# Expected: 200
# Check Kea
# Alpine: rc-service kea-dhcp4 status
# systemd: systemctl status kea-dhcp4-server
Power cycle the Nodegrid device. Ensure the device's BIOS boot order has PXE/network boot enabled and set as the first boot option.
Monitor the device's serial console. A successful PXE boot sequence shows:
PXE-E53: No boot filename received <-- FAIL: Kea not responding
-- or --
Loading kernel: (http)/nodegrid-<VERSION>/vmlinuz <-- SUCCESS
Loading initrd: (http)/nodegrid-<VERSION>/initrd <-- SUCCESS
Tip: If you have access to the server console, watch the Kea log in real time during the boot attempt:
tail -f <KEA_LOG>
You should see DHCPDISCOVER, DHCPOFFER, DHCPREQUEST, and DHCPACK messages for the device's MAC address.
3.8 Troubleshooting -- Lab
Device does not send DHCP requests
- Verify PXE/network boot is enabled in the device BIOS
- Verify the device and server are on the same Layer 2 network (VLAN)
- Check for firewall rules blocking UDP ports 67/68
DHCP response but no TFTP download
- Verify
next-server in the Kea config matches the server's IP on the PXE subnet
- Verify
boot-file-name path matches the actual file location under <TFTP_ROOT>
- Check firewall rules for UDP port 69
- Test TFTP manually:
echo "get boot/grub/i386-pc/core.0 /tmp/test.0" | tftp <SERVER_IP>
GRUB loads but cannot find grub.cfg
- Verify
<TFTP_ROOT>/boot/grub/grub.cfg exists
- GRUB's hardcoded prefix is
/boot/grub/ -- the file must be at exactly this path relative to the TFTP root
- Check TFTP server logs for file-not-found errors
GRUB loads grub.cfg but HTTP downloads fail
- Verify Apache is running and listening on port 80
- Verify symlinks in
<HTTP_DOCROOT> resolve correctly: ls -la <HTTP_DOCROOT>/
- Verify
FollowSymLinks is enabled in the Apache config
- Test HTTP manually:
curl -I http://<SERVER_IP>/nodegrid-<VERSION>/version
- Check firewall rules for TCP port 80
Version comparison skips install (boots from disk)
- The
grub.cfg compares the network version against the version already installed on the device's disk
- If the versions match, GRUB boots from disk and skips the PXE install
- To install a different version, change the version being served (see Part 2)
- To force reinstall of the same version, the disk's version file must be modified (out of scope for server-side configuration)
4. Part 2: Multi-Version Scalable Setup
This section extends the lab setup to support multiple Nodegrid OS versions and multiple devices, each independently targeted to a specific version.
4.1 Architecture Overview
The multi-version architecture is built on three principles:
- TFTP is the single source of truth. Real files live only under
<TFTP_ROOT>. The HTTP docroot contains only symlinks. No file duplication.
boot-file-name is stable -- written once per device at provisioning time and never modified again. Each device's reservation uses a per-MAC TFTP symlink path (boot/grub/i386-pc/core.0-<mac-dashes>) that always resolves to the currently active version's core.0. Version switching retargets the symlink server-side -- no Kea config edit, no Kea restart required.
- No defaults. A device without an explicit reservation and per-MAC TFTP symlinks cannot PXE boot. This is a deliberate safety measure -- it prevents devices from accidentally booting the wrong version.
4.2 Directory Naming Convention
Each OS version is stored in its own directory under <TFTP_ROOT> using the naming convention ng<MAJOR-MINOR-PATCH>:
| OS Version | Directory Name |
|---|
| 6.0.13 | ng6-0-13 |
| 6.0.36 | ng6-0-36 |
| 5.10.4 | ng5-10-4 |
The conversion rule: replace dots with dashes and prefix with ng.
Each version directory is self-contained with its own GRUB binaries, GRUB config, kernel, initrd, and rootfs:
<TFTP_ROOT>/ng<X-Y-Z>/
├── boot/
│ └── grub/
│ ├── grub.cfg Signed, hardcodes "nodegrid-<VERSION>"
│ ├── grub.cfg.sig Signature file (required for UEFI GRUB)
│ ├── i386-pc/
│ │ └── core.0 GRUB bootloader binary — Legacy BIOS
│ └── x86_64-efi/
│ └── core.efi GRUB bootloader binary — UEFI
└── nodegrid-<VERSION>/
├── version Contains the version string (e.g., "6.0.36")
├── vmlinuz Linux kernel
├── initrd Initial ramdisk
├── rootfs_main.img.gz Root filesystem (~800MB)
└── reformat Clean install trigger (0-byte file, optional)
For each additional version, extract the NetBoot tarball and move it to the TFTP root with the correct directory name:
# Upload and extract to staging
mkdir -p /var/staging
tar xzf Nodegrid_NetBoot_v<VERSION>_*.tar.gz -C /var/staging/
# Move to TFTP root with the ng<X-Y-Z> naming convention
mv /var/staging/<extracted-dir> <TFTP_ROOT>/ng<X-Y-Z>/
rm -rf /var/staging
If migrating from the lab setup (Part 1), rename your existing version directory to follow the convention:
# Example: rename the lab layout to multi-version layout
# First, move the existing files into a version directory
mkdir -p <TFTP_ROOT>/ng<X-Y-Z>
mv <TFTP_ROOT>/boot <TFTP_ROOT>/ng<X-Y-Z>/boot
mv <TFTP_ROOT>/nodegrid-<VERSION> <TFTP_ROOT>/ng<X-Y-Z>/nodegrid-<VERSION>
Verify each version directory has the required files:
for VER_DIR in <TFTP_ROOT>/ng*; do
echo "=== $(basename ${VER_DIR}) ==="
NG_SUB=$(ls -d ${VER_DIR}/nodegrid-* 2>/dev/null | head -1)
test -f "${VER_DIR}/boot/grub/i386-pc/core.0" && echo " OK: core.0 (BIOS)" || echo " MISSING: core.0"
test -f "${VER_DIR}/boot/grub/x86_64-efi/core.efi" && echo " OK: core.efi (UEFI)" || echo " MISSING: core.efi"
test -f "${VER_DIR}/boot/grub/grub.cfg" && echo " OK: grub.cfg" || echo " MISSING: grub.cfg"
test -f "${VER_DIR}/boot/grub/grub.cfg.sig" && echo " OK: grub.cfg.sig" || echo " MISSING: grub.cfg.sig"
test -f "${NG_SUB}/version" && echo " OK: version" || echo " MISSING: version"
test -f "${NG_SUB}/vmlinuz" && echo " OK: vmlinuz" || echo " MISSING: vmlinuz"
test -f "${NG_SUB}/initrd" && echo " OK: initrd" || echo " MISSING: initrd"
test -f "${NG_SUB}/rootfs_main.img.gz" && echo " OK: rootfs" || echo " MISSING: rootfs"
done
4.4 Create Symlinks (TFTP + HTTP)
Two sets of symlinks are required for each version:
TFTP Fallback Symlinks
The grub.cfg probes for files at (tftp)/nodegrid-<VERSION>/version (a flat path, not inside the ng<X-Y-Z> directory). Create symlinks at the TFTP root to resolve these probes:
ln -s ng<X-Y-Z>/nodegrid-<VERSION> <TFTP_ROOT>/nodegrid-<VERSION>
Example:
ln -s ng6-0-13/nodegrid-6.0.13 <TFTP_ROOT>/nodegrid-6.0.13
ln -s ng6-0-36/nodegrid-6.0.36 <TFTP_ROOT>/nodegrid-6.0.36
HTTP Docroot Symlinks
Create (or update) symlinks in the HTTP docroot for each version. These use absolute paths because Apache resolves them outside the TFTP chroot:
ln -sf <TFTP_ROOT>/ng<X-Y-Z>/nodegrid-<VERSION> <HTTP_DOCROOT>/nodegrid-<VERSION>
Example:
ln -sf /var/tftpboot/ng6-0-13/nodegrid-6.0.13 <HTTP_DOCROOT>/nodegrid-6.0.13
ln -sf /var/tftpboot/ng6-0-36/nodegrid-6.0.36 <HTTP_DOCROOT>/nodegrid-6.0.36
Note on the boot symlink in HTTP docroot: The grub.cfg also probes HTTP for (http)/boot/grub/... paths. You may keep a single boot symlink in the HTTP docroot pointing to any one version's boot/ directory. This symlink is used only as a fallback and does not affect per-device version targeting (the per-MAC symlink handles that via TFTP).
ln -sf <TFTP_ROOT>/ng<X-Y-Z>/boot <HTTP_DOCROOT>/boot
Remove the lab-style flat symlinks if they exist (from Part 1):
# Only if migrating from lab setup
rm -f <HTTP_DOCROOT>/boot
rm -f <HTTP_DOCROOT>/nodegrid-<VERSION> # remove old flat symlink
Then create the new versioned symlinks as shown above.
4.5 Create Per-MAC TFTP Symlinks
Per-MAC TFTP symlinks steer each device to the correct version's bootloader and configuration. The number of symlinks depends on the boot mode.
BIOS Devices -- Two Symlinks
| Symlink | Location | Target |
|---|
core.0-<mac-dashes> | <TFTP_ROOT>/boot/grub/i386-pc/ | ../../../ng<X-Y-Z>/boot/grub/i386-pc/core.0 |
grub.cfg-01-<mac-dashes> | <TFTP_ROOT>/boot/grub/ | ../../ng<X-Y-Z>/boot/grub/grub.cfg |
UEFI Devices -- Three Symlinks
UEFI GRUB requires a signature file (.sig) alongside each config file. Without it, GRUB rejects the config and falls to the grub> prompt.
| Symlink | Location | Target |
|---|
core.efi-<mac-dashes> | <TFTP_ROOT>/boot/grub/x86_64-efi/ | ../../../ng<X-Y-Z>/boot/grub/x86_64-efi/core.efi |
grub.cfg-01-<mac-dashes> | <TFTP_ROOT>/boot/grub/ | ../../ng<X-Y-Z>/boot/grub/grub.cfg |
grub.cfg-01-<mac-dashes>.sig | <TFTP_ROOT>/boot/grub/ | ../../ng<X-Y-Z>/boot/grub/grub.cfg.sig |
Why These Symlinks?
core.0-<mac> / core.efi-<mac> -- The TFTP path referenced in the Kea boot-file-name. The PXE ROM downloads this file to obtain the GRUB binary. The symlink target is retargeted on version switch; the Kea reservation never changes.
grub.cfg-01-<mac> -- GRUB built-in behavior: the grub.cfg-01-<mac> config search is compiled into the GRUB binary (grub_net_search_config_file). This runs before any grub.cfg is loaded -- it is how GRUB finds its config in the first place. The 01- prefix indicates Ethernet hardware type and the naming convention is built into GRUB, not configured on the server.
grub.cfg-01-<mac>.sig -- (UEFI only) GRUB's grub_net_search_config_file requests <config>.sig immediately after <config>. If the .sig file is missing, GRUB rejects the config and falls through all candidates to the grub> prompt.
Symlink Naming Conventions
Where <mac-dashes> is the device's MAC in lowercase with colons replaced by dashes.
| MAC Address (colon) | Symlink suffix |
|---|
e4:1a:2c:00:35:32 | e4-1a-2c-00-35-32 |
AA:BB:CC:DD:EE:FF | aa-bb-cc-dd-ee-ff |
Create the GRUB Boot and Config Search Directories
mkdir -p <TFTP_ROOT>/boot/grub/i386-pc
mkdir -p <TFTP_ROOT>/boot/grub/x86_64-efi
mkdir -p <TFTP_ROOT>/boot/grub
Important: Do NOT place a default grub.cfg in <TFTP_ROOT>/boot/grub/. The absence of a default ensures that devices without an explicit per-MAC symlink cannot PXE boot -- they fail loudly rather than silently booting the wrong version.
Create Symlinks
BIOS device -- create both per-MAC symlinks:
# core.0 symlink (used by Kea boot-file-name / PXE ROM)
ln -sf ../../../ng<X-Y-Z>/boot/grub/i386-pc/core.0 \
<TFTP_ROOT>/boot/grub/i386-pc/core.0-<mac-dashes>
# grub.cfg symlink (used by GRUB after it starts)
ln -sf ../../ng<X-Y-Z>/boot/grub/grub.cfg \
<TFTP_ROOT>/boot/grub/grub.cfg-01-<mac-dashes>
UEFI device -- create all three per-MAC symlinks:
# core.efi symlink (used by Kea boot-file-name / PXE ROM)
ln -sf ../../../ng<X-Y-Z>/boot/grub/x86_64-efi/core.efi \
<TFTP_ROOT>/boot/grub/x86_64-efi/core.efi-<mac-dashes>
# grub.cfg symlink (used by GRUB after it starts)
ln -sf ../../ng<X-Y-Z>/boot/grub/grub.cfg \
<TFTP_ROOT>/boot/grub/grub.cfg-01-<mac-dashes>
# grub.cfg.sig symlink (required by UEFI GRUB for signature verification)
ln -sf ../../ng<X-Y-Z>/boot/grub/grub.cfg.sig \
<TFTP_ROOT>/boot/grub/grub.cfg-01-<mac-dashes>.sig
Why relative symlinks? The TFTP server uses chroot (-s flag). After chroot, all paths are relative to <TFTP_ROOT>. Relative symlinks that resolve entirely within the chroot work correctly.
Automation note: The management scripts automate symlink creation for both boot modes. The manual steps above are for reference.
Verify all symlinks resolve:
# Boot symlinks (BIOS)
ls -la <TFTP_ROOT>/boot/grub/i386-pc/core.0-*
# Boot symlinks (UEFI)
ls -la <TFTP_ROOT>/boot/grub/x86_64-efi/core.efi-*
# grub.cfg symlinks (both modes)
ls -la <TFTP_ROOT>/boot/grub/grub.cfg-*
# Verify all resolve to real files
for LINK in <TFTP_ROOT>/boot/grub/i386-pc/core.0-* \
<TFTP_ROOT>/boot/grub/x86_64-efi/core.efi-* \
<TFTP_ROOT>/boot/grub/grub.cfg-*; do
test -f "${LINK}" && echo "OK: $(basename ${LINK})" || echo "BROKEN: $(basename ${LINK})"
done
See also: Appendix B for the complete TFTP directory tree showing version directories, per-MAC symlinks, and fallback symlinks after all steps in this section are complete.
4.6 Update Kea Config for Per-Host Reservations
In the multi-version setup, next-server and boot-file-name are specified per host reservation, not at the subnet level. Each device's reservation uses a stable per-MAC boot-file-name -- a TFTP symlink path that never changes after provisioning. Version switching retargets the symlink server-side; the Kea config is never modified again after a device is added.
Update <KEA_CONF>:
{
"Dhcp4": {
"loggers": [
{
"name": "kea-dhcp4",
"output_options": [
{ "output": "<KEA_LOG>" }
],
"severity": "DEBUG",
"debuglevel": 55
}
],
"interfaces-config": {
"interfaces": ["<IFACE>"]
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv"
},
"valid-lifetime": 600,
"renew-timer": 300,
"rebind-timer": 525,
"subnet4": [
{
"id": 1,
"subnet": "<PXE_SUBNET>",
"pools": [
{ "pool": "<POOL_START> - <POOL_END>" }
],
"option-data": [
{ "name": "routers", "data": "<GATEWAY>" },
{ "name": "domain-name-servers", "data": "<DNS_SERVER>" },
{ "name": "domain-name", "data": "<DOMAIN>" }
],
"reservations": [
{
"hw-address": "e4:1a:2c:00:35:32",
"ip-address": "172.16.90.50",
"next-server": "<SERVER_IP>",
"boot-file-name": "boot/grub/i386-pc/core.0-e4-1a-2c-00-35-32"
},
{
"hw-address": "e4:1a:2c:01:5c:fb",
"ip-address": "172.16.90.51",
"next-server": "<SERVER_IP>",
"boot-file-name": "boot/grub/x86_64-efi/core.efi-e4-1a-2c-01-5c-fb"
}
]
}
]
}
}
Key points:
boot-file-name encodes both the boot mode and the device identity:
- BIOS:
boot/grub/i386-pc/core.0-<mac-dashes>
- UEFI:
boot/grub/x86_64-efi/core.efi-<mac-dashes>
- This is a stable path that never changes regardless of which OS version is active -- the TFTP symlink at that path is what gets retargeted during version switches (see Section 4.7)
- BIOS and UEFI devices coexist in the same reservations array -- the boot mode is determined entirely by the
boot-file-name path
- Non-reserved clients get DHCP leases with routing and DNS options but no PXE boot parameters
After creating the initial config, restart Kea:
# Alpine:
rc-service kea-dhcp4 restart
# systemd:
systemctl restart kea-dhcp4-server
Note: This is the only Kea restart needed for the lifetime of the multi-version setup (beyond adding or removing device reservations). Switching versions does not require a Kea restart.
4.7 Version Switching
To switch a device to a different OS version, retarget its per-MAC TFTP symlinks to the new version directory. No Kea config change and no Kea restart are required -- the boot-file-name in the Kea reservation is a stable per-MAC path that never changes.
The firmware type (BIOS or UEFI) is auto-detected from the existing Kea reservation's boot-file-name -- there is no need to specify it during a version switch.
What Happens During a Version Switch
- The boot symlink (
core.0-<mac> or core.efi-<mac>) is retargeted to the new version's bootloader
- The config symlink (
grub.cfg-01-<mac>) is retargeted to the new version's grub.cfg
- For UEFI devices, the signature symlink (
grub.cfg-01-<mac>.sig) is also retargeted
- Per-MAC HTTP symlinks are updated to serve the new version's files
- The reformat sentinel is cleared (unless a clean install is requested)
These operations are automated by the management script. For manual reference, retarget the symlinks using
ln -sf with the new version directory as the target (see the symlink tables in
Section 4.5).
The next power cycle (or PXE boot) of the device triggers the install to the new version.
4.8 Adding a New Device
To add a new Nodegrid device to the PXE infrastructure, the following server-side state must be created. The management script automates all of these steps.
Required State
- Per-MAC TFTP boot symlink --
core.0-<mac> (BIOS) or core.efi-<mac> (UEFI), pointing to the target version's bootloader.
- Per-MAC GRUB config symlink --
grub.cfg-01-<mac>, pointing to the target version's grub.cfg.
- Per-MAC GRUB config signature symlink (UEFI only) --
grub.cfg-01-<mac>.sig, pointing to the target version's grub.cfg.sig. Without this, UEFI GRUB rejects the config.
- Per-MAC HTTP directory --
<HTTP_DOCROOT>/<mac-dashes>/ containing symlinks to the target version's version, vmlinuz, initrd, and rootfs_main.img.gz files. This directory structure is not a GRUB built-in -- it is logic engineered into ZPE's grub.cfg, which uses ${net_default_mac} to probe per-device HTTP paths.
- Colon-format HTTP symlink --
<HTTP_DOCROOT>/<mac-colons> → <mac-dashes>. GRUB's ${net_default_mac} variable uses colons (e.g., e4:1a:2c:00:35:32), but the per-MAC HTTP directory uses dashes. This symlink bridges the two formats so GRUB's HTTP requests resolve correctly.
- Kea DHCP reservation -- with
hw-address, next-server, and a stable boot-file-name that encodes both the boot mode and device identity. Optionally includes a fixed ip-address.
See also: Appendix B for the complete HTTP docroot tree showing per-version symlinks, per-MAC directories with internal symlinks, and colon-format symlinks after a device is provisioned.
Boot Mode Selection
The boot mode is chosen at provisioning time and encoded in the Kea reservation's boot-file-name:
| Boot Mode | boot-file-name |
|---|
| Legacy BIOS | boot/grub/i386-pc/core.0-<mac-dashes> |
| UEFI | boot/grub/x86_64-efi/core.efi-<mac-dashes> |
To change a device's boot mode after provisioning, remove the device and re-add it with the desired mode.
After provisioning, the Kea config is pushed to the DHCP server and Kea is restarted once. Subsequent version switches do not require a Kea restart.
4.9 Adding a New OS Version
To add a new Nodegrid OS version to the PXE infrastructure, the following server-side state must be created:
- Version directory -- The NetBoot tarball contents extracted to
<TFTP_ROOT>/ng<X-Y-Z>/ following the standard naming convention. The directory must contain the GRUB binaries (core.0, core.efi), GRUB config (grub.cfg, grub.cfg.sig), and OS files (vmlinuz, initrd, rootfs_main.img.gz, version).
- TFTP fallback symlink --
<TFTP_ROOT>/nodegrid-<VERSION> → ng<X-Y-Z>/nodegrid-<VERSION>. Resolves the grub.cfg's probe for (tftp)/nodegrid-<VERSION>/version.
- HTTP docroot symlink --
<HTTP_DOCROOT>/nodegrid-<VERSION> → <TFTP_ROOT>/ng<X-Y-Z>/nodegrid-<VERSION>. Resolves the grub.cfg's HTTP probe.
A deployment script automates the full workflow: tarball upload, extraction with correct directory naming, symlink creation, and post-deployment verification. It auto-detects the version from the tarball contents and validates that all critical files are present.
The new version is available immediately after deployment. Assign devices to it using the version switching procedure (
Section 4.7).
4.10 Removing an Old Version
Before removing a version, verify no devices are currently targeting it:
Step 1: Check for Active References
# Check both per-MAC TFTP symlinks
ls -la <TFTP_ROOT>/boot/grub/i386-pc/core.0-* | grep ng<X-Y-Z>
ls -la <TFTP_ROOT>/boot/grub/grub.cfg-* | grep ng<X-Y-Z>
If any devices reference this version, switch them to a different version first (
Section 4.7).
Step 2: Remove Symlinks
rm -f <TFTP_ROOT>/nodegrid-<VERSION>
rm -f <HTTP_DOCROOT>/nodegrid-<VERSION>
Step 3: Remove the Version Directory
rm -rf <TFTP_ROOT>/ng<X-Y-Z>
4.11 Controlling Upgrade vs Clean Install
The reformat sentinel file controls the install behavior per device. It is a 0-byte file in the device's per-MAC HTTP directory:
<HTTP_DOCROOT>/<mac-dashes>/reformat
reformat file | GRUB probe result | Kernel cmdline | Behavior |
|---|
| Present | HTTP 200 | LABEL=pxeboot-format | Full disk reformat + clean OS install |
| Absent | HTTP 404 | LABEL=pxeboot | Upgrade only (preserves existing config) |
GRUB probes GET /<mac-dashes>/reformat via HTTP during Stage 4 of the boot chain. The presence or absence of this file in the device's per-MAC HTTP directory determines the boot mode for that specific device -- no other devices are affected.
The management script handles the sentinel automatically during version switches -- creating it when a clean install is requested, and clearing it by default otherwise. The sentinel persists across version switches until explicitly cleared.
Manual management:
# Enable clean install for device e4:1a:2c:00:35:32
touch <HTTP_DOCROOT>/e4-1a-2c-00-35-32/reformat
# Disable clean install (upgrade only)
rm -f <HTTP_DOCROOT>/e4-1a-2c-00-35-32/reformat
Important: The sentinel is not self-clearing. GRUB reads the sentinel at boot time but does not remove it. After a clean install completes and the device reboots, if the reformat file is still present, the next PXE boot will trigger another clean install. Clear the sentinel after the install completes, or use the management script which handles this automatically on the next version switch.
4.12 Troubleshooting -- Multi-Version
All troubleshooting steps from
Section 3.8 apply. Additional issues specific to multi-version setups:
GRUB loads but boots the wrong version
Verify both per-MAC TFTP symlinks point to the correct version:
ls -la <TFTP_ROOT>/boot/grub/i386-pc/core.0-<mac-dashes>
ls -la <TFTP_ROOT>/boot/grub/grub.cfg-01-<mac-dashes>
- Both should target the same
ng<X-Y-Z> version directory. A mismatch (e.g., core.0 from v6.0.36 but grub.cfg from v6.0.13) will technically work (cross-version loading has been tested), but the grub.cfg symlink determines which version's kernel and rootfs are loaded.
- The Kea reservation
boot-file-name is a stable per-MAC path and never encodes a version -- it does not need to be checked for version mismatches.
GRUB cannot find grub.cfg (file not found)
Verify the per-MAC symlink exists:
ls -la <TFTP_ROOT>/boot/grub/grub.cfg-01-<mac-with-dashes>
Verify the symlink resolves to a real file:
test -f <TFTP_ROOT>/boot/grub/grub.cfg-01-<mac-with-dashes> && echo "OK" || echo "BROKEN"
- Verify the MAC address format: lowercase, dashes not colons,
01- prefix
- The
boot/grub/ directory must exist at the TFTP root (not inside a version directory) because GRUB's prefix is hardcoded to /boot/grub/
Device gets DHCP lease but no PXE parameters
- Verify the device has a reservation in Kea (not just a pool lease)
- Verify the reservation includes both
next-server and boot-file-name
- Verify
boot-file-name is the stable per-MAC form: boot/grub/i386-pc/core.0-<mac-dashes> (no version in the path)
- Check the Kea log for the device's MAC address -- the DHCPACK should include
next-server and boot-file-name fields
New version directory not accessible via HTTP
Verify the HTTP docroot symlink exists and resolves:
ls -la <HTTP_DOCROOT>/nodegrid-<VERSION>
- Verify Apache
FollowSymLinks is enabled
- If the symlink uses an absolute path to
<TFTP_ROOT>, ensure Apache has read permissions on the TFTP root directory
- Test:
curl -I http://localhost/nodegrid-<VERSION>/version
GRUB shows 404 errors for module lists
During boot, GRUB may log errors fetching command.lst, fs.lst, crypto.lst, and terminal.lst from /boot/grub/i386-pc/ (BIOS) or /boot/grub/x86_64-efi/ (UEFI). These are optional module list files. In the multi-version layout, the architecture-specific directories exist inside each version directory but not at the TFTP root. These errors are non-fatal -- GRUB proceeds normally without them.
UEFI GRUB falls to grub> prompt (.sig file missing)
- UEFI GRUB's
grub_net_search_config_file requests <config>.sig immediately after each <config> candidate. If the .sig file is missing, the config is rejected and GRUB falls through all candidates to the interactive grub> prompt.
Verify the .sig symlink exists alongside the config symlink:
ls -la <TFTP_ROOT>/boot/grub/grub.cfg-01-<mac-dashes>.sig
- Verify the symlink resolves to the real
grub.cfg.sig in the version directory.
- GRUB's
${net_default_mac} variable uses colons (e.g., e4:1a:2c:01:5c:fb), but the per-MAC HTTP directory uses dashes (e.g., e4-1a-2c-01-5c-fb).
- A colon-format symlink in the HTTP docroot bridges this gap:
<HTTP_DOCROOT>/e4:1a:2c:01:5c:fb → e4-1a-2c-01-5c-fb
- Without this symlink, GRUB's HTTP requests for version probes, reformat checks, and OS file downloads all return 404.
Verify the symlink exists:
ls -la <HTTP_DOCROOT>/<mac-colons>
5. Appendix A: Technical Details
GRUB Hardcoded Prefix
The core.0 and core.efi binaries both have a compiled-in prefix of /boot/grub/. This is set at GRUB build time and cannot be changed without recompiling. Regardless of the path used to download the bootloader (e.g., ng6-0-36/boot/grub/i386-pc/core.0 or ng6-0-36/boot/grub/x86_64-efi/core.efi), GRUB always searches for its configuration and modules at /boot/grub/ relative to the TFTP root.
This is why:
- Per-MAC
grub.cfg symlinks live at <TFTP_ROOT>/boot/grub/, not inside version directories
- The
boot/grub/ directory at the TFTP root is a routing directory for GRUB config lookups, not a place for real boot files
GRUB Config Search Order
This search order is GRUB built-in behavior -- it is compiled into the core.0 and core.efi binaries and executes before any grub.cfg is loaded. When GRUB starts, it searches for its configuration file in this order (via TFTP):
/boot/grub/grub.cfg-01-<mac> (per-device, Ethernet type prefix)
/boot/grub/grub.cfg-<ip-hex> (per-IP, full hex)
/boot/grub/grub.cfg-<ip-hex-prefix> (progressively shorter IP prefixes)
/boot/grub/grub.cfg (default fallback)
In the multi-version architecture, only option 1 is used. Options 2-4 are not configured (no files at those paths), and option 4 (default) is intentionally absent.
UEFI note: For each config candidate, UEFI GRUB also requests the corresponding .sig file (e.g., grub.cfg-01-<mac>.sig). If the .sig file is missing, that config candidate is rejected and GRUB proceeds to the next candidate in the search order. This is why UEFI devices require a grub.cfg-01-<mac>.sig symlink in addition to the grub.cfg-01-<mac> symlink.
TFTP Chroot and Symlink Resolution
tftpd-hpa with the -s flag performs a chroot() to the TFTP root directory. After chroot:
- All client-requested paths are relative to
<TFTP_ROOT>
- Symlinks must resolve entirely within the chroot
- Relative symlinks work correctly (e.g.,
../../ng6-0-36/boot/grub/grub.cfg)
- Absolute symlinks that point outside
<TFTP_ROOT> will not resolve
This is why per-MAC grub.cfg symlinks use relative paths.
Apache FollowSymLinks
Apache httpd does not chroot. With Options FollowSymLinks enabled, Apache follows both relative and absolute symlinks from the document root. This allows absolute symlinks in <HTTP_DOCROOT> to point to targets under <TFTP_ROOT>, even if the TFTP root is outside the HTTP document root.
Both <TFTP_ROOT> and <HTTP_DOCROOT> must be on the same filesystem (or Apache must have read access to the symlink targets).
Version Comparison Logic
The grub.cfg includes a version comparison step:
cmp ($net_proto)/${net_dir}/version ($VERS_PART)/version
This compares the version file from the network (HTTP or TFTP) against the version file on the device's local disk partition. The behavior is:
| Comparison Result | Action |
|---|
| Versions differ | Proceed with PXE install (kernel load) |
| Versions match | Boot from disk, skip PXE install |
| Comparison error (e.g., no disk) | Boot from disk (failsafe) |
Operational implication: PXE booting a device that already has the target version installed will result in a disk boot, not a reinstall. The reformat file is never checked when versions match.
Signature Enforcement
The grub.cfg includes signature enforcement for kernel and initrd loading. Signature checking is enforced (set check_signatures=enforce) before loading the kernel and initrd. The per-MAC grub.cfg file (or symlink) is loaded before check_signatures=enforce is set, so GRUB does not verify the signature of the grub.cfg file itself. Since the per-MAC symlink points to a real signed grub.cfg, this is a non-issue for the standard architecture.
UEFI Boot Mode Differences
The overall PXE boot architecture is identical for BIOS and UEFI. The following differences apply to UEFI devices:
Bootloader Binary
| Boot Mode | Binary | Location | Typical Size |
|---|
| Legacy BIOS | core.0 | <TFTP_ROOT>/ng<X-Y-Z>/boot/grub/i386-pc/ | ~200 KB |
| UEFI | core.efi | <TFTP_ROOT>/ng<X-Y-Z>/boot/grub/x86_64-efi/ | ~800-900 KB |
Both binaries share the same compiled-in GRUB prefix (/boot/grub/) and use the same grub.cfg, kernel, initrd, and rootfs.
.sig Symlink Requirement
UEFI GRUB's grub_net_search_config_file function requests <config>.sig immediately after <config> for each candidate in the config search order. If the .sig file is missing, the config is rejected and GRUB falls through all candidates to the grub> prompt.
This is why UEFI devices require a third per-MAC symlink: grub.cfg-01-<mac>.sig → ../../ng<X-Y-Z>/boot/grub/grub.cfg.sig
GRUB's ${net_default_mac} variable uses colons (e.g., e4:1a:2c:01:5c:fb), but the per-MAC HTTP directory uses dashes (e.g., e4-1a-2c-01-5c-fb). A colon-format symlink in the HTTP docroot bridges this gap:
<HTTP_DOCROOT>/e4:1a:2c:01:5c:fb -> e4-1a-2c-01-5c-fb
Without this symlink, all of GRUB's HTTP requests (version probe, reformat check, kernel/initrd/rootfs downloads) return 404. This symlink is also created for BIOS devices for consistency, though BIOS GRUB typically uses dashes in its MAC variable.
UEFI GRUB TFTP Request Sequence
The observed TFTP request sequence from a UEFI device (via packet capture):
boot/grub/x86_64-efi/core.efi-<MAC-dashes> (no leading /)
/boot/grub/grub.cfg-01-<MAC-dashes> (leading /, 01- prefix, dashes)
/boot/grub/grub.cfg-01-<MAC-dashes>.sig (signature file)
/boot/grub/grub.cfg-<HEX-IP> (progressively shorter hex IP variants)
/boot/grub/grub.cfg (bare fallback)
command.lst, fs.lst, crypto.lst, terminal.lst (normal mode files)
Steps 4-6 are expected fallthrough behavior when step 2-3 succeed. Steps 4-5 never match (no files at those paths by design), and step 6 files are optional (non-fatal 404s).
Disk Space Planning
Each Nodegrid OS version consumes approximately 1GB of disk space, dominated by rootfs_main.img.gz. Plan storage accordingly:
| Versions | Approximate Disk Usage |
|---|
| 1 | ~1 GB |
| 2 | ~2 GB |
| 5 | ~5 GB |
| 10 | ~10 GB |
The TFTP root filesystem should have sufficient free space for all versions you intend to serve simultaneously.
6. Appendix B: Reference Configurations
Complete Kea Config -- Lab (Single Version)
{
"Dhcp4": {
"loggers": [
{
"name": "kea-dhcp4",
"output_options": [
{ "output": "/var/log/kea/kea-dhcp4.log" }
],
"severity": "DEBUG",
"debuglevel": 55
}
],
"interfaces-config": {
"interfaces": ["<IFACE>"]
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv"
},
"valid-lifetime": 600,
"renew-timer": 300,
"rebind-timer": 525,
"subnet4": [
{
"id": 1,
"subnet": "<PXE_SUBNET>",
"pools": [
{ "pool": "<POOL_START> - <POOL_END>" }
],
"option-data": [
{ "name": "routers", "data": "<GATEWAY>" },
{ "name": "domain-name-servers", "data": "<DNS_SERVER>" },
{ "name": "domain-name", "data": "<DOMAIN>" }
],
"reservations": [
{
"hw-address": "<DEVICE_MAC>",
"ip-address": "<DEVICE_IP>",
"next-server": "<SERVER_IP>",
"boot-file-name": "boot/grub/i386-pc/core.0"
}
]
}
]
}
}
Complete Kea Config -- Scalable (Multi-Version, Mixed BIOS + UEFI)
{
"Dhcp4": {
"loggers": [
{
"name": "kea-dhcp4",
"output_options": [
{ "output": "/var/log/kea/kea-dhcp4.log" }
],
"severity": "DEBUG",
"debuglevel": 55
}
],
"interfaces-config": {
"interfaces": ["<IFACE>"]
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv"
},
"valid-lifetime": 600,
"renew-timer": 300,
"rebind-timer": 525,
"subnet4": [
{
"id": 1,
"subnet": "<PXE_SUBNET>",
"pools": [
{ "pool": "<POOL_START> - <POOL_END>" }
],
"option-data": [
{ "name": "routers", "data": "<GATEWAY>" },
{ "name": "domain-name-servers", "data": "<DNS_SERVER>" },
{ "name": "domain-name", "data": "<DOMAIN>" }
],
"reservations": [
{
"hw-address": "<BIOS_DEVICE_MAC>",
"ip-address": "<BIOS_DEVICE_IP>",
"next-server": "<SERVER_IP>",
"boot-file-name": "boot/grub/i386-pc/core.0-<bios-device-mac-dashes>"
},
{
"hw-address": "<UEFI_DEVICE_MAC>",
"ip-address": "<UEFI_DEVICE_IP>",
"next-server": "<SERVER_IP>",
"boot-file-name": "boot/grub/x86_64-efi/core.efi-<uefi-device-mac-dashes>"
}
]
}
]
}
}
Note: next-server is the same for all reservations (the TFTP server IP). boot-file-name is a stable per-MAC path that never changes after provisioning -- version switching retargets TFTP symlinks server-side and does not require a Kea config edit or restart. The boot mode (BIOS vs UEFI) is encoded in the boot-file-name path: i386-pc/core.0-<mac> for BIOS, x86_64-efi/core.efi-<mac> for UEFI.
TFTP Directory Tree -- Multi-Version
<TFTP_ROOT>/
├── ng6-0-13/ Real files (version 6.0.13)
│ ├── boot/
│ │ └── grub/
│ │ ├── grub.cfg Signed
│ │ ├── grub.cfg.sig
│ │ ├── fonts/
│ │ ├── i386-pc/
│ │ │ └── core.0 GRUB binary — BIOS (~205KB)
│ │ └── x86_64-efi/
│ │ └── core.efi GRUB binary — UEFI (~813KB)
│ └── nodegrid-6.0.13/
│ ├── version "6.0.13"
│ ├── vmlinuz, vmlinuz.sig
│ ├── initrd, initrd.sig
│ └── rootfs_main.img.gz, rootfs_main.img.gz.sig
│
├── ng6-0-36/ Real files (version 6.0.36)
│ ├── boot/
│ │ └── grub/
│ │ ├── grub.cfg Signed
│ │ ├── grub.cfg.sig
│ │ ├── fonts/
│ │ ├── i386-pc/
│ │ │ └── core.0 GRUB binary — BIOS (~214KB)
│ │ └── x86_64-efi/
│ │ └── core.efi GRUB binary — UEFI (~911KB)
│ └── nodegrid-6.0.36/
│ ├── version "6.0.36"
│ ├── vmlinuz, vmlinuz.sig
│ ├── initrd, initrd.sig
│ └── rootfs_main.img.gz, rootfs_main.img.gz.sig
│
├── boot/
│ └── grub/ GRUB routing directory
│ ├── i386-pc/ BIOS per-MAC boot symlinks
│ │ └── core.0-e4-1a-2c-00-35-32 → ../../../ng6-0-36/boot/grub/i386-pc/core.0
│ ├── x86_64-efi/ UEFI per-MAC boot symlinks
│ │ └── core.efi-e4-1a-2c-01-5c-fb → ../../../ng6-0-36/boot/grub/x86_64-efi/core.efi
│ ├── grub.cfg-01-e4-1a-2c-00-35-32 → ../../ng6-0-36/boot/grub/grub.cfg (BIOS device)
│ ├── grub.cfg-01-e4-1a-2c-01-5c-fb → ../../ng6-0-36/boot/grub/grub.cfg (UEFI device)
│ ├── grub.cfg-01-e4-1a-2c-01-5c-fb.sig → ../../ng6-0-36/boot/grub/grub.cfg.sig (UEFI .sig)
│ └── (no default grub.cfg — intentionally absent)
│
├── nodegrid-6.0.13 → ng6-0-13/nodegrid-6.0.13 (TFTP fallback symlink)
└── nodegrid-6.0.36 → ng6-0-36/nodegrid-6.0.36 (TFTP fallback symlink)
HTTP Docroot Tree -- Multi-Version
<HTTP_DOCROOT>/
├── nodegrid-6.0.13 → <TFTP_ROOT>/ng6-0-13/nodegrid-6.0.13 (absolute symlink)
├── nodegrid-6.0.36 → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36 (absolute symlink)
├── boot → <TFTP_ROOT>/ng6-0-36/boot (absolute symlink, optional fallback)
│
├── e4-1a-2c-00-35-32/ Per-MAC directory (BIOS device, targeting v6.0.36)
│ ├── version → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/version
│ ├── vmlinuz → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/vmlinuz
│ ├── initrd → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/initrd
│ └── rootfs_main.img.gz → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/rootfs_main.img.gz
│
├── e4:1a:2c:00:35:32 → e4-1a-2c-00-35-32 Colon-format symlink (GRUB ${net_default_mac})
│
├── e4-1a-2c-01-5c-fb/ Per-MAC directory (UEFI device, targeting v6.0.36)
│ ├── version → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/version
│ ├── vmlinuz → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/vmlinuz
│ ├── initrd → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/initrd
│ ├── rootfs_main.img.gz → <TFTP_ROOT>/ng6-0-36/nodegrid-6.0.36/rootfs_main.img.gz
│ └── reformat Optional sentinel (0-byte, triggers clean install)
│
└── e4:1a:2c:01:5c:fb → e4-1a-2c-01-5c-fb Colon-format symlink (GRUB ${net_default_mac})