404 lines
13 KiB
Markdown
404 lines
13 KiB
Markdown
# Runbook: Boot a Linux machine from an ISO over the network
|
||
|
||
End-to-end walkthrough: spin up PXEForge, load an Ubuntu (or any
|
||
Linux) ISO into it, target a specific bare-metal or VM client by its
|
||
MAC address, and have that machine PXE-boot the installer over the
|
||
LAN — no USB stick, no console babysitting.
|
||
|
||
This runbook assumes:
|
||
|
||
- You have **one Linux host** to run the PXEForge container (any
|
||
distro with Docker / Podman; 2 GB RAM, ~50 GB disk for the ISO
|
||
library).
|
||
- That host sits on the **same broadcast domain / VLAN** as the
|
||
client you want to boot. PXE is L2-broadcast — routed/VLAN’d
|
||
networks need a DHCP relay and are out of scope here.
|
||
- An **existing DHCP server** is already handing out IP leases on
|
||
that VLAN (your home router, OPNsense, Windows Server, etc.).
|
||
PXEForge runs as a *DHCP proxy* — it never leases IPs, it only
|
||
layers the boot information on top of the existing DHCP exchange.
|
||
- The target client is configured to **PXE-boot** in BIOS/UEFI
|
||
firmware (usually `F12` boot menu → Network, or set as first boot
|
||
device).
|
||
|
||
If those don’t hold, stop and read [troubleshooting.md](troubleshooting.md)
|
||
or [docs/architecture.md](../docs/architecture.md) first.
|
||
|
||
---
|
||
|
||
## 0. Pick your host’s LAN IP
|
||
|
||
You need the IPv4 address PXEForge will advertise to clients. From
|
||
the host:
|
||
|
||
```bash
|
||
ip -4 -o addr show | awk '{print $2, $4}'
|
||
```
|
||
|
||
Pick the address on the interface that faces the PXE VLAN — for
|
||
example `10.0.0.5/24` on `eno1`. From here on we call it
|
||
`PXE_HOST_IP`.
|
||
|
||
> **Why this matters.** Every URL handed to clients (TFTP server,
|
||
> iPXE chain URL, ISO URL) is built from this IP. If PXEForge
|
||
> auto-detects the wrong interface or loopback, clients will fetch
|
||
> from an unreachable address and silently fail. The startup will
|
||
> *fail loudly* if it can only auto-detect a loopback address.
|
||
|
||
---
|
||
|
||
## 1. Run PXEForge
|
||
|
||
The MVP path is a single `docker run` against the published image,
|
||
with `--network host` so the container can see DHCP broadcasts on
|
||
the LAN.
|
||
|
||
```bash
|
||
mkdir -p ~/pxeforge/isos ~/pxeforge/work
|
||
|
||
docker run -d --name pxeforge \
|
||
--restart unless-stopped \
|
||
--network host \
|
||
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
|
||
-e PXEFORGE_DHCP_MODE=proxy \
|
||
-v ~/pxeforge/isos:/var/lib/pxeforge/isos \
|
||
-v ~/pxeforge/work:/var/lib/pxeforge/work \
|
||
ghcr.io/YOUR-ORG/pxeforge:0.2.0
|
||
```
|
||
|
||
Substitute your `PXEFORGE_PUBLIC_IP`, of course. If you’re building
|
||
from this repo instead of pulling, see the
|
||
[README quick start](../README.md#quick-start--mvp-container-recommended).
|
||
|
||
### Verify it’s alive
|
||
|
||
```bash
|
||
curl -fsS http://10.0.0.5/healthz # → 200 ok
|
||
curl -fsS http://10.0.0.5/readyz # → 200 ready (iPXE binaries present)
|
||
curl -fsS http://10.0.0.5/api/status | jq .
|
||
```
|
||
|
||
If `/readyz` is **not** 200, your container is missing iPXE binaries.
|
||
Fix that before going further — clients have nothing to boot
|
||
otherwise. See [README — Container health probes](../README.md#container-health-probes).
|
||
|
||
### Check the listening ports
|
||
|
||
PXEForge holds three privileged UDP/TCP ports. From another shell on
|
||
the host:
|
||
|
||
```bash
|
||
sudo ss -lnup | grep -E ':(67|69|4011)\b' # DHCP proxy + TFTP
|
||
sudo ss -lntp | grep ':80\b' # HTTP UI / boot scripts
|
||
```
|
||
|
||
All four should be present. If port 67 is taken by `dnsmasq` or the
|
||
host’s own DHCP, stop that service or run PXEForge on a separate box —
|
||
two listeners on `:67` will fight.
|
||
|
||
---
|
||
|
||
## 2. Load the ISO
|
||
|
||
Two options. Pick one.
|
||
|
||
### 2a. Web UI upload (recommended for one-offs)
|
||
|
||
1. Open `http://10.0.0.5/` in a browser.
|
||
2. Sidebar → **Storage**.
|
||
3. Click **Upload ISO**, pick e.g. `ubuntu-24.04.1-live-server-amd64.iso`.
|
||
4. Wait for upload + introspection. The row turns into a card showing:
|
||
- Distro family (`debian_ubuntu`)
|
||
- Volume label
|
||
- Detected kernel/initrd paths (`/casper/vmlinuz`, `/casper/initrd`)
|
||
- File size and SHA-256
|
||
|
||
Big ISOs stream — there is no 2 GB limit, but expect upload to be
|
||
gated by your browser ↔ host link. The UI shows a progress bar; the
|
||
animated anvil on the Dashboard tab fires up while imaging is in
|
||
flight.
|
||
|
||
### 2b. Bulk seed from a directory (recommended for fresh deploys / CI)
|
||
|
||
If you already have a folder of ISOs on the host, skip the browser:
|
||
|
||
```bash
|
||
# Dry run first — see what would be imported, no writes:
|
||
docker exec pxeforge pxeforge seed \
|
||
--from /seed \
|
||
--dry-run
|
||
|
||
# For real, mount the source dir read-only into the container:
|
||
docker run --rm \
|
||
-v /my/iso-library:/seed:ro \
|
||
-v ~/pxeforge/isos:/var/lib/pxeforge/isos \
|
||
-v ~/pxeforge/work:/var/lib/pxeforge/work \
|
||
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
|
||
ghcr.io/YOUR-ORG/pxeforge:0.2.0 seed --from /seed
|
||
```
|
||
|
||
Each `*.iso` in `/seed` runs through the same upload pipeline as the
|
||
web UI: copy → introspection → boot-entry generation → metadata
|
||
sidecar. Re-running is idempotent.
|
||
|
||
### Confirm the ISO is registered
|
||
|
||
```bash
|
||
curl -fsS http://10.0.0.5/api/isos | jq '.[] | {id, name, family, size}'
|
||
```
|
||
|
||
You should see something like:
|
||
|
||
```json
|
||
{
|
||
"id": "ubuntu-24-04-1-live-server-amd64",
|
||
"name": "ubuntu-24.04.1-live-server-amd64.iso",
|
||
"family": "debian_ubuntu",
|
||
"size": 2748000000
|
||
}
|
||
```
|
||
|
||
The `id` is the **slug**. Remember it — you’ll bind a MAC to it in
|
||
the next step.
|
||
|
||
---
|
||
|
||
## 3. Find the target machine’s MAC address
|
||
|
||
You need the MAC of the **NIC that will PXE**, not the OS’s
|
||
loopback or wifi.
|
||
|
||
### 3a. From the target itself (if it’s already running an OS)
|
||
|
||
```bash
|
||
ip -o link | awk '/ether/ {print $2, $17}' # Linux
|
||
```
|
||
|
||
Pick the line for the wired NIC plugged into the PXE VLAN.
|
||
|
||
### 3b. From the firmware (if it’s a fresh box)
|
||
|
||
Most BIOS/UEFI screens display the NIC MAC during the network-boot
|
||
attempt — usually as `MAC: AA-BB-CC-DD-EE-FF` flashing on the splash
|
||
right before "PXE-E53: No boot filename received". Write it down.
|
||
|
||
### 3c. By letting it boot once and watching PXEForge
|
||
|
||
Easiest if the box is in front of you:
|
||
|
||
1. Power on, hit `F12`, pick **Network boot**.
|
||
2. Without any binding configured, the client will land on the
|
||
PXEForge menu (Default / Installers / Tools / Gated Deployment).
|
||
3. Don’t pick anything. On your laptop:
|
||
```bash
|
||
curl -fsS http://10.0.0.5/api/clients | jq .
|
||
```
|
||
4. The most-recent entry is your target. Copy its `mac`.
|
||
|
||
From here on we call this MAC `TARGET_MAC` (e.g. `aa:bb:cc:dd:ee:ff`).
|
||
Hyphens vs colons, upper vs lower case — PXEForge normalizes both.
|
||
|
||
---
|
||
|
||
## 4. Pin that machine to the Ubuntu ISO
|
||
|
||
This is the **per-MAC host binding**. With it set, the client won’t
|
||
see the menu at all — it goes straight to the bound boot entry,
|
||
Tinkerbell-style.
|
||
|
||
### 4a. Via the web UI
|
||
|
||
1. Sidebar → **Hosts**.
|
||
2. **Add binding**:
|
||
- **MAC**: `aa:bb:cc:dd:ee:ff`
|
||
- **Target**: pick `ubuntu-24-04-1-live-server-amd64` from the dropdown.
|
||
- **Label**: free-form, e.g. `lab-rack3-node07`.
|
||
3. Save.
|
||
|
||
### 4b. Via the API
|
||
|
||
```bash
|
||
curl -fsS -X POST http://10.0.0.5/api/hosts \
|
||
-H 'content-type: application/json' \
|
||
-d '{
|
||
"mac": "aa:bb:cc:dd:ee:ff",
|
||
"target": "ubuntu-24-04-1-live-server-amd64",
|
||
"label": "lab-rack3-node07"
|
||
}' | jq .
|
||
```
|
||
|
||
The binding is persisted to `~/pxeforge/work/hosts.json` and survives
|
||
container restart.
|
||
|
||
### Confirm
|
||
|
||
```bash
|
||
curl -fsS http://10.0.0.5/api/hosts | jq '.[] | select(.mac=="aa:bb:cc:dd:ee:ff")'
|
||
```
|
||
|
||
You should see your entry with `created_at` and `updated_at`
|
||
timestamps.
|
||
|
||
---
|
||
|
||
## 5. Trigger the network boot on the target
|
||
|
||
Now actually boot the machine.
|
||
|
||
### 5a. Boot order
|
||
|
||
In firmware setup, set the wired NIC as the **first** boot device
|
||
(or hold `F12` / `F9` / `Esc` — vendor-specific — to pick "Network
|
||
Boot" interactively).
|
||
|
||
### 5b. What you should see on the target screen
|
||
|
||
In order, with timing:
|
||
|
||
| Stage | Approximate duration | What appears |
|
||
|-------|---------------------:|--------------|
|
||
| Firmware DHCPDISCOVER | ~1 s | `Start PXE over IPv4` / `Station IP address …` |
|
||
| TFTP iPXE binary fetch | ~1 s | `TFTP… snponly.efi` (or `undionly.kpxe` for legacy BIOS) |
|
||
| iPXE banner | ~1 s | The blue iPXE splash, version string |
|
||
| iPXE second-stage DHCP | ~1 s | `Configuring (net0 …)` then `ok` |
|
||
| HTTP boot script fetch | <1 s | `http://10.0.0.5/boot.ipxe?mac=…` |
|
||
| Per-MAC chain | <1 s | `PXEForge: per-MAC binding -> ubuntu-24-04-1-…` |
|
||
| Kernel + initrd HTTP | 5–30 s | Two 200-OK fetches against `/iso/<id>/casper/vmlinuz` and `…/initrd` |
|
||
| Kernel boot | 5–10 s | Kernel banner, then the Ubuntu/cloud-init splash |
|
||
| Installer comes up | 30–60 s | The distro’s normal Live/installer environment |
|
||
|
||
If everything works, you’re looking at the Ubuntu Server installer
|
||
welcome screen end-to-end **without ever touching a USB stick**.
|
||
|
||
### 5c. Watch it from the server
|
||
|
||
In a third shell, tail the live log:
|
||
|
||
```bash
|
||
curl -N http://10.0.0.5/api/log/stream
|
||
```
|
||
|
||
You’ll see each protocol step as it happens:
|
||
|
||
```
|
||
INFO pxeforge::dhcp: reply mac=aa:bb:cc:dd:ee:ff arch=X8664Uefi target=tftp/snponly.efi
|
||
INFO pxeforge::tftp: RRQ snponly.efi blksize=1468 windowsize=8 → 982 KiB in 412 ms
|
||
INFO pxeforge::dhcp: reply mac=aa:bb:cc:dd:ee:ff (iPXE) target=http/boot.ipxe
|
||
INFO pxeforge::http: GET /boot.ipxe?mac=aa:bb:cc:dd:ee:ff → host binding hit
|
||
INFO pxeforge::http: GET /iso/ubuntu-…/casper/vmlinuz Range=bytes=0- 200 OK 14 MiB
|
||
INFO pxeforge::http: GET /iso/ubuntu-…/casper/initrd Range=bytes=0- 200 OK 75 MiB
|
||
```
|
||
|
||
The **Terminal** tab in the web UI shows the same thing live, plus a
|
||
short whitelisted command palette (`status`, `clients`, `gate`,
|
||
`hosts`, `log`).
|
||
|
||
### 5d. Internet-side ISO sources
|
||
|
||
The runbook title says “via the internet” — the **client** itself
|
||
boots from your LAN, but the underlying ISO can come from anywhere
|
||
your *host* can reach:
|
||
|
||
- **Direct upload** from a remote workstation via the web UI (HTTPS
|
||
reverse-proxied if you put PXEForge behind nginx/Caddy).
|
||
- **NFS mount** of a remote share — Sidebar → **Storage** → **NFS** →
|
||
`nfs://files.lab.example.com/exports/isos`. Mounted ISOs show up in
|
||
the same list and are PXE-bootable directly without copying.
|
||
- **Pre-seed** from a CI job that `curl`s a vendor mirror and runs
|
||
`pxeforge seed --from`.
|
||
|
||
PXEForge itself never reaches out to the internet at boot time — all
|
||
client traffic stays on the LAN, served from the host.
|
||
|
||
---
|
||
|
||
## 6. After the install
|
||
|
||
Once Ubuntu has finished installing to the target’s disk, you want
|
||
the next reboot to come up off the new local disk, **not** PXE
|
||
again. Two ways:
|
||
|
||
### 6a. One-shot — release the binding
|
||
|
||
```bash
|
||
curl -fsS -X DELETE http://10.0.0.5/api/hosts/aa:bb:cc:dd:ee:ff
|
||
```
|
||
|
||
Without a binding, the client either gets the menu (BIOS still set
|
||
to PXE first) or boots local disk normally.
|
||
|
||
### 6b. Permanent — pin to local disk
|
||
|
||
Re-bind to the reserved local-boot target:
|
||
|
||
```bash
|
||
curl -fsS -X POST http://10.0.0.5/api/hosts \
|
||
-H 'content-type: application/json' \
|
||
-d '{ "mac": "aa:bb:cc:dd:ee:ff", "target": "_local", "label": "lab-rack3-node07 (installed)" }'
|
||
```
|
||
|
||
Now if anyone hits `F12 → Network` by accident, PXEForge replies
|
||
with a script that says *"chain back to local HDD"* and the box
|
||
boots its real OS instead of re-imaging itself. This is the safest
|
||
default for production hardware.
|
||
|
||
---
|
||
|
||
## 7. Re-imaging — the “Gated Deployment” flow
|
||
|
||
Different scenario: you have **a rack of 30 servers** to image
|
||
identically, all at once. Don’t bind 30 MACs by hand. Use the gate.
|
||
|
||
1. **Don’t** create host bindings.
|
||
2. PXE-boot every machine. They land on the menu.
|
||
3. On each: select **Gated Deployment**. They get position #1, #2,
|
||
…, #30 and start long-polling.
|
||
4. In the UI: **Forge Gate** tab shows all 30 lined up. Pick the
|
||
ISO, click **Assign to all waiting**.
|
||
5. Every client’s open long-poll wakes up at the same instant and
|
||
chains the same boot script. They all start imaging
|
||
simultaneously — the “horse race gate” opens.
|
||
|
||
The animated anvil widget on the Dashboard runs while any client is
|
||
still in the kernel-fetch phase.
|
||
|
||
---
|
||
|
||
## Cheat sheet
|
||
|
||
| Goal | Command |
|
||
|------|---------|
|
||
| Health check | `curl http://$IP/healthz` |
|
||
| List ISOs | `curl http://$IP/api/isos \| jq .` |
|
||
| List clients seen | `curl http://$IP/api/clients \| jq .` |
|
||
| Bind MAC → ISO | `POST /api/hosts` with `{mac,target,label}` |
|
||
| Bind MAC → local disk | same with `target=_local` |
|
||
| Release binding | `DELETE /api/hosts/<mac>` |
|
||
| Live log | `curl -N http://$IP/api/log/stream` |
|
||
| Prometheus metrics | `curl http://$IP/metrics` |
|
||
| Bulk import folder | `pxeforge seed --from /path` |
|
||
|
||
---
|
||
|
||
## Where to look when things break
|
||
|
||
- **Client gets `PXE-E53: No boot filename received`** — DHCP proxy
|
||
isn’t replying. Check `:67` is bound (`ss -lnup`), check
|
||
`--network host`, check the host firewall on UDP 67/69/4011.
|
||
- **iPXE shows `No more network devices`** — firmware NIC isn’t in
|
||
PXE mode, or VLAN tagging is wrong.
|
||
- **iPXE prints `Connection timed out (http://…)`** — `PXEFORGE_PUBLIC_IP`
|
||
is wrong. Clients can’t reach that IP. Check `/api/status` →
|
||
`public_base_url` and `ping` it from the client subnet.
|
||
- **Kernel panics during initrd load** — corrupt ISO upload. Check
|
||
`/api/isos`, compare the SHA-256 to the vendor’s, re-upload.
|
||
- **Boot menu shows but the bound entry doesn’t fire** — the binding
|
||
target slug doesn’t match any ISO `id`. Recheck
|
||
`GET /api/hosts` against `GET /api/isos`. The binding falls back
|
||
to the menu on miss (by design — never lock a client out).
|
||
- **General confusion** — Terminal tab → `status`, then `log`. That
|
||
tells you what protocol stages have run and which haven’t.
|
||
|
||
For deeper protocol-level debugging, see
|
||
[docs/architecture.md](../docs/architecture.md).
|