diff --git a/.forgejo/workflows/deploy-runner-images.yml b/.forgejo/workflows/deploy-runner-images.yml new file mode 100644 index 0000000..d7463cc --- /dev/null +++ b/.forgejo/workflows/deploy-runner-images.yml @@ -0,0 +1,276 @@ +name: deploy-runner-images + +on: + push: + tags: [boot-v*] + workflow_dispatch: + inputs: + release_tag: + description: "Release tag override (manual runs only)" + required: false + default: "" + publish_release: + description: "Publish artifacts to Forgejo release (true/false)" + required: false + default: "true" + build_x86_64_netboot: + description: "Build x86_64 netboot tarball (true/false)" + required: false + default: "true" + build_x86_64_iso: + description: "Build x86_64 installer ISO (true/false)" + required: false + default: "true" + +concurrency: + group: runner-image-deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-release: + if: ${{ github.server_url != 'https://codeberg.org' }} + runs-on: codeberg-medium-lazy + steps: + - name: Fetch source (no git required) + env: + GITHUB_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + echo "error: missing github.token" + exit 2 + fi + if ! command -v curl >/dev/null 2>&1; then + echo "error: curl is required" + exit 2 + fi + if ! command -v tar >/dev/null 2>&1; then + echo "error: tar is required" + exit 2 + fi + if [[ -z "${GITHUB_SHA:-}" ]]; then + echo "error: missing GITHUB_SHA" + exit 2 + fi + if [[ -z "${GITHUB_SERVER_URL:-}" ]]; then + echo "error: missing GITHUB_SERVER_URL" + exit 2 + fi + if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + echo "error: missing GITHUB_REPOSITORY" + exit 2 + fi + + rm -rf .repo + mkdir -p .repo + curl -fsSL -H "Authorization: token ${GITHUB_TOKEN}" \ + "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz?rev=${GITHUB_SHA}" \ + -o .repo/src.tgz + tar -xzf .repo/src.tgz -C .repo --strip-components=1 + rm -f .repo/src.tgz + + - name: Bootstrap Nix + shell: bash + run: | + set -euo pipefail + if ! command -v nix >/dev/null 2>&1; then + curl -fsSL https://nixos.org/nix/install -o /tmp/install-nix.sh + sh /tmp/install-nix.sh --no-daemon --yes + fi + + if [[ -f "$HOME/.nix-profile/etc/profile.d/nix.sh" ]]; then + # shellcheck disable=SC1091 + . "$HOME/.nix-profile/etc/profile.d/nix.sh" + fi + if [[ -d "$HOME/.nix-profile/bin" ]]; then + echo "PATH=$HOME/.nix-profile/bin:$PATH" >> "$GITHUB_ENV" + export PATH="$HOME/.nix-profile/bin:$PATH" + fi + nix --version + + - name: Resolve build plan + id: plan + env: + INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }} + INPUT_PUBLISH_RELEASE: ${{ github.event.inputs.publish_release }} + INPUT_BUILD_X86_64_NETBOOT: ${{ github.event.inputs.build_x86_64_netboot }} + INPUT_BUILD_X86_64_ISO: ${{ github.event.inputs.build_x86_64_iso }} + shell: bash + run: | + set -euo pipefail + + bool_norm() { + local raw + raw="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" + case "${raw}" in + ''|true|1|yes|y|on) echo "true" ;; + false|0|no|n|off) echo "false" ;; + *) + echo "error: invalid boolean value '${1}'" >&2 + exit 2 + ;; + esac + } + + short_sha="${GITHUB_SHA:0:12}" + if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + release_tag="${GITHUB_REF#refs/tags/}" + else + release_tag="${INPUT_RELEASE_TAG:-}" + if [[ -z "${release_tag}" ]]; then + release_tag="boot-${short_sha}" + fi + fi + + if [[ ! "${release_tag}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "error: release tag contains unsupported characters: ${release_tag}" >&2 + exit 2 + fi + + publish_release="$(bool_norm "${INPUT_PUBLISH_RELEASE:-true}")" + build_x86_64_netboot="$(bool_norm "${INPUT_BUILD_X86_64_NETBOOT:-true}")" + build_x86_64_iso="$(bool_norm "${INPUT_BUILD_X86_64_ISO:-true}")" + + if [[ "${build_x86_64_netboot}" != "true" && "${build_x86_64_iso}" != "true" ]]; then + echo "error: at least one image build must be enabled" >&2 + exit 2 + fi + + artifact_suffix="${short_sha}-${GITHUB_RUN_NUMBER:-0}-${GITHUB_RUN_ATTEMPT:-1}" + + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "publish_release=${publish_release}" >> "$GITHUB_OUTPUT" + echo "build_x86_64_netboot=${build_x86_64_netboot}" >> "$GITHUB_OUTPUT" + echo "build_x86_64_iso=${build_x86_64_iso}" >> "$GITHUB_OUTPUT" + echo "artifact_suffix=${artifact_suffix}" >> "$GITHUB_OUTPUT" + + - name: Build runner boot images + env: + BUILD_X86_64_NETBOOT: ${{ steps.plan.outputs.build_x86_64_netboot }} + BUILD_X86_64_ISO: ${{ steps.plan.outputs.build_x86_64_iso }} + ARTIFACT_SUFFIX: ${{ steps.plan.outputs.artifact_suffix }} + shell: bash + run: | + set -euo pipefail + cd .repo + + if [[ -f "$HOME/.nix-profile/etc/profile.d/nix.sh" ]]; then + # shellcheck disable=SC1091 + . "$HOME/.nix-profile/etc/profile.d/nix.sh" + fi + export PATH="$HOME/.nix-profile/bin:$PATH" + + artifacts_dir="$PWD/.artifacts" + rm -rf "${artifacts_dir}" + mkdir -p "${artifacts_dir}" + + nix_args=(--accept-flake-config --extra-experimental-features "nix-command flakes") + + if [[ "${BUILD_X86_64_NETBOOT}" == "true" ]]; then + nix build "${nix_args[@]}" \ + .#nixosConfigurations.ec-runner-x86_64-netboot.config.system.build.netboot \ + -o result-netboot-x86_64 + tar -C result-netboot-x86_64 \ + -czf "${artifacts_dir}/ec-runner-x86_64-netboot-${ARTIFACT_SUFFIX}.tar.gz" \ + kernel initrd netboot.ipxe + fi + + if [[ "${BUILD_X86_64_ISO}" == "true" ]]; then + nix build "${nix_args[@]}" \ + .#nixosConfigurations.ec-runner-x86_64-iso.config.system.build.isoImage \ + -o result-iso-x86_64 + iso_source="" + if [[ -f result-iso-x86_64 ]]; then + iso_source="result-iso-x86_64" + else + iso_source="$(find -L result-iso-x86_64 -type f -name '*.iso' | head -n 1 || true)" + fi + if [[ -z "${iso_source}" ]]; then + echo "error: could not locate ISO output from result-iso-x86_64" >&2 + exit 2 + fi + cp -f "${iso_source}" "${artifacts_dir}/ec-runner-x86_64-iso-${ARTIFACT_SUFFIX}.iso" + fi + + if ! find "${artifacts_dir}" -maxdepth 1 -type f | grep -q .; then + echo "error: no image artifacts were produced" >&2 + exit 2 + fi + + ( + cd "${artifacts_dir}" + sha256sum -- * > SHA256SUMS.txt + ls -lh + ) + + - name: Publish artifacts to Forgejo release + if: ${{ steps.plan.outputs.publish_release == 'true' }} + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.plan.outputs.release_tag }} + shell: bash + run: | + set -euo pipefail + cd .repo + + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + echo "error: missing github.token" + exit 2 + fi + if [[ -z "${GITHUB_SERVER_URL:-}" ]]; then + echo "error: missing GITHUB_SERVER_URL" + exit 2 + fi + if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + echo "error: missing GITHUB_REPOSITORY" + exit 2 + fi + + api_base="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}" + release_json="$(curl -fsSL \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + "${api_base}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)" + + if [[ -z "${release_json}" ]]; then + payload="$(cat </dev/null 2>&1; then + release_id="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"${release_json}" 2>/dev/null || true)" + fi + if [[ -z "${release_id}" ]]; then + release_id="$(printf '%s' "${release_json}" \ + | sed -nE 's/.*"id"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' \ + | head -n 1)" + fi + if [[ -z "${release_id}" ]]; then + echo "error: failed to resolve release id for ${RELEASE_TAG}" >&2 + exit 2 + fi + + for asset_path in .artifacts/*; do + [[ -f "${asset_path}" ]] || continue + asset_name="$(basename "${asset_path}")" + curl -fsSL -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "content-type: application/octet-stream" \ + --data-binary @"${asset_path}" \ + "${api_base}/releases/${release_id}/assets?name=${asset_name}" >/dev/null + echo "uploaded: ${asset_name}" + done diff --git a/README.md b/README.md index e4e5cad..6124b6f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ Git hosting topology: cat docs/GIT_HOSTING.md ``` +NUC PXE rollout (Unifi + ProxyDHCP): + +```sh +cat docs/NUC_UNIFI_NETBOOT.md +``` + ## WebTransport Watch (MoQ) Publish (node -> Cloudflare relay): diff --git a/docs/NUC_UNIFI_NETBOOT.md b/docs/NUC_UNIFI_NETBOOT.md new file mode 100644 index 0000000..e3fbe55 --- /dev/null +++ b/docs/NUC_UNIFI_NETBOOT.md @@ -0,0 +1,102 @@ +# NUC Fleet Netboot (Unifi + ProxyDHCP) + +This runbook provisions x86_64 NUCs from runner netboot artifacts without USB image flashing. + +It uses: + +- Unifi DHCP for IP leases. +- Local `dnsmasq` ProxyDHCP for PXE/iPXE bootfile logic. +- Local HTTP + TFTP service for boot artifacts. + +## Why ProxyDHCP + +iPXE commonly needs two boot stages: + +1. firmware PXE -> `ipxe.efi` +2. iPXE -> `netboot.ipxe` + +If DHCP always returns `ipxe.efi`, clients can loop forever. ProxyDHCP handles stage-specific boot responses cleanly while leaving Unifi as the DHCP lease server. + +## Prerequisites + +- A Linux boot server on the same VLAN/L2 domain as the NUCs. +- Unifi network with normal DHCP enabled. +- Local DNS record on that VLAN: `boot.every.channel -> `. +- `curl`, `tar`, `python3`, `dnsmasq` installed on the boot server. +- Runner netboot artifact already published to Forgejo Releases (or available as a local tarball). + +## 1) Stage artifacts + +From repository root on the boot server: + +```sh +./scripts/netboot-stage.sh +``` + +Optional inputs: + +- `EVERY_CHANNEL_NETBOOT_RELEASE_TAG=boot-v2026.02.28` +- `EVERY_CHANNEL_NETBOOT_TARBALL=/path/to/ec-runner-x86_64-netboot-....tar.gz` +- `EVERY_CHANNEL_FORGE_TOKEN=` for private releases +- `EVERY_CHANNEL_NETBOOT_HOSTNAME=boot.every.channel` + +This stages: + +- `tmp/netboot/http/{kernel,initrd,netboot.ipxe}` +- `tmp/netboot/tftp/ipxe.efi` + +## 2) Serve HTTP + TFTP + ProxyDHCP + +Example (replace values for your VLAN): + +```sh +sudo \ + EVERY_CHANNEL_NETBOOT_LISTEN_IP=10.20.30.2 \ + EVERY_CHANNEL_NETBOOT_INTERFACE=eth0 \ + EVERY_CHANNEL_NETBOOT_PROXY_SUBNET=10.20.30.0/24 \ + EVERY_CHANNEL_NETBOOT_HOSTNAME=boot.every.channel \ + ./scripts/netboot-serve.sh +``` + +Notes: + +- Keep this process running during provisioning. +- Do not set Unifi DHCP bootfile options while this proxy mode is active. +- Ensure `boot.every.channel` resolves to the boot server IP from NUC clients. + +## 3) Unifi / NUC settings + +Unifi: + +- Keep DHCP enabled for the provisioning VLAN. +- Leave DHCP boot/TFTP overrides unset when using `netboot-serve.sh`. +- Create/verify local DNS host override: `boot.every.channel -> `. + +NUC BIOS: + +- Enable UEFI network boot (IPv4 PXE). +- Disable Legacy/CSM if possible. +- Put network boot before disk for first install cycle. + +## 4) Provision the fleet + +1. Boot each NUC on the provisioning VLAN. +2. PXE will chainload into iPXE and then runner `netboot.ipxe`. +3. Complete install/bootstrap flow on each node. +4. After successful install, switch boot order back to local disk. + +## Troubleshooting + +- Symptom: iPXE loop (`ipxe.efi` repeatedly) + - Cause: static DHCP bootfile without iPXE-aware logic. + - Fix: use ProxyDHCP flow (`netboot-serve.sh`) or set conditional DHCP rules. +- Symptom: NUC gets IP but never downloads boot artifacts + - Verify firewall allows UDP 67/68, UDP 69, and TCP 8080 between NUCs and boot server. +- Symptom: no `dnsmasq` offers seen + - Verify `EVERY_CHANNEL_NETBOOT_INTERFACE` and `EVERY_CHANNEL_NETBOOT_PROXY_SUBNET`. + +## Security / networking + +- Tailscale is not required for provisioning. +- Keep the provisioning VLAN isolated from regular clients. +- Stop `netboot-serve.sh` when rollout is complete. diff --git a/docs/RUNNER_IMAGES.md b/docs/RUNNER_IMAGES.md index ffe5c22..4d32893 100644 --- a/docs/RUNNER_IMAGES.md +++ b/docs/RUNNER_IMAGES.md @@ -50,6 +50,36 @@ Build an aarch64 SD image: nix build .#nixosConfigurations.ec-runner-aarch64-sdimage.config.system.build.sdImage ``` +## CI Deploy (Forgejo Releases) + +Boot images can be built and published from CI via: + +- `.forgejo/workflows/deploy-runner-images.yml` + +Triggers: + +- Manual: `workflow_dispatch` +- Tags: `boot-v*` (for example `boot-v2026.02.28`) + +Manual inputs (all optional): + +- `release_tag` (defaults to `boot-`) +- `publish_release` (`true`/`false`, default `true`) +- `build_x86_64_netboot` (`true`/`false`, default `true`) +- `build_x86_64_iso` (`true`/`false`, default `true`) + +Published assets are attached to the resolved Forgejo release tag and include: + +- x86_64 netboot bundle (`kernel`, `initrd`, `netboot.ipxe`) as `.tar.gz` +- x86_64 installer `.iso` +- `SHA256SUMS.txt` + +Notes: + +- CI image publish is disabled on the Codeberg mirror host. +- Current CI scope is x86_64 targets; aarch64 image builds remain local/manual unless an aarch64-capable runner is added. +- For multi-NUC PXE rollout on Unifi networks, use `docs/NUC_UNIFI_NETBOOT.md`. + ## Outputs After building, artifacts will be in `./result` (a symlink into the Nix store). diff --git a/evolution/proposals/ECP-0081-ci-boot-image-deploy.md b/evolution/proposals/ECP-0081-ci-boot-image-deploy.md new file mode 100644 index 0000000..a70c6ef --- /dev/null +++ b/evolution/proposals/ECP-0081-ci-boot-image-deploy.md @@ -0,0 +1,35 @@ +# ECP-0081: CI Boot Image Deployment to Forgejo Releases + +Status: Implemented + +## Context + +Runner boot artifacts (netboot/ISO/SD) are currently built by hand from local Linux/Nix environments. That slows repeatable infra rollout and weakens traceability from commit to runnable images. + +The constitution requires infrastructure definitions to live in-repo and stay independently operable. Boot image publication should follow the same Forgejo-primary CI model used for web deploys, while mirror hosts remain distribution-only. + +## Decision + +1. Add a dedicated Forgejo Actions workflow to build runner boot images from flake outputs in CI. +2. Trigger this workflow on: + - manual dispatch (`workflow_dispatch`), and + - release-style tags (`boot-v*`). +3. Publish built artifacts to Forgejo Releases on the primary host using repository-scoped API calls and the workflow token. +4. Exclude Codeberg mirror runs using the existing server guard (`github.server_url != 'https://codeberg.org'`). +5. Start with x86_64 image targets in CI (`netboot`, `iso`) to keep runtime/runner requirements explicit and reversible. + +## Alternatives considered + +- Keep boot image builds fully manual. Rejected because operator discipline alone does not provide consistent provenance. +- Push images to external object storage first. Rejected for now because Forgejo Releases are already part of the controlled primary platform. +- Build all architectures in one CI pass immediately. Rejected because runner architecture availability is not guaranteed and would make initial rollout brittle. + +## Rollout / teardown plan + +- Rollout: + - merge workflow + docs, + - run a manual dispatch to verify artifact publication, + - optionally create `boot-v*` tags for versioned image drops. +- Teardown: + - disable or delete the boot-image workflow, + - continue using local/manual `nix build` paths from `docs/RUNNER_IMAGES.md`. diff --git a/evolution/proposals/ECP-0082-unifi-pxe-runner-rollout.md b/evolution/proposals/ECP-0082-unifi-pxe-runner-rollout.md new file mode 100644 index 0000000..69efab2 --- /dev/null +++ b/evolution/proposals/ECP-0082-unifi-pxe-runner-rollout.md @@ -0,0 +1,36 @@ +# ECP-0082: Unifi PXE Rollout Path for Runner Images + +Status: Implemented + +## Context + +Runner netboot artifacts now publish from CI, but there is no repository-native operating path for fleet provisioning on common prosumer networks (for example Unifi VLANs). + +Unifi DHCP can expose next-server/bootfile settings, but iPXE chainloading often requires conditional bootfile behavior to avoid loops (`ipxe.efi` first stage, script second stage). Not all controller setups expose that cleanly. + +## Decision + +1. Add first-party scripts for local netboot staging and serving: + - stage x86_64 netboot artifacts from Forgejo Releases (or local tarball), + - stage iPXE UEFI binary for TFTP, + - run HTTP + TFTP + ProxyDHCP via `dnsmasq` for deterministic chainloading. +2. Keep Unifi DHCP as the IP authority; use ProxyDHCP only to supply bootfile logic. +3. Document a concrete NUC rollout sequence for same-VLAN provisioning. +4. Keep dependencies minimal (`curl`, `tar`, `python3`, `dnsmasq`) and avoid requiring image flashing workflows. + +## Alternatives considered + +- Require Unifi DHCP conditional iPXE rules. Rejected because controller capabilities vary and misconfiguration risks boot loops. +- Keep manual USB-only provisioning. Rejected because it increases labor for multi-node rollout. +- Add a heavy provisioning stack (MAAS/Foreman/Kickstart integration). Rejected as too much operational overhead for current scale. + +## Rollout / teardown plan + +- Rollout: + - merge scripts/docs, + - run `netboot-stage` on the boot server, + - run `netboot-serve` on the NUC VLAN and boot hosts via PXE. +- Teardown: + - stop `netboot-serve`, + - remove staged artifacts under `tmp/netboot`, + - continue with ISO+USB fallback path. diff --git a/justfile b/justfile index 9cf8b9e..de15e6a 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,12 @@ git-hosting: git-mirrors: ./scripts/git-push-mirrors.sh +netboot-stage: + ./scripts/netboot-stage.sh + +netboot-serve: + ./scripts/netboot-serve.sh + test-core: cargo test -p ec-core -p ec-crypto -p ec-moq -p ec-iroh -p ec-linux-iptv diff --git a/scripts/netboot-serve.sh b/scripts/netboot-serve.sh new file mode 100755 index 0000000..3b9e190 --- /dev/null +++ b/scripts/netboot-serve.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${root}" + +netboot_root="${EVERY_CHANNEL_NETBOOT_ROOT:-tmp/netboot}" +http_dir="${netboot_root}/http" +tftp_dir="${netboot_root}/tftp" + +listen_ip="${EVERY_CHANNEL_NETBOOT_LISTEN_IP:-}" +interface_name="${EVERY_CHANNEL_NETBOOT_INTERFACE:-}" +proxy_subnet="${EVERY_CHANNEL_NETBOOT_PROXY_SUBNET:-}" +netboot_hostname="${EVERY_CHANNEL_NETBOOT_HOSTNAME:-}" +http_port="${EVERY_CHANNEL_NETBOOT_HTTP_PORT:-8080}" +dnsmasq_port="${EVERY_CHANNEL_NETBOOT_DNS_PORT:-0}" + +need_cmd() { + local name="$1" + if ! command -v "${name}" >/dev/null 2>&1; then + echo "error: required command not found: ${name}" >&2 + exit 2 + fi +} + +need_cmd dnsmasq +need_cmd python3 + +if [[ "$(id -u)" -ne 0 ]]; then + echo "error: netboot-serve requires root (TFTP + ProxyDHCP ports)." >&2 + echo "hint: run with sudo and pass env vars, for example:" >&2 + echo " sudo EVERY_CHANNEL_NETBOOT_LISTEN_IP=10.20.30.2 EVERY_CHANNEL_NETBOOT_INTERFACE=eth0 EVERY_CHANNEL_NETBOOT_PROXY_SUBNET=10.20.30.0/24 EVERY_CHANNEL_NETBOOT_HOSTNAME=boot.every.channel ./scripts/netboot-serve.sh" >&2 + exit 2 +fi + +if [[ -z "${listen_ip}" ]]; then + echo "error: set EVERY_CHANNEL_NETBOOT_LISTEN_IP (boot server IP on NUC VLAN)" >&2 + exit 2 +fi +if [[ -z "${interface_name}" ]]; then + echo "error: set EVERY_CHANNEL_NETBOOT_INTERFACE (interface on NUC VLAN)" >&2 + exit 2 +fi +if [[ -z "${proxy_subnet}" ]]; then + echo "error: set EVERY_CHANNEL_NETBOOT_PROXY_SUBNET (for example 10.20.30.0/24)" >&2 + exit 2 +fi +if [[ -z "${netboot_hostname}" ]]; then + netboot_hostname="${listen_ip}" +fi + +for required in "${http_dir}/kernel" "${http_dir}/initrd" "${http_dir}/netboot.ipxe" "${tftp_dir}/ipxe.efi"; do + if [[ ! -f "${required}" ]]; then + echo "error: missing required staged file: ${required}" >&2 + echo "hint: run ./scripts/netboot-stage.sh first" >&2 + exit 2 + fi +done + +run_dir="$(mktemp -d)" +cleanup() { + if [[ -n "${http_pid:-}" ]] && kill -0 "${http_pid}" >/dev/null 2>&1; then + kill "${http_pid}" >/dev/null 2>&1 || true + wait "${http_pid}" 2>/dev/null || true + fi + rm -rf "${run_dir}" +} +trap cleanup EXIT INT TERM + +cat > "${run_dir}/dnsmasq.conf" </tmp/every-channel-netboot-http.log 2>&1 & +http_pid="$!" + +echo "ok: HTTP serving ${http_dir} on http://${listen_ip}:${http_port}/" +echo "ok: advertised netboot host: ${netboot_hostname}" +echo "ok: TFTP serving ${tftp_dir} on ${listen_ip}:69" +echo "ok: ProxyDHCP active for ${proxy_subnet} on interface ${interface_name}" +echo "ok: Use normal Unifi DHCP for IP assignment; do not configure Unifi DHCP bootfile while proxy mode is active." +echo +echo "Press Ctrl+C to stop." +dnsmasq --no-daemon --conf-file="${run_dir}/dnsmasq.conf" diff --git a/scripts/netboot-stage.sh b/scripts/netboot-stage.sh new file mode 100755 index 0000000..83bebc2 --- /dev/null +++ b/scripts/netboot-stage.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${root}" + +forge_host="${EVERY_CHANNEL_FORGE_HOST:-https://forge.every.channel}" +forge_repo="${EVERY_CHANNEL_FORGE_REPO:-every-channel/every.channel}" +release_tag="${EVERY_CHANNEL_NETBOOT_RELEASE_TAG:-}" +local_tarball="${EVERY_CHANNEL_NETBOOT_TARBALL:-}" +out_root="${EVERY_CHANNEL_NETBOOT_ROOT:-tmp/netboot}" +ipxe_efi_url="${EVERY_CHANNEL_IPXE_EFI_URL:-https://boot.ipxe.org/snponly.efi}" +netboot_hostname="${EVERY_CHANNEL_NETBOOT_HOSTNAME:-boot.every.channel}" +http_port="${EVERY_CHANNEL_NETBOOT_HTTP_PORT:-8080}" +token="${EVERY_CHANNEL_FORGE_TOKEN:-${FORGE_TOKEN:-${CODEBERG_TOKEN:-}}}" + +need_cmd() { + local name="$1" + if ! command -v "${name}" >/dev/null 2>&1; then + echo "error: required command not found: ${name}" >&2 + exit 2 + fi +} + +need_cmd curl +need_cmd tar +need_cmd python3 + +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "${tmp_dir}" +} +trap cleanup EXIT + +archive_path="${tmp_dir}/netboot.tar.gz" +release_asset_url="" + +if [[ -n "${local_tarball}" ]]; then + if [[ ! -f "${local_tarball}" ]]; then + echo "error: netboot tarball not found: ${local_tarball}" >&2 + exit 2 + fi + cp -f "${local_tarball}" "${archive_path}" +else + api_base="${forge_host%/}/api/v1/repos/${forge_repo}" + release_endpoint="${api_base}/releases/latest" + if [[ -n "${release_tag}" ]]; then + release_endpoint="${api_base}/releases/tags/${release_tag}" + fi + + auth_args=() + if [[ -n "${token}" ]]; then + auth_args=(-H "Authorization: token ${token}") + fi + + release_json="${tmp_dir}/release.json" + curl -fsSL "${auth_args[@]}" "${release_endpoint}" -o "${release_json}" + + release_asset_url="$( + python3 - "${release_json}" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +assets = data.get("assets", []) +candidates = [] +for asset in assets: + name = asset.get("name", "") + if name.startswith("ec-runner-x86_64-netboot-") and name.endswith(".tar.gz"): + candidates.append(asset) + +if not candidates: + sys.exit(1) + +# Pick newest by release ordering if API already sorted; otherwise prefer largest id. +chosen = sorted(candidates, key=lambda x: x.get("id", 0))[-1] +print(chosen.get("browser_download_url", "")) +PY + )" + + if [[ -z "${release_asset_url}" ]]; then + echo "error: unable to find x86_64 netboot asset in release" >&2 + exit 2 + fi + + curl -fsSL "${auth_args[@]}" -o "${archive_path}" "${release_asset_url}" +fi + +http_dir="${out_root}/http" +tftp_dir="${out_root}/tftp" +rm -rf "${http_dir}" +mkdir -p "${http_dir}" "${tftp_dir}" + +tar -xzf "${archive_path}" -C "${http_dir}" + +for required in kernel initrd netboot.ipxe; do + if [[ ! -f "${http_dir}/${required}" ]]; then + echo "error: extracted netboot bundle is missing ${required}" >&2 + exit 2 + fi +done + +curl -fsSL -o "${tftp_dir}/ipxe.efi" "${ipxe_efi_url}" +cp -f "${http_dir}/netboot.ipxe" "${tftp_dir}/netboot.ipxe" + +cat > "${tftp_dir}/bootstrap.ipxe" <<'EOF' +#!ipxe +dhcp +chain http://__NETBOOT_HOST__:__HTTP_PORT__/netboot.ipxe +EOF +sed -i.bak \ + -e "s#__NETBOOT_HOST__#${netboot_hostname}#g" \ + -e "s#__HTTP_PORT__#${http_port}#g" \ + "${tftp_dir}/bootstrap.ipxe" +rm -f "${tftp_dir}/bootstrap.ipxe.bak" + +echo "ok: staged netboot content" +echo "ok: http root: ${http_dir}" +echo "ok: tftp root: ${tftp_dir}" +echo "ok: netboot hostname: ${netboot_hostname}" +echo "ok: netboot http port: ${http_port}" +if [[ -n "${release_asset_url}" ]]; then + echo "ok: source asset: ${release_asset_url}" +else + echo "ok: source asset: ${local_tarball}" +fi +echo "hint: run sudo ./scripts/netboot-serve.sh to expose HTTP+TFTP+ProxyDHCP"