From 083277faaef144e0f6f12e1e608b1d9f2805bf16 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Thu, 30 Apr 2026 00:02:29 -0400 Subject: [PATCH] Add Unraid quickstart: build-and-publish script + Docker template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three paths from "Gitea-on-Unraid + a built repo" to "Unraid pulls PXEForge by tag": 1. scripts/build-and-publish-unraid.sh — one-shot run on the Unraid host. Clones from local Gitea (http://localhost:3000), runs the iPXE fetch, docker build, docker login + push to Gitea's container registry. Token never lands in the host's ~/.docker/config.json: we set DOCKER_CONFIG to a tempdir and rm -rf it on exit. Token never lands in `ps`/bash history either: --password-stdin. 2. deploy/unraid/pxeforge.xml — Docker template for the Unraid UI. Forces NetworkType=host (PXE needs raw L2 broadcast — bridge mode doesn't work, full stop), declares the right cap-add, and surfaces PXEFORGE_PUBLIC_IP / PXEFORGE_LOG as configurable variables. 3. deploy/unraid/README.md — three documented paths (registry, compose from cloned repo, docker load from tarball) and the gotchas that actually bite (DHCP collision, host networking, perms on /mnt/user/appdata, NFS-needs-CAP_SYS_ADMIN). The build host I'm running on can't reach Unraid right now (LAN moved to a different subnet) and the Cloudflare WAF skip rule on gitea.milesward.dev doesn't yet cover /v2/* or /git-{upload,receive}-pack paths, so the publish has to happen from the Unraid host itself for now. This commit is what makes that one-shot. --- deploy/unraid/README.md | 136 ++++++++++++++++++++++++++++ deploy/unraid/pxeforge.xml | 75 +++++++++++++++ scripts/build-and-publish-unraid.sh | 114 +++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 deploy/unraid/README.md create mode 100644 deploy/unraid/pxeforge.xml create mode 100755 scripts/build-and-publish-unraid.sh diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md new file mode 100644 index 0000000..392e7e7 --- /dev/null +++ b/deploy/unraid/README.md @@ -0,0 +1,136 @@ +# PXEForge on Unraid + +Three paths from "I have an Unraid box with Gitea on it" to "PXE clients +boot from PXEForge". Pick the one that matches what you have. + +## Path A — build on Unraid, push to Gitea registry, pull by tag + +Recommended once you've done it once. Image is published to +`gitea.milesward.dev/mward4/pxeforge:0.1.0` (or your equivalent) and +every Unraid template / docker-compose just references the tag. + +Pre-flight: + +- Gitea has the **Container Registry** enabled (Site Admin → Configuration → set + `[packages] ENABLED = true` in `app.ini`; default-on in Gitea ≥ 1.18). +- You have a Gitea **personal access token** with `write:package` scope + (User → Settings → Applications → Generate New Token, check `package` + read+write). + +Run on the Unraid host (Settings → Terminal, or `ssh root@unraid`): + +```bash +# Pull just the build script straight from Gitea (no clone needed first): +GITEA_TOKEN= +curl -fsSL \ + -H "Authorization: token $GITEA_TOKEN" \ + http://localhost:3000/mward4/PXEForge/raw/branch/main/scripts/build-and-publish-unraid.sh \ + -o /tmp/pxeforge-publish.sh + +# Run it. ~6 min on Unraid hardware (native amd64, no QEMU). +chmod +x /tmp/pxeforge-publish.sh +GITEA_TOKEN=$GITEA_TOKEN /tmp/pxeforge-publish.sh +``` + +What it does: + +1. Shallow-clones the repo from local Gitea (no Cloudflare, no LAN issues). +2. Runs `scripts/fetch-ipxe.sh` to grab the iPXE binaries from the official + release URLs. +3. `docker build` against `deploy/docker/Dockerfile`. +4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG` + so the credential never lands in your real `~/.docker/config.json`. +5. `docker push` both `:0.1.0` and `:latest`. +6. Logout, scrub the temp config, delete the workspace. + +After it finishes, in Unraid → Docker → Add Container, set: + +| Field | Value | +|------------|-------------------------------------------------| +| Repository | `gitea.milesward.dev/mward4/pxeforge:0.1.0` | +| Network | `host` | +| Extra args | `--cap-add=NET_BIND_SERVICE` | + +Volume mounts (paths inside container in **bold**): + +- **`/var/lib/pxeforge/isos`** ↔ `/mnt/user/appdata/pxeforge/isos` +- **`/var/lib/pxeforge/work`** ↔ `/mnt/user/appdata/pxeforge/work` +- **`/var/lib/pxeforge/smb`** ↔ `/mnt/user/appdata/pxeforge/smb` + +Or skip the manual UI by dropping `pxeforge.xml` (in this directory) +into `/boot/config/plugins/dockerMan/templates-user/` and Unraid will +list it as a one-click template. + +Web UI: `http:///`. + +## Path B — `docker-compose up` directly from cloned repo + +Skip the registry entirely. Useful for "hack on it locally" iterations. + +```bash +ssh root@unraid +cd /mnt/user/appdata +git clone http://localhost:3000/mward4/PXEForge.git pxeforge-src +cd pxeforge-src +bash scripts/fetch-ipxe.sh +docker compose -f docker-compose.yml up -d --build pxeforge +``` + +The bundled `docker-compose.yml` already wires host networking, the +right cap_add, and bind-mounts to `./data/`. Edit those bind-mount +paths if you want them under `/mnt/user/appdata/pxeforge/`. + +## Path C — `docker load` from a tarball I built off-box + +For when the build host can reach Unraid but Unraid can't reach the +internet. Build the image somewhere with a cargo + buildx environment, +then: + +```bash +# On the build host +docker save pxeforge:0.1.0 | gzip > pxeforge-0.1.0.tar.gz + +# Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet) +scp pxeforge-0.1.0.tar.gz root@unraid:/tmp/ + +# On Unraid +gunzip -c /tmp/pxeforge-0.1.0.tar.gz | docker load +docker tag pxeforge:0.1.0 gitea.milesward.dev/mward4/pxeforge:0.1.0 +``` + +If you want it pullable by tag from other Unraid templates, push to +Gitea afterward (path A's last step). + +## Verifying it works + +Once the container is up, from any LAN host: + +```bash +# Should return 'ok' +curl -s http:///healthz +# Should return 'ready' if iPXE binaries are bundled +curl -s http:///readyz +``` + +Web UI: `http:///`. The Dashboard's top-bar pill should +read `● ready`. Upload an ISO, boot a test machine PXE → it should +show up in the Dashboard's "Recent connections" table within seconds. + +## Common gotchas + +- **DHCP collision.** Don't run two PXE _proxies_ on the same broadcast + domain. PXEForge runs in proxy mode and never offers IP leases, so + it coexists with whatever DHCP server is already on the network — + but two proxies racing each other will whichever-wins at random. +- **Host networking only.** Bridge mode containers don't see broadcast + DHCP. There's no working bridge-mode config for a PXE server. +- **Permissions on `/mnt/user/appdata/pxeforge`.** The container runs + as uid 10001 by default. The entrypoint chowns the bind mounts to + 10001 on first start, but only if the container itself has root — + `--user=root` isn't needed; the multi-stage Dockerfile starts as + root, fixes perms, then drops to pxeforge via gosu. +- **NFS mounts in the Storage tab.** Mounting NFS inside the container + needs `CAP_SYS_ADMIN`. To enable, add `--cap-add=SYS_ADMIN` to the + Unraid template's "Extra args" — but understand that's a meaningful + capability, comparable to `--privileged`. Skip it if you only need + uploads. diff --git a/deploy/unraid/pxeforge.xml b/deploy/unraid/pxeforge.xml new file mode 100644 index 0000000..d01fd08 --- /dev/null +++ b/deploy/unraid/pxeforge.xml @@ -0,0 +1,75 @@ + + + + PXEForge + gitea.milesward.dev/mward4/pxeforge:latest + https://gitea.milesward.dev/mward4/-/packages/container/pxeforge + host + + sh + false + https://gitea.milesward.dev/mward4/PXEForge/issues + https://gitea.milesward.dev/mward4/PXEForge + + Air-gapped network PXE boot server. Container-native Rust + implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO + streaming, all in one process. Web UI for ISO upload, NFS share + mounting, and Gated Deployment ("horse-race" simultaneous launch + of one ISO across many waiting clients). + + NEVER touches the client OS trust store: no test-signed drivers, + no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla + Microsoft-signed WinPE + SMB share. + + Network:Other Network:Management + http://[IP]/ + + https://gitea.milesward.dev/mward4/PXEForge/raw/branch/main/crates/webui/src/logo.svg + --cap-add=NET_BIND_SERVICE + + + + + + + Host networking. Unraid's built-in DHCP server (if any) must + not collide with a network that already has DHCP — PXEForge runs + in proxy mode and coexists, but only one DHCP _proxy_ should reply + per broadcast domain. + + /mnt/user/appdata/pxeforge/isos + /mnt/user/appdata/pxeforge/work + /mnt/user/appdata/pxeforge/smb + + info,pxeforge=debug + diff --git a/scripts/build-and-publish-unraid.sh b/scripts/build-and-publish-unraid.sh new file mode 100755 index 0000000..993081f --- /dev/null +++ b/scripts/build-and-publish-unraid.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# build-and-publish-unraid.sh — one-shot: clone PXEForge, build the image, +# push it to your local Gitea container registry. Run this ON the Unraid +# box (or any host that can reach Gitea on http://localhost:3000 or its +# LAN IP). No Cloudflare in the way; the proxy doesn't matter for this +# path. +# +# Inputs (env vars, all optional except GITEA_TOKEN): +# GITEA_TOKEN personal access token with `write:package` scope +# GITEA_HOST default: localhost:3000 (use 192.168.1.49:3000 if +# you're on the LAN but not on the Unraid host) +# GITEA_OWNER default: mward4 +# GITEA_REPO default: PXEForge +# IMAGE_TAG default: 0.1.0 (also tagged :latest) +# PLATFORM default: linux/amd64 (Unraid is x86_64) +# WORKDIR default: /tmp/pxeforge-build (deleted on success) +# +# What it does: +# 1. git clone /mward4/PXEForge.git into WORKDIR +# 2. fetch iPXE binaries (scripts/fetch-ipxe.sh) +# 3. docker build deploy/docker/Dockerfile -> pxeforge:$TAG (and :latest) +# 4. docker login to GITEA_HOST using the token +# 5. docker push to //pxeforge: and :latest +# 6. docker logout, scrub creds, clean WORKDIR +# +# After this, on any Unraid Docker template, set: +# Repository: /mward4/pxeforge:0.1.0 (or :latest) +# Network: host (DHCP/TFTP need raw L2) + +set -euo pipefail + +GITEA_HOST=${GITEA_HOST:-localhost:3000} +GITEA_OWNER=${GITEA_OWNER:-mward4} +GITEA_REPO=${GITEA_REPO:-PXEForge} +IMAGE_TAG=${IMAGE_TAG:-0.1.0} +PLATFORM=${PLATFORM:-linux/amd64} +WORKDIR=${WORKDIR:-/tmp/pxeforge-build} + +# Lowercase the image name — OCI distribution rejects uppercase paths. +IMAGE_NAME="$(printf '%s' "$GITEA_REPO" | tr '[:upper:]' '[:lower:]')" +FULL_TAG="$GITEA_HOST/$GITEA_OWNER/$IMAGE_NAME:$IMAGE_TAG" +LATEST_TAG="$GITEA_HOST/$GITEA_OWNER/$IMAGE_NAME:latest" + +if [[ -z "${GITEA_TOKEN:-}" ]]; then + echo "ERROR: set GITEA_TOKEN to a token with write:package scope." >&2 + echo " Gitea -> Settings -> Applications -> Generate New Token." >&2 + exit 2 +fi + +# Required tools — fail loudly upfront so the operator doesn't wait +# 20 minutes on a build that was going to fail at `docker push`. +for tool in git docker; do + command -v "$tool" >/dev/null 2>&1 \ + || { echo "ERROR: '$tool' not found in PATH" >&2; exit 2; } +done + +# Use a private docker config so the credential never lands in +# ~/.docker/config.json on the Unraid box. We blow it away in the +# trap below. +DOCKER_CONFIG="$(mktemp -d)" +export DOCKER_CONFIG +trap 'rm -rf "$DOCKER_CONFIG"; rm -rf "$WORKDIR"' EXIT + +echo +echo "==> Workspace : $WORKDIR" +echo "==> Gitea host : $GITEA_HOST" +echo "==> Image to push : $FULL_TAG" +echo "==> Platform : $PLATFORM" +echo + +# Step 1: clone (shallow — we don't need history for a build). +rm -rf "$WORKDIR" +git -c "http.extraHeader=Authorization: token $GITEA_TOKEN" \ + clone --depth 1 \ + "http://$GITEA_HOST/$GITEA_OWNER/$GITEA_REPO.git" "$WORKDIR" + +cd "$WORKDIR" + +# Step 2: fetch iPXE binaries. The Dockerfile's first stage will also do +# this, but doing it once here means a re-run with a hot Docker cache +# skips the apt-get install + GitHub fetch entirely. +bash scripts/fetch-ipxe.sh + +# Step 3: build. We don't need buildx for a same-arch build on Unraid, +# but we use --pull so a stale cached `rust:1.82-bookworm` doesn't bake +# in a CVE that's been fixed upstream. +docker build \ + --pull \ + --platform "$PLATFORM" \ + --file deploy/docker/Dockerfile \ + --tag "$FULL_TAG" \ + --tag "$LATEST_TAG" \ + . + +# Step 4: login. --password-stdin keeps the token out of `ps` output and +# bash history. The token still lands in $DOCKER_CONFIG/config.json +# (base64-encoded) but that dir is ours and gets nuked by the trap. +printf '%s' "$GITEA_TOKEN" | docker login \ + --username "$GITEA_OWNER" \ + --password-stdin \ + "$GITEA_HOST" + +# Step 5: push both tags. +docker push "$FULL_TAG" +docker push "$LATEST_TAG" + +# Step 6: logout (best-effort; if it fails the trap still scrubs). +docker logout "$GITEA_HOST" || true + +echo +echo "==> Done. Pull from Unraid templates with:" +echo " $FULL_TAG" +echo " $LATEST_TAG" +echo