Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copy this file to .env and edit the values

# Timezone (see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
TZ=America/New_York

# User/Group ID for file permissions (run `id` on Linux to find yours; leave 1000 on Windows)
PUID=1000
PGID=1000
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Docker named volume data (managed by Docker, not tracked in git)
data/

# Local ISO files (can be large — track only the readme placeholder)
isos/*.iso

# Environment overrides
.env

# OS / editor
.DS_Store
Thumbs.db
*.swp
38 changes: 38 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
services:
netbootxyz:
image: lscr.io/linuxserver/netbootxyz:latest
container_name: netbootxyz
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Etc/UTC}
volumes:
# Named volume for persistent config/state — mirrors the data:data pattern
- data:/config
# Custom iPXE menu files — written by the iso-watcher container
- ./menus:/config/menus
# Local ISOs — drop files here; the watcher auto-updates the menu
# To map to another drive, replace ./isos with an absolute path:
# Windows: - D:\ISOs:/assets/isos
# Linux: - /mnt/storage/isos:/assets/isos
- ./isos:/assets/isos
ports:
- 3000:3000 # Web UI
- 69:69/udp # TFTP (PXE boot entry point)
- 8080:80 # HTTP (serves bootloaders + ISOs)
restart: unless-stopped

iso-watcher:
build:
context: .
dockerfile: docker/Dockerfile.watcher
container_name: netbootxyz-iso-watcher
volumes:
# Read ISOs from the same folder (read-only — watcher never modifies ISOs)
- ./isos:/isos:ro
# Write the generated menu into the menus folder
- ./menus:/menus
restart: unless-stopped

volumes:
data: # Named volume — survives container restarts/upgrades
5 changes: 5 additions & 0 deletions docker/Dockerfile.watcher
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM alpine:latest
RUN apk add --no-cache inotify-tools
COPY docker/watcher.sh /watcher.sh
RUN chmod +x /watcher.sh
ENTRYPOINT ["/watcher.sh"]
102 changes: 102 additions & 0 deletions docker/watcher.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/bin/sh
# Watches /isos for changes and auto-regenerates /menus/local-isos.ipxe
# Runs inside the iso-watcher container — nothing needs to be done manually.

ISOS_DIR="/isos"
OUTPUT="/menus/local-isos.ipxe"
TMPLIST="/tmp/iso_list.txt"
CHANGED="/tmp/iso_changed"

generate_menu() {
mkdir -p /menus

# Collect all ISOs recursively, sorted by path
find "${ISOS_DIR}" -name "*.iso" 2>/dev/null | sort > "${TMPLIST}"
ISO_COUNT="$(wc -l < "${TMPLIST}" | tr -d ' ')"

if [ "${ISO_COUNT}" = "0" ]; then
printf '#!ipxe\necho No ISOs found. Drop .iso files into the isos/ folder.\necho.\nprompt Press any key to return...\nexit 1\n' \
> "${OUTPUT}"
echo "$(date '+%Y-%m-%d %H:%M:%S') [iso-watcher] No ISOs found — menu cleared"
return
fi

# ── Header ──────────────────────────────────────────────────────────────────
cat > "${OUTPUT}" <<'HEADER'
#!ipxe
###
### Local ISO Boot Menu (auto-generated — managed by iso-watcher container)
###

:local_iso_menu
clear iso_choice
menu Local ISO Boot Menu
HEADER

# ── Menu items, grouped by subfolder ────────────────────────────────────────
last_dir=""
idx=0
while IFS= read -r isofile; do
[ -z "${isofile}" ] && continue
rel="${isofile#${ISOS_DIR}/}"
dir="$(dirname "${rel}")"
filename="$(basename "${rel}")"
label="${filename%.iso}"

# Emit a category gap whenever the directory changes
if [ "${dir}" != "${last_dir}" ]; then
if [ "${dir}" = "." ]; then
printf 'item --gap -- === General ===\n' >> "${OUTPUT}"
else
printf 'item --gap -- === %s ===\n' "${dir}" >> "${OUTPUT}"
fi
last_dir="${dir}"
fi

printf 'item iso_%d %s\n' "${idx}" "${label}" >> "${OUTPUT}"
idx=$((idx + 1))
done < "${TMPLIST}"

# ── Navigation footer ────────────────────────────────────────────────────────
printf 'item --gap --\nitem iso_back Go back\nchoose iso_choice || goto iso_back\ngoto ${iso_choice}\n\n' \
>> "${OUTPUT}"

# ── Boot entries ─────────────────────────────────────────────────────────────
# ${boot_domain} is an iPXE variable — the $ is intentionally literal here.
# ISOs in subfolders are served at http://${boot_domain}/isos/subfolder/name.iso
idx=0
while IFS= read -r isofile; do
[ -z "${isofile}" ] && continue
rel="${isofile#${ISOS_DIR}/}"
filename="$(basename "${rel}")"
printf ':iso_%d\necho Booting %s ...\nkernel http://${boot_domain}/memdisk raw iso\ninitrd http://${boot_domain}/isos/%s\nboot || goto local_iso_menu\n\n' \
"${idx}" "${filename}" "${rel}" >> "${OUTPUT}"
idx=$((idx + 1))
done < "${TMPLIST}"

printf ':iso_back\nexit 1\n' >> "${OUTPUT}"

echo "$(date '+%Y-%m-%d %H:%M:%S') [iso-watcher] Menu updated — ${ISO_COUNT} ISO(s)"
}

echo "[iso-watcher] Starting. Watching ${ISOS_DIR} for changes ..."
generate_menu

# ── Background watcher ────────────────────────────────────────────────────────
# inotifywait fires on any file event; we touch a flag file as the signal.
# The main loop checks every 2 s and regenerates only once per burst of events.
(
while true; do
inotifywait -r -e create,delete,move,close_write -q "${ISOS_DIR}" 2>/dev/null \
&& touch "${CHANGED}"
sleep 1
done
) &

while true; do
sleep 2
if [ -f "${CHANGED}" ]; then
rm -f "${CHANGED}"
generate_menu
fi
done
120 changes: 120 additions & 0 deletions generate-iso-menu.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# MANUAL FALLBACK — normally the iso-watcher container handles this automatically.
# Only run this if the watcher container is not running (e.g. during development).
#
# Scans the isos/ folder (including subfolders) and writes menus/local-isos.ipxe.
# Subfolders become category headers in the boot menu.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ISOS_DIR="${SCRIPT_DIR}/isos"
OUTPUT_FILE="${SCRIPT_DIR}/menus/local-isos.ipxe"

mkdir -p "${SCRIPT_DIR}/menus"

# Collect ALL .iso files recursively, sorted by path (groups subfolders together)
mapfile -d '' ALL_ISOS < <(find "${ISOS_DIR}" -name "*.iso" -print0 2>/dev/null | sort -z)

ISO_COUNT=${#ALL_ISOS[@]}

if [ "${ISO_COUNT}" -eq 0 ]; then
echo "No .iso files found in ${ISOS_DIR}/ (or subfolders)."
echo "Drop .iso files there — subfolders like isos/linux/ or isos/windows/ are supported."
cat > "${OUTPUT_FILE}" <<'PLACEHOLDER'
#!ipxe
echo No local ISOs found.
echo Drop .iso files into the isos/ folder (subfolders are supported).
echo Then run ./generate-iso-menu.sh on the host.
echo.
prompt Press any key to return...
exit 1
PLACEHOLDER
exit 0
fi

# ── Build the menu header ────────────────────────────────────────────────────
cat > "${OUTPUT_FILE}" <<'HEADER'
#!ipxe
###
### Local ISO Boot Menu (auto-generated — do not edit by hand)
### Re-run ./generate-iso-menu.sh to refresh after adding/moving ISOs.
###

:local_iso_menu
clear iso_choice
menu Local ISO Boot Menu
HEADER

# ── Menu items, grouped by subfolder ─────────────────────────────────────────
# When the parent folder changes, emit a --gap separator with the folder name.
# ISOs directly in isos/ appear under "General".
declare -a ITEM_ISOS=()
last_dir=""
idx=0

for isofile in "${ALL_ISOS[@]}"; do
rel="${isofile#"${ISOS_DIR}/"}" # e.g. linux/ubuntu.iso or ubuntu.iso
dir="$(dirname "${rel}")" # e.g. linux or .
filename="$(basename "${rel}")"
label="${filename%.iso}" # strip .iso for display

# Emit a category gap whenever the directory changes
if [ "${dir}" != "${last_dir}" ]; then
if [ "${dir}" = "." ]; then
echo 'item --gap -- === General ===' >> "${OUTPUT_FILE}"
else
echo "item --gap -- === ${dir} ===" >> "${OUTPUT_FILE}"
fi
last_dir="${dir}"
fi

printf 'item iso_%d %s\n' "${idx}" "${label}" >> "${OUTPUT_FILE}"
ITEM_ISOS+=("${rel}")
idx=$((idx + 1))
done

# ── Footer of menu items ─────────────────────────────────────────────────────
cat >> "${OUTPUT_FILE}" <<'MID'
item --gap --
item iso_back Go back
choose iso_choice || goto iso_back
goto ${iso_choice}

MID

# ── Boot entries ─────────────────────────────────────────────────────────────
# ISOs are served by the container's HTTP server:
# container port 80 → host port 8080 (docker-compose.yml)
# URL: http://${boot_domain}/isos/<relative-path>
# ${boot_domain} is resolved by iPXE to the IP that delivered the boot file.
# Subfolders are preserved in the URL path automatically.
for i in "${!ITEM_ISOS[@]}"; do
rel="${ITEM_ISOS[$i]}"
filename="$(basename "${rel}")"
urlpath="isos/${rel}" # e.g. isos/linux/ubuntu.iso
cat >> "${OUTPUT_FILE}" <<ENTRY
:iso_${i}
echo Booting ${filename} ...
kernel http://\${boot_domain}/memdisk raw iso
initrd http://\${boot_domain}/${urlpath}
boot || goto local_iso_menu

ENTRY
done

# ── Final label ──────────────────────────────────────────────────────────────
cat >> "${OUTPUT_FILE}" <<'FOOTER'
:iso_back
exit 1
FOOTER

echo "Generated: ${OUTPUT_FILE}"
echo "Found ${ISO_COUNT} ISO file(s):"
for f in "${ALL_ISOS[@]}"; do
rel="${f#"${ISOS_DIR}/"}"
echo " - ${rel}"
done
echo
echo "ISOs are served at: http://YOUR_SERVER_IP:8080/isos/"
echo "Refresh the page in the netboot.xyz web UI if the menu doesn't update."
41 changes: 41 additions & 0 deletions isos/DROP_ISOS_HERE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
DROP .ISO FILES IN THIS FOLDER
================================

Just drop any .iso file here — the menu updates automatically.
No scripts, no restarts, nothing else to do.

You can also use subfolders to keep things organised:

isos/
linux/
ubuntu-24.04.iso
debian-12.iso
windows/
windows11.iso
tools/
proxmox-8.iso
rescue.iso

Subfolders show up as category labels in the boot menu.
ISOs in the root of this folder appear under "General".

TO MAP THIS FOLDER TO ANOTHER DRIVE
-------------------------------------
Edit the docker-compose.yml line:

- ./isos:/assets/isos

Replace ./isos with the full path to your drive:

Windows: - D:\MyISOs:/assets/isos
Linux: - /mnt/nas/isos:/assets/isos

Then restart the stack in Portainer.

HOW TO BOOT
------------
In the netboot.xyz menu, go to:
Custom... → Custom Boot Menu → Boot a Local ISO

You can also boot any ISO by URL:
Custom... → Custom Boot Menu → Boot from URL
50 changes: 50 additions & 0 deletions menus/custom.ipxe
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!ipxe
###
### Custom Boot Menu
### Includes local ISOs (from isos/ folder) and URL boot option
###

:custom_menu
clear custom_choice
menu Custom Boot Menu
item local_isos Boot a Local ISO (from isos/ folder)
item url_boot Boot from URL (type any ISO link)
item --gap --
item return Back to main menu
choose custom_choice || goto custom_menu_exit
goto ${custom_choice}
goto custom_menu_exit
Comment thread
vampiricbunny marked this conversation as resolved.

:local_isos
chain local-isos.ipxe || goto no_isos

:no_isos
echo No local ISOs found.
echo Run ./generate-iso-menu.sh on the host to build the menu,
echo then drop .iso files into the isos/ folder.
echo.
prompt Press any key to return...
goto custom_menu

:url_boot
clear boot_url
echo.
echo Enter the full URL to an ISO or image file:
echo (e.g. http://192.168.1.10:8080/isos/ubuntu.iso)
echo.
read boot_url
iseq ${boot_url} && goto url_boot ||
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Logic error in conditional

This line uses iseq (is equal) with && and || operators that will create unexpected flow. If ${boot_url} is empty, iseq returns true and executes goto url_boot (infinite loop). If not empty, it falls through without doing anything.

The correct iPXE pattern for "retry if empty" is:

iseq ${boot_url} && goto url_boot ||

(The trailing || continues to the next line if the variable is NOT empty)

echo.
echo Booting ${boot_url} ...
kernel http://${boot_domain}/memdisk raw iso
initrd ${boot_url}
boot || goto url_boot_fail

:url_boot_fail
echo.
echo Boot failed. Check the URL and that the server is reachable.
prompt Press any key to go back...
goto custom_menu

:custom_menu_exit
exit 1