diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..7f2bf89c6f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..c7a2afcce3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..b7c4dbf72d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker/Dockerfile.watcher b/docker/Dockerfile.watcher new file mode 100644 index 0000000000..5f76de2e18 --- /dev/null +++ b/docker/Dockerfile.watcher @@ -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"] diff --git a/docker/watcher.sh b/docker/watcher.sh new file mode 100644 index 0000000000..a857e6174a --- /dev/null +++ b/docker/watcher.sh @@ -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 diff --git a/generate-iso-menu.sh b/generate-iso-menu.sh new file mode 100644 index 0000000000..74a2b28095 --- /dev/null +++ b/generate-iso-menu.sh @@ -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/ +# ${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}" <> "${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." diff --git a/isos/DROP_ISOS_HERE.txt b/isos/DROP_ISOS_HERE.txt new file mode 100644 index 0000000000..26cf7adbef --- /dev/null +++ b/isos/DROP_ISOS_HERE.txt @@ -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 diff --git a/menus/custom.ipxe b/menus/custom.ipxe new file mode 100644 index 0000000000..6bf2292b04 --- /dev/null +++ b/menus/custom.ipxe @@ -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 + +: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 || +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