From b74e16668c985edc6e43161a40d340148e71d63f Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Wed, 6 May 2026 03:19:21 +0300 Subject: [PATCH 01/16] feat(core): add artifact_ready extension hook for post-build artifacts Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- lib/functions/artifacts/artifacts-obtain.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/functions/artifacts/artifacts-obtain.sh b/lib/functions/artifacts/artifacts-obtain.sh index 50697a618b31..9dcdfa5604eb 100644 --- a/lib/functions/artifacts/artifacts-obtain.sh +++ b/lib/functions/artifacts/artifacts-obtain.sh @@ -311,6 +311,24 @@ function obtain_complete_artifact() { # reversioning removes the original in packages-hashed. debug_dict artifact_map_debs_reversioned LOG_SECTION="artifact_reversion_for_deployment" do_with_logging artifact_reversion_for_deployment + + # Reversioned .debs now sit under ${DEB_STORAGE} with their canonical + # names; converging point for build-from-sources, local-cache hit, and + # remote-cache fetch. Skipped on deploy_to_remote=yes — that path + # uploads to OCI and tears down ${artifact_base_dir} without leaving + # usable .debs on disk for downstream hooks to consume. + call_extension_method "artifact_ready" <<- 'ARTIFACT_READY' + *hook fired once an artifact is reversioned and ready to consume* + Called after `artifact_reversion_for_deployment` on the local-build + path (`deploy_to_remote!=yes`). The reversioned `.deb` filenames + are listed in `artifact_map_debs_reversioned_*`; the originals + under `${DEB_STORAGE}` no longer exist by this point. Context: + `WHAT` (CLI command), `artifact_name`, `artifact_version`, + `artifact_final_file`, `artifact_base_dir`, and the hash-keyed + `artifact_map_debs_*` / `artifact_map_packages_*` / + `artifact_map_debs_reversioned_*` arrays. Use this for side + effects that need a ready artifact (deploy, notification, …). + ARTIFACT_READY fi } From 3fa8cf00968c204c6328fb01eff75bf1307255f7 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:17:19 +0300 Subject: [PATCH 02/16] fix(templates): make nfs-boot.cmd.template architecture-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the network-boot u-boot template work for both arm64 (Image/booti) and armv7 (zImage/bootz). Load kernel/initrd/dtb from the local boot partition; mount root over NFS. Take console settings from DTB `/chosen/stdout-path` instead of hardcoded baud — boards like helios64 (1500000) and others (115200) work without per-board overrides. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- config/templates/nfs-boot.cmd.template | 72 +++++++++++++++++++++----- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/config/templates/nfs-boot.cmd.template b/config/templates/nfs-boot.cmd.template index f512ff234495..63fb73c235ce 100644 --- a/config/templates/nfs-boot.cmd.template +++ b/config/templates/nfs-boot.cmd.template @@ -7,27 +7,71 @@ setenv net_setup "ip=dhcp" # for static configuration see documentation -# https://github.com/torvalds/linux/blob/master/Documentation/admin-guide/nfs/nfsroot.rst +# https://www.kernel.org/doc/Documentation/admin-guide/nfs/nfsroot.rst # setenv net_setup "ip=::::::::" -# you may need to add extra kernel arguments specific to your device -setenv bootargs "console=tty1 console=ttyS0,115200 root=/dev/nfs ${net_setup} rw rootflags=noatime disp.screen0_output_mode=1920x1080p60 panic=10 consoleblank=0 enforcing=0 loglevel=6" +# No hardcoded `console=`: kernels resolve the console from the DTB's +# /chosen/stdout-path. Hardcoding a baud rate here breaks boards whose +# UART runs at non-115200 speeds (e.g. helios64 @ 1500000). +setenv bootargs "root=/dev/nfs ${net_setup} rw rootwait earlycon panic=10 loglevel=6" -if test -n ${nfs_root}; then +if test -n "${nfs_root}"; then setenv bootargs "${bootargs} nfsroot=${nfs_root}" fi -if ext4load mmc 0 0x00000000 .next || fatload mmc 0 0x00000000 .next; then - ext4load mmc 0 ${fdt_addr_r} /dtb/${fdtfile} || fatload mmc 0 ${fdt_addr_r} /dtb/${fdtfile} - ext4load mmc 0 ${kernel_addr_r} zImage || fatload mmc 0 ${kernel_addr_r} zImage - ext4load mmc 0 ${ramdisk_addr_r} uInitrd || fatload mmc 0 ${ramdisk_addr_r} uInitrd || setenv ramdisk_addr_r "-" - setenv fdt_high ffffffff - bootz ${kernel_addr_r} ${ramdisk_addr_r} ${fdt_addr_r} +# Load kernel + DTB + initrd from the active distro boot partition. +# devtype/devnum/distro_bootpart are set by U-Boot's bootflow scanner +# before this script runs. Boards that source boot.scr directly (legacy +# sunxi without distro_bootcmd, etc.) have these unset — fall back to +# the historical "mmc 0:1" so the existing ROOTFS_TYPE=nfs path keeps +# working there. +test -n "${devtype}" || setenv devtype mmc +test -n "${devnum}" || setenv devnum 0 +test -n "${distro_bootpart}" || setenv distro_bootpart 1 +setenv boot_dev "${devtype} ${devnum}:${distro_bootpart}" + +# Probe mainline marker: mainline rootfs has /boot/.next, legacy sunxi (pre-DT) +# does not — it ships script.bin in place of a DTB. +if load ${boot_dev} ${fdt_addr_r} .next; then + # Mainline: real DTB, arm64 flat Image or compressed zImage. + # Some boards (TI K3, BeagleBone) keep fdtfile with a .dts suffix; the + # tree installs the compiled .dtb, so we need to normalize before loading. + setenv dtb_name "${fdtfile}" + # U-Boot setexpr.s sub takes ` ` — the target is implicit + # (the named variable itself), which is both read and written in place. + setexpr.s dtb_name sub "\\.dts\$" ".dtb" + if load ${boot_dev} ${fdt_addr_r} dtb/${dtb_name}; then + true + else + echo "FATAL: failed to load DTB dtb/${dtb_name} (fdtfile=${fdtfile})" + reset + fi + load ${boot_dev} ${ramdisk_addr_r} uInitrd || setenv ramdisk_addr_r "-" + if load ${boot_dev} ${kernel_addr_r} Image; then + booti ${kernel_addr_r} ${ramdisk_addr_r} ${fdt_addr_r} + elif load ${boot_dev} ${kernel_addr_r} zImage; then + setenv fdt_high ffffffff + bootz ${kernel_addr_r} ${ramdisk_addr_r} ${fdt_addr_r} + else + echo "FATAL: failed to load kernel (neither Image nor zImage)" + reset + fi else - ext4load mmc 0 ${fdt_addr_r} script.bin || fatload mmc 0 ${fdt_addr_r} script.bin - ext4load mmc 0 ${kernel_addr_r} zImage || fatload mmc 0 ${kernel_addr_r} zImage - ext4load mmc 0 ${ramdisk_addr_r} uInitrd || fatload mmc 0 ${ramdisk_addr_r} uInitrd || setenv ramdisk_addr_r "-" - bootz ${kernel_addr_r} ${ramdisk_addr_r} + # Legacy sunxi (pre-mainline): script.bin substitutes for DTB, zImage only, + # bootz is called without the fdt argument. + if load ${boot_dev} ${fdt_addr_r} script.bin; then + true + else + echo "FATAL: no mainline (.next) and no legacy script.bin" + reset + fi + load ${boot_dev} ${ramdisk_addr_r} uInitrd || setenv ramdisk_addr_r "-" + if load ${boot_dev} ${kernel_addr_r} zImage; then + bootz ${kernel_addr_r} ${ramdisk_addr_r} + else + echo "FATAL: failed to load zImage (legacy)" + reset + fi fi # Recompile with: From 03c9bccbe076f19fddd33e5c22553768211669a0 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:13:11 +0300 Subject: [PATCH 03/16] feat(rootfs-to-image): rootfs export tree + configurable compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow the rootfs stage to produce a compressed archive, an exported directory tree, or both. Compression is configurable. When `ROOTFS_COMPRESSION=none` is set without `ROOTFS_EXPORT_DIR`, fail fast — there would be no rootfs artifact otherwise. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- lib/functions/image/rootfs-to-image.sh | 95 +++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 8 deletions(-) diff --git a/lib/functions/image/rootfs-to-image.sh b/lib/functions/image/rootfs-to-image.sh index 51903cec259f..5788cca1b0e0 100644 --- a/lib/functions/image/rootfs-to-image.sh +++ b/lib/functions/image/rootfs-to-image.sh @@ -20,6 +20,7 @@ function calculate_image_version() { [[ $BUILD_DESKTOP == yes ]] && calculated_image_version=${calculated_image_version}_desktop [[ $BUILD_MINIMAL == yes ]] && calculated_image_version=${calculated_image_version}_minimal [[ $ROOTFS_TYPE == nfs ]] && calculated_image_version=${calculated_image_version}_nfsboot + [[ $ROOTFS_TYPE == nfs-root ]] && calculated_image_version=${calculated_image_version}_nfsroot display_alert "Calculated image version" "${calculated_image_version}" "debug" } @@ -40,7 +41,7 @@ function create_image_from_sdcard_rootfs() { if [[ ${INCLUDE_HOME_DIR:-no} == yes ]]; then exclude_home=""; fi # nilfs2 fs does not have extended attributes support, and have to be ignored on copy if [[ $ROOTFS_TYPE == nilfs2 ]]; then rsync_ea=""; fi - if [[ $ROOTFS_TYPE != nfs ]]; then + if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]]; then display_alert "Copying files via rsync to" "/ (MOUNT root)" run_host_command_logged rsync -aHWh $rsync_ea \ --exclude="/boot" \ @@ -51,13 +52,6 @@ function create_image_from_sdcard_rootfs() { --exclude="/sys/*" \ $exclude_home \ --info=progress0,stats1 $SDCARD/ $MOUNT/ - else - display_alert "Creating rootfs archive" "rootfs.tgz" "info" - tar cp --xattrs --directory=$SDCARD/ --exclude='./boot/*' --exclude='./dev/*' --exclude='./proc/*' --exclude='./run/*' --exclude='./tmp/*' \ - --exclude='./sys/*' $exclude_home . | - pv -p -b -r -s "$(du -sb "$SDCARD"/ | cut -f1)" \ - -N "$(logging_echo_prefix_for_pv "create_rootfs_archive") rootfs.tgz" | - gzip -c > "$DEST/images/${version}-rootfs.tgz" fi # stage: rsync /boot @@ -110,6 +104,91 @@ function create_image_from_sdcard_rootfs() { Called before unmounting both `/root` and `/boot`. PRE_UMOUNT_FINAL_IMAGE + if [[ $ROOTFS_TYPE == nfs ]]; then + # ROOTFS_COMPRESSION: zstd (default, .tar.zst) | gzip (.tar.gz) | none (skip archive) + declare rootfs_compression="${ROOTFS_COMPRESSION:-zstd}" + declare archive_ext="" archive_filter="" + case "${rootfs_compression}" in + gzip) + archive_ext="tar.gz" + archive_filter="gzip -c" + ;; + zstd | zst) + archive_ext="tar.zst" + archive_filter="zstd -T0 -c" + ;; + none) ;; + *) exit_with_error "Unknown ROOTFS_COMPRESSION: '${rootfs_compression}' (expected: gzip|zstd|none)" ;; + esac + if [[ "${rootfs_compression}" == "none" && -z "${ROOTFS_EXPORT_DIR}" ]]; then + exit_with_error "ROOTFS_COMPRESSION=none requires ROOTFS_EXPORT_DIR (otherwise nothing is produced)" + fi + + declare -g ROOTFS_ARCHIVE_PATH="" + if [[ "${rootfs_compression}" != "none" ]]; then + ROOTFS_ARCHIVE_PATH="${FINALDEST}/${version}-rootfs.${archive_ext}" + # Write to a hidden temp file in FINALDEST and rename in place on success — + # keeps the publish atomic even when DESTIMG and FINALDEST are on different + # filesystems. A failed tar/pv/compressor stage leaves only the hidden tmp, + # never a truncated ${version}-rootfs.${archive_ext} that looks valid. + run_host_command_logged mkdir -pv "${FINALDEST}" + declare rootfs_archive_tmp="${FINALDEST}/.${version}-rootfs.${archive_ext}.tmp" + display_alert "Creating rootfs archive" "${version}-rootfs.${archive_ext}" "info" + # Subshell with pipefail so failures in tar/pv propagate (otherwise the + # exit code of the final compressor stage hides truncation mid-archive). + declare -a tar_excludes=(--exclude='./boot/*' --exclude='./dev/*' --exclude='./proc/*' --exclude='./run/*' --exclude='./tmp/*' --exclude='./sys/*') + [[ "${INCLUDE_HOME_DIR:-no}" != "yes" ]] && tar_excludes+=(--exclude='./home/*') + rm -f "${rootfs_archive_tmp}" + ( + set -o pipefail + tar cp --xattrs --directory="$SDCARD/" "${tar_excludes[@]}" . | + pv -p -b -r -s "$(du -sb "$SDCARD"/ | cut -f1)" \ + -N "$(logging_echo_prefix_for_pv "create_rootfs_archive") rootfs.${archive_ext}" | + ${archive_filter} > "${rootfs_archive_tmp}" + ) + run_host_command_logged mv -v "${rootfs_archive_tmp}" "${ROOTFS_ARCHIVE_PATH}" + fi + + # ROOTFS_EXPORT_DIR: when set, also rsync rootfs tree into this directory. + # Useful when the build host is the NFS server, or has the NFS export mounted, + # so netboot deployment is a single build step with no unpack/transport phase. + if [[ -n "${ROOTFS_EXPORT_DIR}" ]]; then + # Hard guard: the netboot extension confines the path to ${SRC}/output/ + # netboot-export/ when active, but plain ROOTFS_TYPE=nfs reaches this block + # with the raw user value. A typo like / or ${SDCARD} would turn the + # rsync --delete below into a destructive wipe. + declare _export_resolved _sdcard_resolved + _export_resolved="$(realpath -m "${ROOTFS_EXPORT_DIR}")" + _sdcard_resolved="$(realpath -m "${SDCARD}")" + if [[ -z "${_export_resolved}" || "${_export_resolved}" == "/" || "${_export_resolved}" == "${_sdcard_resolved}" ]]; then + exit_with_error "Refusing rsync --delete to unsafe ROOTFS_EXPORT_DIR" "${ROOTFS_EXPORT_DIR}" + fi + display_alert "Exporting rootfs tree" "${ROOTFS_EXPORT_DIR}" "info" + run_host_command_logged mkdir -pv "${ROOTFS_EXPORT_DIR}" + # --delete so files removed from the source rootfs don't survive in a + # reused export tree (otherwise the NFS root silently drifts from the image). + # --delete-excluded additionally purges receiver-side files that match our + # excludes (e.g. stale /home/* left over from a prior INCLUDE_HOME_DIR=yes build). + run_host_command_logged rsync -aHWh --delete --delete-excluded $rsync_ea \ + --exclude="/boot/*" \ + --exclude="/dev/*" \ + --exclude="/proc/*" \ + --exclude="/run/*" \ + --exclude="/tmp/*" \ + --exclude="/sys/*" \ + $exclude_home \ + --info=progress0,stats1 "$SDCARD/" "${ROOTFS_EXPORT_DIR}/" + fi + fi + + call_extension_method "post_create_rootfs_archive" <<- 'POST_CREATE_ROOTFS_ARCHIVE' + *called after the rootfs archive / export tree is produced* + Runs after pre_umount_final_image and after the archive step, so any + path the archive step sets (e.g. ROOTFS_ARCHIVE_PATH) is populated. + Use this instead of pre_umount_final_image when you need the final + archive path — deploy hooks that ship the archive belong here. + POST_CREATE_ROOTFS_ARCHIVE + if [[ "${SHOW_DEBUG}" == "yes" ]]; then # Check the partition table after the uboot code has been written display_alert "Partition table after write_uboot" "$LOOP" "debug" From ad090b74671cbe4fd0cca3ae81bb4c28ed58284b Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:13:11 +0300 Subject: [PATCH 04/16] feat(extensions/netboot): netboot extension for TFTP+NFS diskless boot Add opt-in netboot extension for diskless U-Boot PXE/NFS boot. Generates a TFTP tree (kernel + DTB + uInitrd + per-board pxelinux.cfg with extlinux APPEND for NFS root) alongside or instead of a flashable image. Supports per-host MAC-tagged configs, builder-as-NFS-server via ROOTFS_EXPORT_DIR, ROOTSERVER discovery from DHCP siaddr in initramfs, and a `netboot_artifacts_ready` post-hook for deploy automation. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- .../files/dhcpcd-hooks/71-netboot-rootpath | 27 + .../files/initramfs-hooks/netboot-rootpath | 26 + extensions/netboot/netboot.sh | 595 ++++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 extensions/netboot/files/dhcpcd-hooks/71-netboot-rootpath create mode 100644 extensions/netboot/files/initramfs-hooks/netboot-rootpath create mode 100644 extensions/netboot/netboot.sh diff --git a/extensions/netboot/files/dhcpcd-hooks/71-netboot-rootpath b/extensions/netboot/files/dhcpcd-hooks/71-netboot-rootpath new file mode 100644 index 000000000000..a87689c5641e --- /dev/null +++ b/extensions/netboot/files/dhcpcd-hooks/71-netboot-rootpath @@ -0,0 +1,27 @@ +#!/bin/sh +# Fix ROOTSERVER in /run/net-${interface}.conf for nfsroot= (path-only) boots. +# +# The standard 70-net-conf hook sets ROOTSERVER='${new_routers}' (default +# gateway). The kernel's own IP-Config (ip=dhcp) correctly resolves the boot +# server from DHCP siaddr and records it as "bootserver" in /proc/net/pnp. +# Appending ROOTSERVER from there overrides the gateway value; shell-source +# semantics in /scripts/nfs pick the last assignment. +# +# Installed by the Armbian `netboot` extension. + +if ${if_configured?} && ${if_up?} && [ "${protocol-}" = dhcp ] \ + && [ -f "/run/net-${interface?}.conf" ]; then + + # Read boot server from kernel IP-Config — set from DHCP siaddr. + # /proc/net/pnp is only populated when the kernel handles DHCP itself + # (ip=dhcp in cmdline); if it is absent or has no valid bootserver entry + # we clear ROOTSERVER explicitly so nfs-mount fails fast rather than + # silently trying the default gateway written by 70-net-conf. + bootserver=$(grep '^bootserver ' /proc/net/pnp 2>/dev/null | cut -d' ' -f2) + if [ -n "$bootserver" ] && [ "$bootserver" != "0.0.0.0" ]; then + printf "ROOTSERVER='%s'\n" "$bootserver" >> "/run/net-${interface}.conf" + else + printf '71-netboot-rootpath: bootserver not found in /proc/net/pnp, clearing ROOTSERVER\n' >&2 + printf "ROOTSERVER=''\n" >> "/run/net-${interface}.conf" + fi +fi diff --git a/extensions/netboot/files/initramfs-hooks/netboot-rootpath b/extensions/netboot/files/initramfs-hooks/netboot-rootpath new file mode 100644 index 000000000000..7ede5cc75f71 --- /dev/null +++ b/extensions/netboot/files/initramfs-hooks/netboot-rootpath @@ -0,0 +1,26 @@ +#!/bin/sh +# Initramfs hook: include 71-netboot-rootpath in the dhcpcd hooks dir of the +# generated initramfs. That hook overrides ROOTSERVER in +# /run/net-${interface}.conf with the boot server from /proc/net/pnp so the +# initramfs nfs-mount script does not fall back to the default gateway. +# Companion of the Armbian `netboot` extension. + +set -e + +PREREQ="" +case "$1" in + prereqs) echo "$PREREQ"; exit 0 ;; +esac + +. /usr/share/initramfs-tools/hook-functions + +# Mirror the destination directory choice from the upstream `dhcpcd` hook +# (depends on whether dhcpcd ships its hooks in /usr/lib/dhcpcd or /usr/libexec). +if [ -e /usr/lib/dhcpcd/dhcpcd-run-hooks ]; then + dhcpcd_dir=/usr/lib/dhcpcd +else + dhcpcd_dir=/usr/libexec +fi + +copy_file config /usr/share/initramfs-tools/dhcpcd-hooks/71-netboot-rootpath \ + "${dhcpcd_dir}/dhcpcd-hooks/71-netboot-rootpath" diff --git a/extensions/netboot/netboot.sh b/extensions/netboot/netboot.sh new file mode 100644 index 000000000000..4f9b4b53419d --- /dev/null +++ b/extensions/netboot/netboot.sh @@ -0,0 +1,595 @@ +#!/usr/bin/env bash +# +# SPDX-License-Identifier: GPL-2.0 +# Copyright (c) 2026 Igor Velkov +# This file is a part of the Armbian Build Framework https://github.com/armbian/build/ +# +# Netboot: produce kernel + DTB + extlinux.conf + rootfs archive/export tree for TFTP/NFS root +# boot without local storage. See Developer-Guide_Netboot.md for server setup +# (tftpd-hpa + nfs-kernel-server + router DHCP options) and for the +# `netboot_artifacts_ready` hook used to auto-deploy artifacts to a server. +# +# Variables: +# NETBOOT_SERVER IP of the NFS server baked into nfsroot=. If +# empty, APPEND uses nfsroot=,tcp,v3 with no +# server, and the kernel takes the NFS server from +# DHCP siaddr (the boot-server field of DHCPOFFER) +# at boot. Per-host rootfs paths live in the +# per-board pxelinux.cfg files; the server is a +# single network-wide value (set via dnsmasq +# `dhcp-boot`/`next-server`, ISC `next-server`, +# etc.). DHCP option 17 (root-path) is no longer +# consulted in this mode — option 17 is a +# network-wide singleton (one path for all +# clients) which doesn't scale to multi-board +# setups. +# NETBOOT_TFTP_PREFIX Path prefix inside TFTP root. Default: +# armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE} +# NETBOOT_NFS_PATH Absolute NFS path of rootfs on the server. +# Default depends on NETBOOT_HOSTNAME — see below. +# NETBOOT_HOSTNAME Per-host deployment. When set, default NFS path +# becomes /srv/netboot/rootfs/hosts/ +# (each machine owns a full writable rootfs copy). +# When empty, shared/${LINUXFAMILY}/${BOARD}/... is used. +# NETBOOT_CLIENT_MAC Client MAC (aa:bb:cc:dd:ee:ff or aa-bb-cc-dd-ee-ff). +# Tags the generated PXE config so multiple build +# variants coexist on one TFTP root without +# overwriting each other. Filename layout: +# set → `01-.--[-]` +# unset → `--[-].example` +# Neither is a valid PXELINUX fallback path — the +# operator picks the active variant by symlinking +# it to `default--` (or `01-` +# without suffix). Rebuilding one variant does not +# touch the active link. +# ROOTFS_COMPRESSION gzip | zstd | zst | none. Empty defers to the +# default in rootfs-to-image.sh. `none` requires +# ROOTFS_EXPORT_DIR (archive step is skipped). +# ROOTFS_EXPORT_DIR Optional rsync-target for the rootfs tree. +# Relative → ${SRC}/output/netboot-export/ +# Absolute under base → kept as-is +# Absolute elsewhere → bind-mounted into the +# container at the same path +# (target must exist on host). +# System roots (`/`, `/etc`, `/usr`, ...) and +# `..` segments are rejected. A non-empty target +# without the .netboot_export_marker stamp at its +# root is refused (refuses to clobber an unrelated +# Linux tree); pass NETBOOT_EXPORT_FORCE=yes to +# override. +# NETBOOT_EXPORT_FORCE Set to "yes" to allow overwriting a non-empty +# ROOTFS_EXPORT_DIR that lacks the +# .netboot_export_marker stamp (rsync --delete +# will clobber whatever is there). +# +# Hook: +# netboot_artifacts_ready Called after all artifacts are staged. Exposed +# context: NETBOOT_TFTP_OUT, NETBOOT_TFTP_PREFIX, +# NETBOOT_NFS_PATH, NETBOOT_PXE_FILE, +# NETBOOT_ROOTFS_ARCHIVE (may be empty if +# ROOTFS_COMPRESSION=none), plus BOARD/LINUXFAMILY/ +# BRANCH/RELEASE. Use it from userpatches to rsync +# to a netboot server, unpack the rootfs archive, +# etc. For builder-as-NFS-server workflows prefer +# ROOTFS_EXPORT_DIR to skip the archive step. + +function extension_prepare_config__netboot_defaults_and_validate() { + # Keep non-nfs-root builds (ext4/btrfs/...) unaffected by this extension. + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + declare -g NETBOOT_SERVER="${NETBOOT_SERVER:-}" + # nfs-root has no local storage — prevent boot partition creation (and the + # resulting phantom /boot fstab entry whose UUID points at nothing). + # $MOUNT/boot/ remains accessible via the bind mount from $SDCARD (line 394 + # of partitioning.sh), so pre_umount_final_image__900 still finds kernel/DTB. + # The early return above keeps this safe for non-nfs-root builds. + # shellcheck disable=SC2034 # BOOTSIZE is read by armbian core (skips /boot partition) + declare -g BOOTSIZE=0 + declare -g NETBOOT_HOSTNAME="${NETBOOT_HOSTNAME:-}" + declare -g NETBOOT_CLIENT_MAC="${NETBOOT_CLIENT_MAC:-}" + # Declared unconditionally so later `[[ -n "${NETBOOT_CLIENT_MAC_NORMALIZED}" ]]` + # checks remain safe under `set -u` when no MAC is configured. + declare -g NETBOOT_CLIENT_MAC_NORMALIZED="" + # Build-flavor suffix lets default TFTP/NFS/PXE layouts coexist for the + # same board/release/branch when several flavors are built (CLI vs + # minimal vs desktop). Declared global so the post-build hook that + # composes pxe_tag and the deploy hook can pick it up. Only applied to + # defaults — user-supplied NETBOOT_TFTP_PREFIX / NETBOOT_NFS_PATH are + # honored verbatim. Computed here (early) because BUILD_MINIMAL / + # BUILD_DESKTOP are static config inputs, not derived from family + # sourcing — safe to inspect in extension_prepare_config. + declare -g _NETBOOT_FLAVOR="" + if [[ "${BUILD_MINIMAL:-no}" == "yes" ]]; then + _NETBOOT_FLAVOR="-min" + elif [[ "${BUILD_DESKTOP:-no}" == "yes" ]]; then + _NETBOOT_FLAVOR="-desktop" + fi + + if [[ -n "${NETBOOT_CLIENT_MAC}" ]]; then + NETBOOT_CLIENT_MAC_NORMALIZED="${NETBOOT_CLIENT_MAC//:/-}" + NETBOOT_CLIENT_MAC_NORMALIZED="${NETBOOT_CLIENT_MAC_NORMALIZED,,}" + if [[ ! "${NETBOOT_CLIENT_MAC_NORMALIZED}" =~ ^[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}$ ]]; then + exit_with_error "${EXTENSION}: NETBOOT_CLIENT_MAC must look like aa:bb:cc:dd:ee:ff (got '${NETBOOT_CLIENT_MAC}')" + fi + fi + + # Disambiguate per-host artifact names so two host-specific builds for the + # same board/release/branch don't overwrite each other's + # ${version}-netboot-tftp / ${version}-rootfs.* on the builder. Append to + # the EXTRA_IMAGE_SUFFIXES array, not the scalar — do_main_configuration + # rebuilds the final EXTRA_IMAGE_SUFFIX from this array and declares it + # readonly, so writes to the scalar from here are silently overwritten + # before calculate_image_version() runs. + if [[ -n "${NETBOOT_HOSTNAME}" ]]; then + EXTRA_IMAGE_SUFFIXES+=("_${NETBOOT_HOSTNAME}") + elif [[ -n "${NETBOOT_CLIENT_MAC_NORMALIZED}" ]]; then + EXTRA_IMAGE_SUFFIXES+=("_${NETBOOT_CLIENT_MAC_NORMALIZED}") + fi + + # Fail-fast on bad ROOTFS_COMPRESSION/ROOTFS_EXPORT_DIR combos before debootstrap, + # not hours later in create_image_from_sdcard_rootfs. The default itself lives + # in rootfs-to-image.sh; here we only validate values the user actually set. + case "${ROOTFS_COMPRESSION:-}" in + "" | gzip | zstd | zst | none) ;; + *) exit_with_error "${EXTENSION}: unknown ROOTFS_COMPRESSION: '${ROOTFS_COMPRESSION:-}' (expected: gzip|zstd|zst|none)" ;; + esac + if [[ "${ROOTFS_COMPRESSION:-}" == "none" && -z "${ROOTFS_EXPORT_DIR:-}" ]]; then + exit_with_error "${EXTENSION}: ROOTFS_COMPRESSION=none requires ROOTFS_EXPORT_DIR (otherwise nothing is produced)" + fi + + _netboot_normalize_export_dir +} + +# Resolve ROOTFS_EXPORT_DIR into a path the build will actually rsync into: +# - relative → confined under ${SRC}/output/netboot-export/ +# so `rsync --delete` cannot escape that subtree. +# - absolute under base → kept as-is. +# - absolute elsewhere → kept as-is and bind-mounted into the container +# at the same path (see host_pre_docker_launch +# hook below). System roots are rejected. +# +# Called on both the host (lazily from host_pre_docker_launch hooks, since +# extension_prepare_config only runs inside docker post-relaunch) and inside +# the container (where ${SRC}=/armbian). The host pass propagates ${SRC} via +# _NETBOOT_HOST_SRC --env so the in-container pass recognises a host-base +# path and translates it to the container-base path instead of re-prefixing. +function _netboot_normalize_export_dir() { + [[ -z "${ROOTFS_EXPORT_DIR:-}" ]] && return 0 + + case "${ROOTFS_EXPORT_DIR}" in + *..*) exit_with_error "${EXTENSION}: ROOTFS_EXPORT_DIR must not contain '..'" "${ROOTFS_EXPORT_DIR}" ;; + esac + + declare host_src="${_NETBOOT_HOST_SRC:-${SRC}}" + + if [[ "${ROOTFS_EXPORT_DIR}" != /* ]]; then + declare -g ROOTFS_EXPORT_DIR="${SRC}/output/netboot-export/${ROOTFS_EXPORT_DIR}" + elif [[ "${ROOTFS_EXPORT_DIR}" == "${SRC}/output/netboot-export" || + "${ROOTFS_EXPORT_DIR}" == "${SRC}/output/netboot-export/"* ]]; then + : # already in the current ${SRC}'s base + elif [[ "${ROOTFS_EXPORT_DIR}" == "${host_src}/output/netboot-export" || + "${ROOTFS_EXPORT_DIR}" == "${host_src}/output/netboot-export/"* ]]; then + declare rest="${ROOTFS_EXPORT_DIR#"${host_src}"/output/netboot-export}" + declare -g ROOTFS_EXPORT_DIR="${SRC}/output/netboot-export${rest}" + else + declare -g _NETBOOT_EXPORT_DIR_EXTERNAL=yes + fi + + # Validate the resolved filesystem target for *every* path shape — relative, + # absolute under-base, host-base translated, and absolute external. The + # under-base branches would otherwise skip the blacklist/non-empty-target + # guard, and `rsync --delete` could wipe an unrelated tree under the + # netboot-export base. readlink -m also follows symlinks under the base + # so a misaimed symlink there cannot escape into /etc, /srv/... etc. + declare resolved + resolved="$(readlink -m "${ROOTFS_EXPORT_DIR}" 2> /dev/null || echo "${ROOTFS_EXPORT_DIR}")" + _netboot_validate_external_export_dir "${resolved}" +} + +function _netboot_validate_external_export_dir() { + declare path="${1:-${ROOTFS_EXPORT_DIR}}" + # Blacklist of system roots that must never become ROOTFS_EXPORT_DIR. + declare -ag NETBOOT_EXPORT_DIR_BLACKLIST=( + / /etc /usr /bin /sbin /lib /lib64 /boot + /proc /sys /dev /run /tmp /var/log /var/run + ) + call_extension_method "netboot_export_dir_blacklist" <<- 'NETBOOT_BL_DOC' + *adjust the list of system roots forbidden as ROOTFS_EXPORT_DIR* + Override or extend NETBOOT_EXPORT_DIR_BLACKLIST in your userpatches + extension or hook function before the build descends into debootstrap. + NETBOOT_BL_DOC + + declare entry + for entry in "${NETBOOT_EXPORT_DIR_BLACKLIST[@]}"; do + [[ "${path}" == "${entry}" || + "${path}" == "${entry}/"* ]] || continue + exit_with_error "${EXTENSION}: ROOTFS_EXPORT_DIR overlaps blacklisted system path" \ + "'${path}' inside '${entry}'" + done + + # Refuse to clobber a non-empty target that does not carry our own + # netboot-export marker. /etc/os-release is too generic — any Linux tree + # (Debian server, Ubuntu chroot, ...) has it. A dedicated marker placed + # at the rootfs root by post_customize_image__netboot_install_export_marker + # is specific to "this directory is a netboot-export tree we wrote earlier + # and may overwrite". A freshly-created bind-mountpoint is empty and passes. + if [[ -d "${path}" ]] && + [[ -n "$(ls -A "${path}" 2> /dev/null)" ]] && + [[ ! -f "${path}/.netboot_export_marker" ]] && + [[ "${NETBOOT_EXPORT_FORCE:-no}" != "yes" ]]; then + exit_with_error "${EXTENSION}: ROOTFS_EXPORT_DIR is non-empty and lacks .netboot_export_marker" \ + "rsync --delete would clobber '${path}'; pass NETBOOT_EXPORT_FORCE=yes to override" + fi +} + +# Compute defaults for `NETBOOT_TFTP_PREFIX` and `NETBOOT_NFS_PATH` lazily, +# at hook time, instead of in `extension_prepare_config`. The naive default +# `armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}` references +# `${LINUXFAMILY}`, which is populated late in the config dispatch (see +# `change-tracking: after defaulting LINUXFAMILY to BOARDFAMILY` in build +# logs); evaluating the default in `extension_prepare_config` on a kernel-only +# `compile.sh kernel ...` flow can capture an empty `${LINUXFAMILY}` and bake +# `armbian//${BOARD}/...` into the prefix. Calling this from the consuming +# hooks (artifact_collect, rootfs_archive_deploy, kernel_artifact_deploy) +# guarantees the values reflect the populated config snapshot. +function _netboot_compute_runtime_defaults() { + declare -g NETBOOT_TFTP_PREFIX="${NETBOOT_TFTP_PREFIX:-armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}${_NETBOOT_FLAVOR:-}}" + # TFTP_PREFIX is appended to the staging root with `mkdir -p`; a `..` segment would + # walk out of it and let an extension scribble onto arbitrary paths under FINALDEST. + case "${NETBOOT_TFTP_PREFIX}" in + *..*) exit_with_error "${EXTENSION}: NETBOOT_TFTP_PREFIX must not contain '..'" "${NETBOOT_TFTP_PREFIX}" ;; + esac + + if [[ -n "${NETBOOT_HOSTNAME:-}" ]]; then + declare -g NETBOOT_NFS_PATH="${NETBOOT_NFS_PATH:-/srv/netboot/rootfs/hosts/${NETBOOT_HOSTNAME}}" + else + declare -g NETBOOT_NFS_PATH="${NETBOOT_NFS_PATH:-/srv/netboot/rootfs/shared/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}${_NETBOOT_FLAVOR:-}}" + fi + if [[ "${NETBOOT_NFS_PATH}" != /* ]]; then + exit_with_error "${EXTENSION}: NETBOOT_NFS_PATH must be an absolute path (got '${NETBOOT_NFS_PATH}')" + fi +} + +# Empty stub so the framework loads this file on the host phase. Required when +# `netboot` is auto-enabled later inside the container (e.g. via ROOTFS_TYPE= +# nfs-root): without a host-side dispatch point, host_pre_docker_launch hooks +# defined below would never be registered before docker launch, and the +# bind-mount of the export symlink would silently no-op. +function add_host_dependencies__netboot_stub() { + : +} + +# Builder-as-NFS-server: when ${SRC}/output/netboot-export is a symlink to an +# external directory (e.g. /srv/netboot/rootfs on the local NFS server), bind- +# mount the symlink target into the container at the same absolute path. This +# way the symlink resolves identically inside and outside the container, and +# rsync from inside (running as docker-root, no userns-remap) writes directly +# into the NFS export tree with original ownership preserved. The export step +# is a plain rsync — it copies file-by-file regardless of source/destination +# filesystem (no FICLONE / reflink path); incremental rebuilds rsync only +# changed files, which is where the actual time savings come from. +function host_pre_docker_launch__netboot_bindmount_export_symlink() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + # Skip when the build will not rsync into the export tree — bind-mounting the + # NFS root into docker has no purpose without ROOTFS_EXPORT_DIR set. + [[ -n "${ROOTFS_EXPORT_DIR:-}" ]] || return 0 + # Owns only the under-base export workflow. Absolute-external exports are + # bind-mounted by host_pre_docker_launch__netboot_bindmount_external_export + # at the narrow target path; doing it here too would also expose the wider + # symlink target (e.g. all of /srv/netboot/rootfs) inside docker. + _netboot_normalize_export_dir + [[ "${ROOTFS_EXPORT_DIR}" == "${SRC}/output/netboot-export" || + "${ROOTFS_EXPORT_DIR}" == "${SRC}/output/netboot-export/"* ]] || return 0 + declare link="${SRC}/output/netboot-export" + [[ -L "${link}" ]] || return 0 + declare target + target="$(readlink -f "${link}" 2> /dev/null || true)" + if [[ -z "${target}" || ! -d "${target}" ]]; then + exit_with_error "${EXTENSION}: ${link} is a dangling symlink" "target='${target:-}'" + fi + # `--mount` CSV has no escape syntax — reject paths with commas. + if [[ "${target}" == *,* ]]; then + exit_with_error "${EXTENSION}: symlink target must not contain a comma" "${target}" + fi + display_alert "${EXTENSION}: bind-mounting NFS export root into container" "${target}" "info" + DOCKER_EXTRA_ARGS+=("--mount" "type=bind,source=${target},target=${target}") +} + +# Propagate host-side ${SRC} (and the external-path flag) into the container +# so the in-docker pass of _netboot_normalize_export_dir recognises a path +# already normalised against the host's ${SRC} and translates it to the +# container's /armbian base instead of stacking the prefix. +function host_pre_docker_launch__netboot_propagate_normalize_state() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + DOCKER_EXTRA_ARGS+=("--env" "_NETBOOT_HOST_SRC=${SRC}") + if [[ "${_NETBOOT_EXPORT_DIR_EXTERNAL:-}" == "yes" ]]; then + DOCKER_EXTRA_ARGS+=("--env" "_NETBOOT_EXPORT_DIR_EXTERNAL=yes") + fi +} + +# Builder-as-NFS-server, no-symlink variant: when ROOTFS_EXPORT_DIR is an +# absolute path outside ${SRC}/output/netboot-export, bind-mount it into the +# container at the same absolute path. rsync from inside writes directly into +# the host export tree with original ownership/xattrs preserved. +function host_pre_docker_launch__netboot_bindmount_external_export() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + # extension_prepare_config runs only inside docker (post-relaunch), so + # normalize on the host here to set _NETBOOT_EXPORT_DIR_EXTERNAL before + # the propagate_normalize_state hook (alphabetically later) reads it. + # Idempotent: a no-op when already normalised. + _netboot_normalize_export_dir + [[ "${_NETBOOT_EXPORT_DIR_EXTERNAL:-}" == "yes" ]] || return 0 + declare target="${ROOTFS_EXPORT_DIR}" + if [[ "${target}" == *,* ]]; then + exit_with_error "${EXTENSION}: ROOTFS_EXPORT_DIR must not contain a comma" "${target}" + fi + # Target must exist on the host: docker bind-mount needs the source path, + # and creating it here is unreliable (the parent is often root-owned NFS + # tree while the build runs as the regular user pre-docker). + if [[ ! -d "${target}" ]]; then + exit_with_error "${EXTENSION}: ROOTFS_EXPORT_DIR does not exist on host" \ + "create '${target}' (with ownership writable by the build user) before launching the build" + fi + display_alert "${EXTENSION}: bind-mounting external export dir into container" "${target}" "info" + DOCKER_EXTRA_ARGS+=("--mount" "type=bind,source=${target},target=${target}") +} + +# Ensure NFS-root client support is built into the kernel. +function custom_kernel_config__netboot_enable_nfs_root() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + opts_y+=("ROOT_NFS" "NFS_FS" "NFS_V3" "IP_PNP" "IP_PNP_DHCP") +} + +# Stamp the rootfs root with a netboot-export marker. The soft guard in +# _netboot_validate_external_export_dir uses this file to recognise a +# previously-exported armbian rootfs at ROOTFS_EXPORT_DIR and allow +# rsync --delete to overwrite it on the next build. Anything else +# (a stranger's Debian/Ubuntu tree at the same path) lacks the marker +# and gets refused unless NETBOOT_EXPORT_FORCE=yes. Created in $SDCARD +# so the rsync that exports the rootfs tree carries it along. +function post_customize_image__netboot_install_export_marker() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + cat > "${SDCARD}/.netboot_export_marker" <<- 'NETBOOT_EXPORT_MARKER' + This file marks an Armbian netboot rootfs export. + + When this file is present at the root of ROOTFS_EXPORT_DIR, the + netboot extension's safety guard treats the directory as a + previously-exported armbian rootfs and lets a subsequent build + overwrite it (rsync --delete) silently — the expected workflow + for incremental rebuilds against the same NFS export tree. + + When this file is absent and the directory is non-empty, the + guard refuses to write into it, to prevent accidentally wiping + an unrelated Linux tree that happened to land at the same path. + Pass NETBOOT_EXPORT_FORCE=yes to override the refusal. + + Created by: extensions/netboot/netboot.sh + NETBOOT_EXPORT_MARKER +} + +# armbian-resize-filesystem tries to grow the root fs on first boot via resize2fs. +# On an NFS-mounted root that's always meaningless (and would error) — strip the +# systemd enablement symlink so the unit never runs. +function post_customize_image__netboot_disable_resize_filesystem() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + display_alert "${EXTENSION}: disabling armbian-resize-filesystem.service" "meaningless on NFS root" "info" + run_host_command_logged find "${SDCARD}/etc/systemd/system/" \ + -name "armbian-resize-filesystem.service" -type l -delete +} + +# /etc/profile.d/armbian-check-first-login.sh launches the armbian-firstlogin +# whiptail wizard (root password → user → locale …) when /root/.not_logged_in_yet +# exists. On a default (empty) trigger the wizard would demand interactive input +# on the first login — inconvenient when iterating on netboot images. When the +# file is non-empty it contains PRESET_* keys (e.g. from the preset-firstrun +# extension) that let the wizard complete non-interactively, so we leave it alone. +function post_customize_image__netboot_skip_firstlogin_wizard() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + [[ -f "${SDCARD}/root/.not_logged_in_yet" ]] || return 0 + if [[ -s "${SDCARD}/root/.not_logged_in_yet" ]]; then + display_alert "${EXTENSION}: keeping /root/.not_logged_in_yet" "non-empty — presets detected (e.g. preset-firstrun)" "info" + return 0 + fi + display_alert "${EXTENSION}: removing empty /root/.not_logged_in_yet" "wizard would block first login without presets" "info" + run_host_command_logged rm -f "${SDCARD}/root/.not_logged_in_yet" +} + +# Fix ROOTSERVER in initramfs for path-only nfsroot= boots. The stock +# 70-net-conf dhcpcd-hook sets ROOTSERVER to the default gateway (new_routers), +# which makes /scripts/nfs in initramfs-tools mount the rootfs from the wrong +# host. Our 71-netboot-rootpath hook reads the actual boot server from +# /proc/net/pnp (written by the kernel IP-Config from DHCP siaddr) and appends +# it last, so shell-source semantics in /scripts/nfs pick it over the gateway. +# +# Without this hook, nfs-root mounts attempt the gateway and time out. +function post_customize_image__netboot_request_root_path() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + + declare dhcpcd_hook="${SDCARD}/usr/share/initramfs-tools/dhcpcd-hooks/71-netboot-rootpath" + declare initramfs_hook="${SDCARD}/etc/initramfs-tools/hooks/netboot-rootpath" + + # Always install/overwrite both hooks so stale copies from earlier + # revisions are replaced unconditionally (same idiom as the watchdog + # hook installer below). + display_alert "${EXTENSION}: installing dhcpcd hook" "71-netboot-rootpath records ROOTSERVER from /proc/net/pnp for nfsroot=" "info" + run_host_command_logged install -D -m 0755 \ + "${EXTENSION_DIR}/files/dhcpcd-hooks/71-netboot-rootpath" "${dhcpcd_hook}" + + display_alert "${EXTENSION}: installing initramfs hook" "netboot-rootpath bundles 71-netboot-rootpath into uInitrd" "info" + run_host_command_logged install -D -m 0755 \ + "${EXTENSION_DIR}/files/initramfs-hooks/netboot-rootpath" "${initramfs_hook}" + + # Kernel install ran update-initramfs earlier without our hooks present, + # so rebuild now to ensure the uInitrd we ship over TFTP carries them. + display_alert "${EXTENSION}: rebuilding initramfs" "to include 71-netboot-rootpath ROOTSERVER fix" "info" + chroot_sdcard update-initramfs -u +} + +function post_customize_image__netboot_initramfs_watchdog() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + + declare premount="${SDCARD}/etc/initramfs-tools/scripts/init-premount/zz-netboot-watchdog" + declare cancel="${SDCARD}/etc/initramfs-tools/scripts/nfs-bottom/zz-netboot-watchdog-cancel" + + # Always install/overwrite both hooks so stale copies from earlier revisions + # (e.g. init-bottom/zz-netboot-watchdog-cancel) are replaced unconditionally. + display_alert "${EXTENSION}: installing initramfs watchdog" "reboot after 10 min NFS hang" "info" + run_host_command_logged install -D -m 0755 \ + "${EXTENSION_DIR}/files/initramfs-scripts/init-premount/zz-netboot-watchdog" "${premount}" + run_host_command_logged install -D -m 0755 \ + "${EXTENSION_DIR}/files/initramfs-scripts/nfs-bottom/zz-netboot-watchdog-cancel" "${cancel}" + display_alert "${EXTENSION}: rebuilding initramfs" "to include NFS watchdog" "info" + chroot_sdcard update-initramfs -u +} + +function pre_umount_final_image__900_collect_netboot_artifacts() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + _netboot_compute_runtime_defaults + + # shellcheck disable=SC2154 # ${version} is a readonly global set in create_image_from_sdcard_rootfs + declare tftp_out="${FINALDEST}/${version}-netboot-tftp" + declare tftp_prefix_dir="${tftp_out}/${NETBOOT_TFTP_PREFIX}" + declare pxe_dir="${tftp_out}/pxelinux.cfg" + # Wipe the per-prefix subtree first: cp below is additive, so on an + # incremental rebuild a removed DTB or stale uInitrd from the previous + # build would persist and the later BOOT_FDT_FILE sanity check would + # validate against that stale tree. pxe_dir/ is intentionally NOT wiped — + # it is admin-managed (multi-board TFTP root). + run_host_command_logged rm -rf "${tftp_prefix_dir}" + run_host_command_logged mkdir -pv "${tftp_prefix_dir}/dtb" "${pxe_dir}" + + # Kernel image: arm64 uses Image, armv7 uses zImage. Preserve source basename + # so U-Boot `booti`/`bootz` still picks the right path via image header. + declare kernel_src="" kernel_name="" + if [[ -f "${MOUNT}/boot/Image" ]]; then + kernel_src="${MOUNT}/boot/Image" + kernel_name="Image" + elif [[ -f "${MOUNT}/boot/zImage" ]]; then + kernel_src="${MOUNT}/boot/zImage" + kernel_name="zImage" + elif [[ -n "${IMAGE_INSTALLED_KERNEL_VERSION:-}" && -f "${MOUNT}/boot/vmlinuz-${IMAGE_INSTALLED_KERNEL_VERSION}" ]]; then + kernel_src="${MOUNT}/boot/vmlinuz-${IMAGE_INSTALLED_KERNEL_VERSION}" + # vmlinuz-* is a generic bzImage/Image; prefer Image for arm64, zImage otherwise + [[ "${ARCH}" == "arm64" ]] && kernel_name="Image" || kernel_name="zImage" + fi + [[ -n "${kernel_src}" ]] || exit_with_error "${EXTENSION}: kernel image not found under ${MOUNT}/boot" + run_host_command_logged cp -v "${kernel_src}" "${tftp_prefix_dir}/${kernel_name}" + + # Stage DTBs only if the rootfs actually has them. linux-dtb is an ARM-only + # deb; x86 and kernels built without KERNEL_BUILD_DTBS produce no /boot/dtb. + declare dtb_payload="" + if [[ -d "${MOUNT}/boot/dtb" ]]; then + run_host_command_logged cp -a "${MOUNT}/boot/dtb/." "${tftp_prefix_dir}/dtb/" + dtb_payload="dtb/" + fi + + declare initrd_line="" + if [[ -f "${MOUNT}/boot/uInitrd" ]]; then + run_host_command_logged cp -v "${MOUNT}/boot/uInitrd" "${tftp_prefix_dir}/uInitrd" + initrd_line="INITRD ${NETBOOT_TFTP_PREFIX}/uInitrd" + fi + + # extlinux APPEND is passed verbatim to the kernel — U-Boot does not expand + # ${var} inside it. With NETBOOT_SERVER set we bake the literal IP into + # nfsroot=. Without it we emit nfsroot=,tcp,v3 (no server) and the + # kernel resolves the NFS server from DHCP siaddr at boot. Per-host paths + # live in the per-board pxelinux.cfg file; the server is a single + # network-wide DHCP value. + declare nfsroot_param=" nfsroot=${NETBOOT_NFS_PATH},tcp,v3" + if [[ -n "${NETBOOT_SERVER}" ]]; then + nfsroot_param=" nfsroot=${NETBOOT_SERVER}:${NETBOOT_NFS_PATH},tcp,v3" + fi + + # Intentionally no `console=` in APPEND: hardcoding a baud (e.g. 115200) + # breaks boards like helios64 which run at 1500000. Kernel resolves console + # from DTB `/chosen/stdout-path`; `earlycon` keeps the early output. + + # BOOT_FDT_FILE unset (e.g. helios64) → emit FDTDIR so U-Boot resolves via + # its own ${fdtfile}. When no DTB tree was staged at all, skip FDT/FDTDIR + # entirely so the PXE stanza never points at an empty TFTP directory — + # mirrors the extlinux fallback in lib/functions/rootfs/distro-agnostic.sh. + declare fdt_line="" + if [[ -n "${BOOT_FDT_FILE:-}" && "${BOOT_FDT_FILE}" != "none" ]]; then + # K3/BeagleBone boards declare BOOT_FDT_FILE with a .dts suffix (e.g. + # ti/k3-am625-beagleplay.dts); in the TFTP tree we ship the compiled .dtb. + # Normalize so the PXE stanza references a file that actually exists. + declare fdt_file="${BOOT_FDT_FILE}" + [[ "${fdt_file}" == *.dts ]] && fdt_file="${fdt_file%.dts}.dtb" + # Fail fast if the DTB the PXE stanza points at is missing from the + # staged tree. Otherwise U-Boot would silently fail `load … ${fdtfile}` + # at boot time and drop to a prompt. + if [[ ! -f "${tftp_prefix_dir}/dtb/${fdt_file}" ]]; then + exit_with_error "${EXTENSION}: BOOT_FDT_FILE not found in staged TFTP dtb tree" "${fdt_file}" + fi + fdt_line="FDT ${NETBOOT_TFTP_PREFIX}/dtb/${fdt_file}" + elif [[ -n "${dtb_payload}" ]]; then + fdt_line="FDTDIR ${NETBOOT_TFTP_PREFIX}/dtb" + fi + + # Tag the PXE config file with the same coordinates that name the kernel + # image and rootfs tree (board / branch / release [ / hostname ]) so that + # multiple build variants can coexist in pxelinux.cfg/ without overwriting + # each other on every rebuild. Names like `helios64-edge-resolute.example` + # or `01-aa-bb-cc-dd-ee-ff.helios64-edge-trixie` are NOT valid PXELINUX + # fallback paths; the operator picks the active variant by symlinking it + # into `default--` (or `01-` without the suffix). This + # also means rebuilding one release doesn't touch the active link. + declare pxe_tag="${BOARD}-${BRANCH}-${RELEASE}${_NETBOOT_FLAVOR}" + [[ -n "${NETBOOT_HOSTNAME}" ]] && pxe_tag="${pxe_tag}-${NETBOOT_HOSTNAME}" + declare pxe_file + if [[ -n "${NETBOOT_CLIENT_MAC_NORMALIZED}" ]]; then + pxe_file="01-${NETBOOT_CLIENT_MAC_NORMALIZED}.${pxe_tag}" + else + pxe_file="${pxe_tag}.example" + fi + + cat > "${pxe_dir}/${pxe_file}" <<- EXTLINUX_CONF + # Generated by ${EXTENSION} for ${BOARD} ${BRANCH} ${RELEASE} + # Target NFS path: ${NETBOOT_NFS_PATH} + DEFAULT armbian + TIMEOUT 30 + PROMPT 0 + + LABEL armbian + MENU LABEL Armbian ${BOARD} ${BRANCH} ${RELEASE} (netboot) + KERNEL ${NETBOOT_TFTP_PREFIX}/${kernel_name}${fdt_line:+ + ${fdt_line}}${initrd_line:+ + ${initrd_line}} + APPEND root=/dev/nfs${nfsroot_param} ip=dhcp rw rootwait earlycon loglevel=7 panic=3 + EXTLINUX_CONF + + display_alert "${EXTENSION}: artifacts ready" "${tftp_out}" "info" + display_alert "${EXTENSION}: TFTP payload" "${NETBOOT_TFTP_PREFIX}/ (${kernel_name}${dtb_payload:+, ${dtb_payload}}${initrd_line:+, uInitrd})" "info" + display_alert "${EXTENSION}: PXE config" "pxelinux.cfg/${pxe_file}" "info" + display_alert "${EXTENSION}: target NFS path" "${NETBOOT_NFS_PATH}" "info" + + # Expose TFTP/PXE context for the deploy hook. The rootfs archive path is + # not known yet — it is produced after pre_umount_final_image, so the + # actual netboot_artifacts_ready dispatch happens in post_create_rootfs_archive. + # shellcheck disable=SC2034 # exposed as netboot_artifacts_ready hook context + declare -g NETBOOT_TFTP_OUT="${tftp_out}" + # shellcheck disable=SC2034 # exposed as netboot_artifacts_ready hook context + declare -g NETBOOT_PXE_FILE="${pxe_file}" +} + +# Dispatched after the rootfs archive / export tree is fully produced, so +# ${ROOTFS_ARCHIVE_PATH} is populated (or deliberately empty when +# ROOTFS_COMPRESSION=none). At this point every netboot artifact exists on +# disk and deploy hooks can safely rsync/ship them. +function post_create_rootfs_archive__900_netboot_deploy() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + _netboot_compute_runtime_defaults + + # shellcheck disable=SC2034 # exposed as netboot_artifacts_ready hook context + declare -g NETBOOT_ROOTFS_ARCHIVE="${ROOTFS_ARCHIVE_PATH:-}" + + call_extension_method "netboot_artifacts_ready" <<- 'NETBOOT_HOOK_DOC' + *called after netboot TFTP tree and rootfs are staged* + Implementations can rsync ${NETBOOT_TFTP_OUT} to a TFTP server, extract + ${NETBOOT_ROOTFS_ARCHIVE} into ${NETBOOT_NFS_PATH} on an NFS server, etc. + When the build host IS the NFS server, prefer ROOTFS_EXPORT_DIR (skips + the archive step and writes straight into the export path). + Exposed context: NETBOOT_TFTP_OUT, NETBOOT_TFTP_PREFIX, NETBOOT_PXE_FILE, + NETBOOT_NFS_PATH, NETBOOT_ROOTFS_ARCHIVE (may be empty), NETBOOT_HOSTNAME, + NETBOOT_CLIENT_MAC, plus BOARD, LINUXFAMILY, BRANCH, RELEASE. + NETBOOT_HOOK_DOC +} From 2e9391911eae05ee65df652867d1548434e9c35e Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:13:11 +0300 Subject: [PATCH 05/16] feat(core): add ROOTFS_TYPE=nfs-root for full network boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New rootfs type for full network boot: the only thing on the device's local storage is U-Boot itself. Kernel, DTB, optional uInitrd and PXE config come from TFTP; rootfs is mounted over NFS. A new case branch in do_main_configuration auto-enables the netboot extension, symmetric with existing fs-f2fs-support / fs-btrfs wiring. The legacy ROOTFS_TYPE=nfs (hybrid: kernel on local storage, only / over NFS) is untouched — both paths coexist. - nfs-root case branch in ROOTFS_TYPE dispatch calls enable_extension "netboot" - prepare_partitions skips root partition creation and SD-size sanity check - check_filesystem_compatibility_on_host skipped for nfs-root - create_image_from_sdcard_rootfs early-returns for nfs-root after the pre_umount hook has grabbed TFTP artifacts from /boot: SDCARD.raw is dropped, the .img pipeline (mv to DESTIMG, write-to-SD, fingerprint, compress) is skipped. For nfs-root the only deliverables are the rootfs archive / ROOTFS_EXPORT_DIR tree and the TFTP staging dir — producing a boot-partition .img would be misleading (nothing on the device reads it). Co-Authored-By: Claude Opus 4.6 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- lib/functions/configuration/main-config.sh | 7 +++++++ lib/functions/image/partitioning.sh | 6 +++--- lib/functions/image/rootfs-to-image.sh | 13 ++++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/functions/configuration/main-config.sh b/lib/functions/configuration/main-config.sh index 598929432e02..87630e23fbf3 100644 --- a/lib/functions/configuration/main-config.sh +++ b/lib/functions/configuration/main-config.sh @@ -141,6 +141,13 @@ function do_main_configuration() { nfs) FIXED_IMAGE_SIZE=256 # small SD card with kernel, boot script and .dtb/.bin files ;; + nfs-root) + # Full netboot: no local storage in the early boot path at all. Kernel, + # DTB and extlinux go to TFTP, rootfs is mounted over NFS. The netboot + # extension owns artifact staging and PXE config generation. + FIXED_IMAGE_SIZE=256 + enable_extension "netboot" + ;; f2fs) enable_extension "fs-f2fs-support" # Fixed image size is in 1M dd blocks (MiB) diff --git a/lib/functions/image/partitioning.sh b/lib/functions/image/partitioning.sh index d1fbf7f61860..a8f5fec17e87 100644 --- a/lib/functions/image/partitioning.sh +++ b/lib/functions/image/partitioning.sh @@ -20,7 +20,7 @@ function prepare_partitions() { # possible partition combinations # /boot: none, ext4, ext2, fat (BOOTFS_TYPE) - # root: ext4, btrfs, f2fs, nilfs2, nfs (ROOTFS_TYPE) + # root: ext4, btrfs, f2fs, nilfs2, nfs, nfs-root (ROOTFS_TYPE) # declare makes local variables by default if used inside a function # NOTE: mountopts string should always start with comma if not empty @@ -121,7 +121,7 @@ function prepare_partitions() { BOOTSIZE=0 fi # Check if we need root partition - [[ $ROOTFS_TYPE != nfs ]] && + [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]] && local rootpart=$((next++)) display_alert "calculated rootpart" "rootpart: ${rootpart}" "debug" @@ -144,7 +144,7 @@ function prepare_partitions() { display_alert "Using user-defined image size" "$FIXED_IMAGE_SIZE MiB" "info" sdsize=$FIXED_IMAGE_SIZE # basic sanity check - if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != btrfs && $sdsize -lt $rootfs_size ]]; then + if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root && $ROOTFS_TYPE != btrfs && $sdsize -lt $rootfs_size ]]; then exit_with_error "User defined image size is too small" "$sdsize <= $rootfs_size" fi else diff --git a/lib/functions/image/rootfs-to-image.sh b/lib/functions/image/rootfs-to-image.sh index 5788cca1b0e0..533555f97451 100644 --- a/lib/functions/image/rootfs-to-image.sh +++ b/lib/functions/image/rootfs-to-image.sh @@ -104,7 +104,7 @@ function create_image_from_sdcard_rootfs() { Called before unmounting both `/root` and `/boot`. PRE_UMOUNT_FINAL_IMAGE - if [[ $ROOTFS_TYPE == nfs ]]; then + if [[ $ROOTFS_TYPE == nfs || $ROOTFS_TYPE == nfs-root ]]; then # ROOTFS_COMPRESSION: zstd (default, .tar.zst) | gzip (.tar.gz) | none (skip archive) declare rootfs_compression="${ROOTFS_COMPRESSION:-zstd}" declare archive_ext="" archive_filter="" @@ -211,6 +211,17 @@ function create_image_from_sdcard_rootfs() { rm -rf --one-file-system "${MOUNT}" # unset MOUNT # don't unset, it's readonly now + # nfs-root: no local storage image. Kernel/DTB/initrd are already staged for TFTP + # by the netboot extension (pre_umount_final_image hook). Rootfs lands in the + # archive under ${FINALDEST} (when ROOTFS_COMPRESSION != none) and/or in the + # user-selected ${ROOTFS_EXPORT_DIR}, which may live outside ${FINALDEST}. + # Drop SDCARD.raw and skip the .img pipeline (write-to-SD, fingerprint, compress). + if [[ "${ROOTFS_TYPE}" == "nfs-root" ]]; then + display_alert "nfs-root" "no local storage image produced" "info" + rm -f "${SDCARD}.raw" + return 0 + fi + mkdir -p "${DESTIMG}" # @TODO: misterious cwd, who sets it? From e4d3657ad3de1df161bc452db5cec7115957f7a0 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:13:11 +0300 Subject: [PATCH 06/16] docs(netboot): add full netboot setup guide Documents the netboot extension: artifact server setup (tftpd-hpa + nfs-kernel-server), TFTP tree layout, DHCP options 66/67 on the network DHCP server, userpatches.conf knobs, the netboot_artifacts_ready hook, a full end-to-end helios64 walkthrough, and a troubleshooting section. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- extensions/netboot/README.md | 927 +++++++++++++++++++++++++++++++++++ 1 file changed, 927 insertions(+) create mode 100644 extensions/netboot/README.md diff --git a/extensions/netboot/README.md b/extensions/netboot/README.md new file mode 100644 index 000000000000..01c8361d71f0 --- /dev/null +++ b/extensions/netboot/README.md @@ -0,0 +1,927 @@ +# Armbian `netboot` extension + +Produces a full network-boot payload for a single-board computer: kernel +image, DTB, optional initrd, PXE/extlinux config, and an NFS-exportable +rootfs. After first boot the **kernel/DTB/rootfs all live on the network** +— U-Boot fetches kernel+DTB over TFTP, then mounts root over NFS, no +local SD/eMMC content needed for those. Local storage is still required +for the **bootloader itself**: U-Boot (or SPL+U-Boot) must already be +flashed to the board's boot media (eMMC, SPI flash, dedicated SD, or +factory ROM-loaded), and that bootloader must be configured to attempt +PXE before any local boot target. The operator provisions the bootloader +once; everything past it is network. + +For the short overview + variable reference see the companion page in +[armbian-doc](https://github.com/armbian/documentation) (`Developer-Guide_Netboot.md`). +This README holds the long-form guide: upstream constraints, server +setup, network configuration, troubleshooting, end-to-end examples. + +## Table of contents + +- [Why this exists (hybrid-NFS vs full netboot)](#why-this-exists) +- [Build-time variables](#build-time-variables) +- [Build artifacts matrix](#build-artifacts-matrix) +- [Upstream constraint: U-Boot does not support proxyDHCP](#upstream-constraint-u-boot-does-not-support-proxydhcp) +- [Server side: TFTP + NFS](#server-side-tftp--nfs) +- [Network side: DHCP options 66/67](#network-side-dhcp-options-6667) +- [Builder-as-NFS-server single-step workflow](#builder-as-nfs-server-single-step-workflow) +- [Multi-board / multi-host deployments](#multi-board--multi-host-deployments) +- [First boot and `armbian-firstrun`](#first-boot-and-armbian-firstrun) +- [End-to-end example: helios64](#end-to-end-example-helios64) +- [Troubleshooting](#troubleshooting) + +## Why this exists + +`ROOTFS_TYPE=nfs` alone produces a hybrid image: kernel and DTB still +live on a local boot partition (SD/eMMC), only `/` comes over NFS. +`ROOTFS_TYPE=nfs-root` takes it further — kernel, DTB and PXE config +are also staged for TFTP, and on every reboot the only thing the target +needs is a network with working DHCP+TFTP+NFS, **plus a PXE-capable +U-Boot already living on its boot media** (eMMC/SPI/dedicated SD). +Provisioning that bootloader is a one-time operator task, outside this +extension's scope; once it's there and configured to try PXE first, +the rest is network. Selecting `nfs-root` is the single switch that +turns this extension on; it is auto-enabled from the core `ROOTFS_TYPE` +dispatch, no separate `ENABLE_EXTENSIONS` flag is needed. + +Use cases: + +- Read-only / tamper-evident workstations and kiosks. +- SBC clusters where one machine owns the storage and N workers pull + their rootfs over NFS. +- Development loops where `edit → build → reboot target` should not + involve flashing or SD-card swaps. +- Boards with damaged or missing eMMC/SD but a working Ethernet PHY. + +Local storage (NVMe, USB, swap partition, data disks) can still be +mounted at runtime — the extension only arranges for the *early* boot +to come over the wire. + +## Build-time variables + +All variables are optional. The only required step is +`ROOTFS_TYPE=nfs-root`; defaults give you a single shared rootfs +per `BOARD × BRANCH × RELEASE` and a tagged +`pxelinux.cfg/--.example` file. The tag +matches the kernel/rootfs paths so building several variants for +one board doesn't overwrite each other; the operator picks the +active one by symlinking it to `default--` (or +`01-`) under `pxelinux.cfg/`. + +| Variable | Default | Purpose | +|---|---|---| +| `NETBOOT_SERVER` | _(empty)_ | IP of the NFS server baked into `nfsroot=`. When empty, the extension writes `nfsroot=,tcp,v3` (path-only, no server) into APPEND; the kernel resolves the NFS server from DHCP `siaddr` (the boot-server field in the DHCP offer, set via `dhcp-boot` in dnsmasq). The same image then boots against any server the router announces as boot-server without any per-image configuration. Set this when you'd rather hard-code a single server in the PXE config. extlinux does not expand `${serverip}` in APPEND, so a literal placeholder is not an option. | +| `NETBOOT_TFTP_PREFIX` | `armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}` | Path prefix inside TFTP root. One board can share one TFTP root with many other boards because each lives under its own prefix. `` is `-min` when `BUILD_MINIMAL=yes`, `-desktop` when `BUILD_DESKTOP=yes`, otherwise empty — so CLI / minimal / desktop builds for the same board+branch+release coexist side by side. | +| `NETBOOT_NFS_PATH` | see below | Absolute NFS path of the rootfs on the server. The APPEND line uses exactly this string for `nfsroot=...`. | +| `NETBOOT_HOSTNAME` | _(empty)_ | Per-host deployment. When set, the default `NETBOOT_NFS_PATH` becomes `/srv/netboot/rootfs/hosts/` — each machine owns its own writable rootfs copy. When empty, the default is `/srv/netboot/rootfs/shared/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}` (one image, potentially reused by identical boards). The `` rule is the same as for `NETBOOT_TFTP_PREFIX` (`-min` / `-desktop` / empty). | +| `NETBOOT_CLIENT_MAC` | _(empty)_ | Client MAC. The user value accepts either separator and any case (e.g. `aa:BB:cc:DD:ee:FF` or `AA-bb-CC-dd-EE-ff`); the extension normalises it to **lowercase, dash-separated** for filename use — so MAC `aa:BB:cc:DD:ee:FF` becomes `aa-bb-cc-dd-ee-ff`. The build then writes `pxelinux.cfg/01-aa-bb-cc-dd-ee-ff.--[-]` (tagged file, not a name U-Boot looks up). To activate, symlink it under the U-Boot lookup name `01-aa-bb-cc-dd-ee-ff` (lowercase + dashes — that's exactly what U-Boot's PXELINUX per-MAC fallback resolution requests). The exact generated filename is also exposed via `NETBOOT_PXE_FILE` for `netboot_artifacts_ready` hooks. | +| `ROOTFS_COMPRESSION` | `zstd` | Format of the rootfs archive produced by `create_image_from_sdcard_rootfs`. `zstd` (alias `zst`) → `.tar.zst`, `gzip` → `.tar.gz`, `none` → no archive at all. The `none` case requires `ROOTFS_EXPORT_DIR`. | +| `ROOTFS_EXPORT_DIR` | _(empty)_ | rsync target for the rootfs tree. **Relative** value (e.g. `shared/rockchip64/helios64/edge-trixie`) is confined under `${SRC}/output/netboot-export/` so `rsync --delete` cannot escape that subtree. **Absolute path outside the build tree** (e.g. `/srv/netboot/rootfs/shared//-` or `/nfsroot`) is kept as-is and bind-mounted into the container at the same path; rsync writes straight into the host export tree. The directory must exist on the host before the build (typically `sudo mkdir -p` for root-owned NFS roots). Primary use: builder host is also the NFS server — single-step `build → boot` loop, no tar/unpack/rsync hop. System roots (`/`, `/etc`, `/usr`, ...) and `..` segments are rejected. The build stamps a `.netboot_export_marker` at the root of every export tree it writes; a non-empty target without that marker is refused (so `rsync --delete` cannot wipe an unrelated Linux tree at the same path) unless `NETBOOT_EXPORT_FORCE=yes`. | +| `NETBOOT_EXPORT_FORCE` | `no` | Set to `yes` to allow overwriting a non-empty `ROOTFS_EXPORT_DIR` that does not carry the `.netboot_export_marker` stamp (rsync `--delete` will clobber whatever is there). | + +### Hook: `netboot_artifacts_ready` + +Called from `post_create_rootfs_archive__900_netboot_deploy`, after +the TFTP tree and rootfs archive/export are staged. Exposed context: + +| Variable | Meaning | +|---|---| +| `NETBOOT_TFTP_OUT` | Absolute path of the staging directory (`${FINALDEST}/-netboot-tftp`; by default `FINALDEST=output/images`). | +| `NETBOOT_TFTP_PREFIX` | As above. | +| `NETBOOT_NFS_PATH` | As above. | +| `NETBOOT_PXE_FILE` | The tagged file written under `pxelinux.cfg/`: `--[-].example` or `01-.--[-]`. The operator symlinks one of these to the name U-Boot actually looks for (`default--`, `default`, or `01-`). | +| `NETBOOT_ROOTFS_ARCHIVE` | Full path to the produced rootfs archive (empty when `ROOTFS_COMPRESSION=none`). | +| `NETBOOT_HOSTNAME` | Passed through verbatim — no sanitization. Hook code that embeds it in a shell command or a path must quote/escape itself. | +| `NETBOOT_CLIENT_MAC` | The raw user value (`aa:bb:cc:dd:ee:ff` or `aa-bb-cc-dd-ee-ff`). Normalise yourself if you need a specific form. | +| `BOARD`, `LINUXFAMILY`, `BRANCH`, `RELEASE` | Standard build variables. | + +Implement this hook in `userpatches/extensions/` to rsync the TFTP +tree to a netboot server, unpack the rootfs archive into the export +path, notify a monitoring system, etc. When the build host is the +NFS server, prefer `ROOTFS_EXPORT_DIR` — the hook then only needs to +handle the TFTP side. + +### Reference implementation: `netboot-deploy.sh` + +`extensions/netboot/netboot-deploy.sh` ships a worked example of this +hook: it rsyncs the TFTP tree to a remote server over SSH and untars +the rootfs archive into the NFS export. It is **not loaded +automatically** — opt in by adding `netboot-deploy` to the extension +list; it pulls in `netboot` itself: + +```sh +DOCKER_PASS_SSH_AGENT=yes \ +./compile.sh build BOARD=helios64 BRANCH=edge RELEASE=resolute \ + BUILD_MINIMAL=yes ROOTFS_TYPE=nfs-root NETBOOT_SERVER=192.168.1.10 \ + ENABLE_EXTENSIONS=netboot-deploy \ + NETBOOT_DEPLOY_SSH=root@netboot.local +``` + +For Docker builds (the default), the SSH client inside the container +needs credentials. Pick one of two paths depending on the workflow: + +- **Interactive use — agent forwarding.** Add `DOCKER_PASS_SSH_AGENT=yes` + (as above). The host `ssh-agent` socket is forwarded into the + container; the agent must be live and the socket reachable by the + container user. + +- **Batch / CI — bind-mount a key file.** Set + `NETBOOT_DEPLOY_SSH_KEY=/path/to/key`. The hook mounts the file + read-only and re-copies it to a root-owned scratch path inside the + container so OpenSSH accepts it. No agent required. + +Without one of those, the `ssh`/`rsync` calls fall back to password +auth and fail under `BatchMode=yes`. + +Configuration variables (see the file header for details): + +| Variable | Meaning | +|---|---| +| `NETBOOT_DEPLOY_SSH` | SSH target for the netboot server (required). | +| `NETBOOT_DEPLOY_TFTP_ROOT` | TFTP root on the server. Default: `/srv/netboot/tftp`. | +| `NETBOOT_DEPLOY_TFTP_DELETE` | `yes` (default) enables `rsync --delete` on TFTP; set `no` when the TFTP root is shared with unrelated deployments. | +| `NETBOOT_DEPLOY_EXCLUDE_FILE` | Optional `rsync --exclude-from` file applied to the TFTP sync. | +| `NETBOOT_DEPLOY_SUDO` | `yes` runs the remote `rsync`, `mkdir`, and `tar` under `sudo -n`. Default: `no`. Required when the SSH account cannot write to `NETBOOT_DEPLOY_TFTP_ROOT` or `NETBOOT_NFS_PATH` directly. Needs passwordless sudo on the server. | +| `NETBOOT_DEPLOY_SSH_KEY` | Path to a private key file. The hook bind-mounts the file read-only at the same path inside the container, copies it to a root-owned scratch path before use (OpenSSH refuses identity files whose owner is neither root nor the current user), and adds `-i ` to the ssh command. Use for batch/CI runs without a live ssh-agent. | +| `NETBOOT_DEPLOY_SSH_KNOWN_HOSTS` | Path to a `known_hosts` file on the build host. The hook bind-mounts it into the container at `/root/.ssh/known_hosts:ro`. Default: auto-pickup of `${HOME}/.ssh/known_hosts` if it exists. See "SSH host identity" below for how this composes with the other knobs. | +| `NETBOOT_DEPLOY_SSH_TOFU` | `yes` switches ssh to ephemeral trust-on-first-use: `-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new` — each connection learns the host key fresh, persists nothing. For trusted-segment use only. Default: `no`. Mutually exclusive with `NETBOOT_DEPLOY_SSH_KNOWN_HOSTS` (and the `${HOME}/.ssh/known_hosts` auto-pickup). | +| `NETBOOT_DEPLOY_SSH_OPTS` | Extra ssh options applied to both `ssh` and `rsync`. Default: `-o BatchMode=yes`. Strict by design (fails fast on unknown auth and on unknown/changed host keys). Use the dedicated `NETBOOT_DEPLOY_SSH_KNOWN_HOSTS` / `NETBOOT_DEPLOY_SSH_TOFU` for host-identity control; this variable is for arbitrary other tweaks (`ConnectTimeout`, `ProxyCommand`, etc.). | +| `NETBOOT_DEPLOY_PROBE` | `yes` (default) probes the deploy target during host-phase config (ssh + optional sudo + `touch`+`rm` under `NETBOOT_DEPLOY_TFTP_ROOT`). Surfaces missing keys, password-locked sudo, or a read-only/non-existent TFTP root in seconds, before the kernel build. Set `no` to bypass when the probe itself becomes the obstacle (jumphosts, custom `ProxyCommand`, targets that allow rsync via a wrapper but disallow plain `touch`). | + +#### SSH host identity + +The deploy hook needs to know the SSH host key of the deploy target before +it will ssh/rsync into it. Default behaviour is **strict**: an unknown or +changed host key fails the build instead of silently being trusted. Pick +one of three sources for the key: + +| Scenario | What to set | What happens | +|---|---|---| +| **Interactive (your laptop)** | _(nothing — auto)_ | If `${HOME}/.ssh/known_hosts` exists on the build host, it is bind-mounted into docker read-only at `/root/.ssh/known_hosts`. SSH'd to the target manually once → the build inherits that trust. | +| **CI / containers without `${HOME}/.ssh`** | `NETBOOT_DEPLOY_SSH_KNOWN_HOSTS=/path/to/known_hosts` | The given file is bind-mounted into docker. Pre-populate it via `ssh-keyscan -H target >> /path/to/known_hosts` in a CI step (or as a pipeline secret). Survives the build cleanly. | +| **Home lab — trusted segment** | `NETBOOT_DEPLOY_SSH_TOFU=yes` | Adds `-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new`. Each connection learns the host key fresh, persists nothing — no setup, no MITM protection. | + +`NETBOOT_DEPLOY_SSH_TOFU=yes` is mutually exclusive with the +`NETBOOT_DEPLOY_SSH_KNOWN_HOSTS` mount (and with the auto-pickup +`${HOME}/.ssh/known_hosts`); the build refuses to ambiguate which source +of trust applies. If the auto-pickup gets in the way for a quick TOFU +test, unset it explicitly: `NETBOOT_DEPLOY_SSH_KNOWN_HOSTS=/dev/null` +plus `NETBOOT_DEPLOY_SSH_TOFU=yes` is rejected — move/rename your +`${HOME}/.ssh/known_hosts` instead, or just commit to one mode. + +The rootfs archive is untarred into `NETBOOT_NFS_PATH` without wiping +existing content — per-host state (ssh host keys, machine-id, +`/home`) survives rebuilds, but files removed from the source +accumulate. For a clean slate, empty `NETBOOT_NFS_PATH` on the +server between deploys. When `ROOTFS_COMPRESSION=none` is combined +with `ROOTFS_EXPORT_DIR` (builder-as-NFS-server), the archive step +is skipped — only TFTP is deployed. + +#### Kernel-only deploy + +A narrow optimization for **boards whose boot networking driver is built +into the kernel** (e.g. helios64 mvneta, helios4 mvneta — anything where +`root=/dev/nfs ip=dhcp` works without an initramfs). On such boards a +fresh kernel can be deployed without rebuilding the rootfs or initramfs +at all, which turns a 15-minute full image rebuild into a 10-second +incremental refresh — useful for kernel debugging and config iteration. + +Triggered by `compile.sh kernel ...`: + +```bash +./compile.sh iav kernel \ + BOARD=helios64 BRANCH=edge \ + ENABLE_EXTENSIONS=netboot,netboot-deploy \ + NETBOOT_DEPLOY_SSH=root@m1 +``` + +The `artifact_ready` handler unpacks the produced +`linux-image-${BRANCH}-${LINUXFAMILY}_*.deb`, rsyncs `vmlinuz` and dtbs +into `${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/`, syncs +`/lib/modules/${kver}/` (with `--delete`) into the NFS rootfs at +`${NETBOOT_NFS_PATH}/lib/modules/${kver}/`, and **removes any +pre-existing `uInitrd`** from the TFTP prefix. + +This closes a coherence gap that otherwise produces `BPF: Invalid +name_offset:N` and `failed to validate module BTF: -22` spam on boot: +split-BTF references in `.ko` modules point to the BTF inside the +running vmlinux; if the kernel image and the modules in `/lib/modules/` +come from different build runs, every match resolves to a wrong string +offset. Coherent kernel + modules deployed together fix it. + +**Initramfs is intentionally not regenerated, by design.** Initramfs +content depends on the *configured* rootfs — `customize_image` hooks, +`/etc/initramfs-tools/*` tweaks, board-specific extensions, userpatches +overlay — and that context only exists during a full image build. Past +that build, the configured rootfs lives only on the NFS server, which +may be an OpenWRT box or anything else that cannot run +chroot+update-initramfs. Regenerating from a generic post-debootstrap +rootfs cache would produce a *different* initramfs than the one a full +image build would have made — wrong, not just incomplete. + +So the kernel-only deploy drops the previous build's `uInitrd` and lets +the kernel boot without an initramfs: + +- **Boards with built-in boot networking**: `root=/dev/nfs ip=dhcp` runs + directly from kernel — clean boot, every time. Modules under + `/lib/modules/${kver}/` get loaded later for non-boot-critical drivers + (WiFi, BT, etc.). +- **Boards needing modular drivers in initramfs** (USB-eth, modular NIC, + modular SATA, etc.): kernel will fail fast at networking instead of + silently corrupting later. The fix for those boards is **not** a kernel + refresh — it is a full image rebuild + (`compile.sh build ROOTFS_TYPE=nfs-root ...`), which produces a fresh + initramfs alongside the kernel. + +Use the kernel-only path when you know your board falls in the first +category. If unsure, the full image rebuild is always the safe choice. + +## Build artifacts matrix + +What ends up under `output/images/` for a given combination of +`ROOTFS_TYPE` × `ROOTFS_COMPRESSION` × `ROOTFS_EXPORT_DIR`. Vanilla armbian +behaviour is shown first; the netboot extension only **adds** a TFTP tree +to the picture, it does not replace any of the rootfs/archive outputs. + +| `ROOTFS_TYPE` | `ROOTFS_COMPRESSION` | `ROOTFS_EXPORT_DIR` | Produced under `output/images/` | +|---|---|---|---| +| `ext4`/`btrfs`/`f2fs`/`xfs`/`nilfs2` | (n/a) | (n/a) | `.img[.xz/zst/zip]` + `.txt` + `.sha` (flashable image) | +| `nfs` / `nfs-root` | `none` | _empty_ | **build fails** — `ROOTFS_COMPRESSION=none requires ROOTFS_EXPORT_DIR` (no archive, no rsync target — nothing produced) | +| `nfs` / `nfs-root` | `none` | _set_ | rsync into `${ROOTFS_EXPORT_DIR}/` only (no `-rootfs.tar.*` archive) | +| `nfs` / `nfs-root` | `zstd`/`zst` (default) | _empty_ | `-rootfs.tar.zst` archive only | +| `nfs` / `nfs-root` | `zstd`/`zst` | _set_ | `-rootfs.tar.zst` archive **and** rsync into `${ROOTFS_EXPORT_DIR}/` (both produced) | +| `nfs` / `nfs-root` | `gzip` | _empty_ | `-rootfs.tar.gz` archive only | +| `nfs` / `nfs-root` | `gzip` | _set_ | `-rootfs.tar.gz` archive **and** rsync into `${ROOTFS_EXPORT_DIR}/` | + +**With this extension loaded**, every `ROOTFS_TYPE=nfs-root` build (auto-enabled +by setting `ROOTFS_TYPE=nfs-root`) **additionally** writes a TFTP tree under +`-netboot-tftp/` next to whatever rootfs output the row above produces: + +```text +-netboot-tftp/ + pxelinux.cfg/ + --[-].example # tagged file (or 01-.<...> with NETBOOT_CLIENT_MAC) + / # default: armbian///-/ + Image # or zImage on armhf + dtb/<...>/*.dtb + uInitrd # only if /boot/uInitrd was produced +``` + +The TFTP tree is independent of `ROOTFS_COMPRESSION`/`ROOTFS_EXPORT_DIR` — it is +always staged, so even an archive-only build yields a complete TFTP payload that +can be deployed separately (for example via a `netboot_artifacts_ready` hook). + +## Upstream constraint: U-Boot does not support proxyDHCP + +This is the single most important fact behind the server-side design. +Any tutorial that tells you to set up a "PXE proxy server" next to +your existing router DHCP will not work with U-Boot — it works with +BIOS/UEFI PXE ROMs but not with Das U-Boot's `bootp.c`. + +What the U-Boot source (`net/bootp.c`, current master) actually does: + +- Sends `vendor-class-identifier = U-Boot.armv8` (or `.armv7`). It + does **not** send `PXEClient`, so a proxyDHCP server that filters + on vendor class (the standard case for dnsmasq `dhcp-range=...,proxy`) + will not answer at all. +- Parses the **first** `DHCPOFFER` it sees. The state machine + immediately transitions `SELECTING → REQUESTING`: + ```c + dhcp_state = REQUESTING; + dhcp_send_request_packet(bp); + ``` + A second OFFER from a separate PXE server arriving moments later is + silently discarded. +- Takes `server-ip` (`siaddr`) from that first OFFER only: + `net_server_ip = ntohl(bp->bp_siaddr)`. If the router answered + first with `siaddr = router_ip`, U-Boot will TFTP from the router. +- Never talks to UDP/4011 (Boot Server Discovery), which is the + second phase of the PXE spec that a proxyDHCP flow depends on. + +**Consequence:** any scheme where the router hands out IPs and a +separate server is supposed to add PXE options is architecturally +incompatible with U-Boot without patching the client. The PXE +information (`siaddr`, `bootfile`) must come from the **same** DHCP +server that hands out the IP. + +Two supported workarounds: + +1. **Put DHCP options 66/67 on the main network DHCP server** (usually + the router). Works unmodified with upstream U-Boot. Documented + below. _This is the path the extension is designed around._ +2. Persist `serverip` in U-Boot environment via `env set serverip …; + env save`. This is per-board, brittle (`env` offset can be wiped + by an image flash), and not something the Armbian build can do for + you — but it's there if you absolutely cannot touch your DHCP. + +## Server side: TFTP + NFS + +The minimal production setup is `tftpd-hpa` + `nfs-kernel-server` on +one Linux host. **No DHCP runs on the server.** DHCP lives on the +network router (see next section). + +> **GNU tar required on the deploy target.** When the deploy hook +> ships a rootfs archive over SSH, it extracts with +> `tar -xp --xattrs --xattrs-include='*' --acls --selinux`. Busybox tar +> (Alpine, OpenWRT) silently drops xattrs on extract — `security.capability` +> for binaries like `ping`/`mtr` is lost and they fail at runtime +> regardless of host filesystem support. The `--acls`/`--selinux` flags +> are also unrecognized on Busybox. Use a regular Linux distribution +> with GNU tar on the NFS server. + +Directory layout: + +```text +/srv/netboot/ + tftp/ # TFTP root (= TFTP_DIRECTORY) + pxelinux.cfg/ + --.example # tagged file the build writes; not + # a name U-Boot looks up — must be + # promoted via mv/ln to activate + default # one of the names U-Boot does look up; + # typically a symlink to a .example file + default-arm-rk3399-helios64 # board-specific fallback (same idea) + 01-aa-bb-cc-dd-ee-ff.-... # per-MAC tagged variant + 01-aa-bb-cc-dd-ee-ff # per-MAC lookup name (symlink to ↑) + armbian/ + //-/ + Image + uInitrd # optional + dtb/ + rockchip/rk3399-kobol-helios64.dtb + rockchip/overlay/*.dtbo + rootfs/ + shared///-/ + etc/ bin/ usr/ ... + hosts// + etc/ bin/ usr/ ... +``` + +### `/etc/default/tftpd-hpa` + +```sh +TFTP_USERNAME="tftp" +TFTP_DIRECTORY="/srv/netboot/tftp" +TFTP_ADDRESS=":69" +TFTP_OPTIONS="--secure --ipv4" +``` + +`--secure` chroots the daemon into `TFTP_DIRECTORY`; `--ipv4` avoids +IPv6 bind conflicts on dual-stack hosts. + +### `/etc/exports` + +```text +# Replace 192.168.1.0/24 with your own LAN subnet or an explicit +# hostname. Never export netboot rootfs to * — anyone who can reach +# the NFS port gets root-equivalent write access. +/srv/netboot/rootfs 192.168.1.0/24(ro,sync,no_subtree_check,no_root_squash,crossmnt,fsid=0) +/srv/netboot/rootfs/shared 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash) +/srv/netboot/rootfs/hosts 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash) +``` + +`no_root_squash` is required so the NFS client can write files owned +by UID 0 — which is why the export MUST be restricted to a trusted +subnet or explicit hosts. `crossmnt`/`fsid=0` makes the top-level a +pseudo-root so clients can mount `shared/...` and `hosts/...` paths +directly without needing the top export. + +### systemd + +```sh +systemctl enable --now tftpd-hpa nfs-kernel-server +exportfs -ra +``` + +Firewall: UDP/69 (TFTP), TCP/2049 (NFS), plus whatever `rpc.mountd` +and `rpc.statd` bind to if you're using NFSv3 — pin them via +`/etc/default/nfs-kernel-server` and `/etc/default/nfs-common` for a +predictable firewall rule. + +## Network side: DHCP options 66/67 + +This is the only section that changes on the *network* side — the +main DHCP server (usually a router) needs to announce two options for +PXE clients: + +- **Option 66** (`tftp-server-name`) — IP or hostname of the TFTP + server. This ends up as `siaddr`/`serverip` in U-Boot. +- **Option 67** (`bootfile-name`) — the filename U-Boot asks for first + via `pxe get`. This must be **`default`**, not `pxelinux.cfg/default` + (see gotcha below). + +### OpenWRT (UCI / dnsmasq as DHCP) + +```sh +uci set dhcp.@dnsmasq[0].dhcp_boot='default,,' +uci commit dhcp +/etc/init.d/dnsmasq restart +``` + +Example for a server reachable as `m1` / `192.168.1.125`: + +```sh +uci set dhcp.@dnsmasq[0].dhcp_boot='default,m1,192.168.1.125' +uci commit dhcp +/etc/init.d/dnsmasq restart +``` + +The three fields are `bootfile,servername,siaddr`. `servername` is +informational (populates the `sname` DHCP field); `siaddr` is what +U-Boot actually uses. + +**LuCI has no UI for `dhcp_boot`.** The "DHCP-Options" field in +*Network → DHCP and DNS → Advanced Settings* is a different mechanism +(`list dhcp_option`) and cannot express option 66/67 cleanly. The only +way to see / change `dhcp_boot` is via UCI/SSH or by reading +`/etc/config/dhcp` directly. + +### NFS server resolution for an empty `NETBOOT_SERVER` + +Options 66/67 above only handle the **TFTP** stage. The kernel still +needs to know which NFS server to mount as `/`. Two ways: + +1. **Bake it into the image**: set `NETBOOT_SERVER=` at build time; + the extension writes a fixed `nfsroot=:,tcp,v3` into + the PXE config. The router only needs options 66/67. +2. **Use DHCP `siaddr`** (the default when `NETBOOT_SERVER` is empty): + the extension writes `nfsroot=,tcp,v3` (no server) into APPEND. + At boot the kernel's IP-Config resolves the NFS server from the + `siaddr` field of the DHCP offer (the boot-server field). Set this on + the router via `dhcp-boot` / `next-server` in dnsmasq: + +```sh +# OpenWRT / dnsmasq via UCI — set siaddr to the NFS server +uci set dhcp.@dnsmasq[0].dhcp_boot='default,nfsserver,192.168.1.x' +uci commit dhcp && /etc/init.d/dnsmasq restart + +# dnsmasq standalone +dhcp-boot=default,nfsserver,192.168.1.x +``` + +The extension installs a dhcpcd hook (`71-netboot-rootpath`) in the +initramfs that reads the boot server from `/proc/net/pnp` (written by +the kernel IP-Config) and appends it as `ROOTSERVER` to +`/run/net-${interface}.conf`, overriding the default gateway value that +`70-net-conf` writes there. If `/proc/net/pnp` is absent or has no +valid `bootserver` entry the hook clears `ROOTSERVER` explicitly and +logs a warning, so the failure is immediate and visible. + +### The `bootfile=default` gotcha + +Set the bootfile to `default`, **not** `pxelinux.cfg/default`. U-Boot's +`pxe get` treats the bootfile path as a *directory*, extracts the +directory component as `bootdir`, and then its internal +`get_pxelinux_path()` prefixes `pxelinux.cfg/` again. So: + +- bootfile = `default` → bootdir = `""` → requests become + `pxelinux.cfg/01-`, `pxelinux.cfg/`, `pxelinux.cfg/default` + — correct paths, `tftpd-hpa` finds them. +- bootfile = `pxelinux.cfg/default` → bootdir = `pxelinux.cfg/` → + requests become `pxelinux.cfg/pxelinux.cfg/01-`, and so on — + doubled prefix, `tftpd-hpa` returns file-not-found for everything. + +### Other DHCP servers + +The same two options translate directly: + +- **isc-dhcp-server**: `next-server ; filename "default";` inside + the relevant subnet or host stanza. +- **dnsmasq standalone**: `dhcp-boot=default,,` in + `dnsmasq.conf`. +- **Mikrotik/RouterOS**: `/ip dhcp-server network set [find] boot-file-name=default next-server=`. +- **EdgeOS / VyOS**: `set service dhcp-server shared-network-name + subnet bootfile-name default` and + `bootfile-server `. +- **Windows DHCP Server**: Scope Options → 066 (Boot Server Host Name) + + 067 (Bootfile Name), value `default`. + +## Builder-as-NFS-server single-step workflow + +When the machine building the image is also the NFS server for the +target, you can skip the archive entirely: build straight into the +export directory. + +Point `ROOTFS_EXPORT_DIR` straight at the NFS export tree. Any +absolute path outside the build tree works; the extension bind-mounts +it into the container at the same absolute path. The in-container +rsync (running as docker-root, no userns-remap) writes files with +their original ownership preserved — exactly what NFS-mounted root +filesystems need. The export step is a plain `rsync`; it copies +file-by-file, so on a multi-GiB rootfs expect minutes (cache and +incremental updates make repeat builds much faster). + +Alternative for sites that prefer keeping export paths inside the +build tree: symlink `output/netboot-export` to the NFS root once and +use a relative `ROOTFS_EXPORT_DIR=shared/...`. The symlink does not +survive `rm -rf output/` and similar cleanups, so the absolute-path +form is the safer default for unattended workflows. + +```sh +# One-time, only if you go the symlink route. Run from the Armbian +# checkout root, or replace `$PWD` with the absolute checkout path. +ln -s /srv/netboot/rootfs "$PWD/output/netboot-export" +``` + +Without either approach everything still works — the artefacts just +sit under `output/netboot-export/` inside the checkout and you rsync +them out later (which downgrades ownership and copies bytes). + +```sh +./compile.sh build \ + BOARD=helios64 BRANCH=edge RELEASE=trixie \ + BUILD_MINIMAL=yes \ + ROOTFS_TYPE=nfs-root \ + NETBOOT_SERVER=192.168.1.125 \ + NETBOOT_HOSTNAME=helios64-a \ + NETBOOT_CLIENT_MAC=aa:bb:cc:dd:ee:ff \ + ROOTFS_COMPRESSION=none \ + ROOTFS_EXPORT_DIR=hosts/helios64-a +``` + +What happens: + +- `ROOTFS_COMPRESSION=none` skips the tar/gzip step. No `*.tar.gz` + appears under `output/images/`. +- `ROOTFS_EXPORT_DIR=hosts/helios64-a` expands to + `${SRC}/output/netboot-export/hosts/helios64-a` on the host side; + rsync (`-aHWh -AXS --numeric-ids`, falling back to bare + `--numeric-ids` only on nilfs2 which has no xattr support) populates + it from the chroot, preserving permissions, hardlinks, ACLs (`-A`), + xattrs incl. file capabilities (`-X`), sparse holes (`-S`) and + numeric ownership. +- In Docker builds `ROOTFS_EXPORT_DIR` resolves under + `${SRC}/output/netboot-export/...` (inside the container `${SRC}` + expands to `/armbian`, so the same path works on both sides). When + `output/netboot-export` is a symlink to an external root (e.g. + `/srv/netboot/rootfs` on a builder-as-NFS-server), the extension + bind-mounts that target into the container at its **original + absolute path** so the symlink resolves identically inside and out. + Native builds work the same — just no docker hop. +- `pre_umount_final_image__900_collect_netboot_artifacts` still + produces the TFTP tree at + `${FINALDEST}/-netboot-tftp/armbian///-/` + (by default under `output/images/`) — you rsync that into your TFTP + root as usual. + +Requirements: + +- The export directory must be writable by the build process (root in + most setups — `compile.sh` escalates via sudo). +- Disk budget: roughly 1.5 GB per `BUILD_MINIMAL` rootfs, more for + desktop images. Multiply by the number of `hosts/` + directories. +- `ROOTFS_COMPRESSION=none` without `ROOTFS_EXPORT_DIR` is rejected + early (in `extension_prepare_config`) — otherwise nothing would be + produced at all. + +When this workflow does **not** fit: + +- Builder and NFS server are different machines with no shared mount. + Use `ROOTFS_COMPRESSION=gzip|zstd` and rsync/ssh the archive via a + `netboot_artifacts_ready` hook (or by hand). +- Two parallel builds targeting the same `ROOTFS_EXPORT_DIR` — rsync + will clobber each other. Use distinct directories (a per-host + layout already gives you that). + +## Multi-board / multi-host deployments + +Armbian does not have a universal image. The smallest unit is +`BOARD × BRANCH × RELEASE`, and even among boards in the same SoC +family the BSP (`armbian-bsp-cli-*`) is per-board. Plan sharing +accordingly: + +| Share | x86 ↔ arm64 | rockchip64 ↔ meson64 | helios64 ↔ rock5b | +|---|---|---|---| +| Kernel image | impossible (different arch) | different kernel packages | different kernels | +| DTB | x86 doesn't use DTBs | different DTB trees | different DTBs | +| rootfs binaries | impossible | technically compatible | technically compatible | +| `armbian-bsp-cli-*` | per-board | per-board | per-board | + +Practically, the maximum rootfs sharing is **N physical boards of +identical model**, and even that has caveats (see "identical boards" +below). + +Supported patterns: + +1. **One board, one image.** Default: `shared///-/`, + `pxelinux.cfg/default` points at it. +2. **N different boards (different models).** Each in its own + `shared///...`; each board's U-Boot requests + `01-` first, so per-MAC PXE configs are the routing mechanism. + Build each with a different `NETBOOT_CLIENT_MAC`. +3. **N identical boards, per-host rootfs.** `NETBOOT_HOSTNAME=` + → rootfs lives at `hosts//`. Each board gets its own copy; + there are no shared-write conflicts (`/var/log`, `/etc/machine-id`, + `/etc/ssh/ssh_host_*_key`, etc.). Build once per host, deploy each + to its own directory. +4. **N identical boards, one rootfs (advanced).** Technically possible + with a read-only rootfs + tmpfs/overlay over the writable paths, + but the Armbian build itself does not set this up for you — the + produced rootfs assumes single-host ownership. If you need this, + layer a `userpatches/customize-image.sh` that moves `/var`, `/etc` + and `/home` onto tmpfs/overlay mounts in `/etc/fstab`, and use + `ro` instead of `rw` in the NFS export + APPEND. + +Explicit non-goals: + +- One rootfs shared between architectures (x86 + arm64). +- One rootfs shared between SoC families (rockchip64 + meson64 would + conflict on the BSP and often on kernel ABI). +- "Generic Linux netboot" — that's the job of Debian/Ubuntu + netinstall images, not Armbian. + +## First boot and `armbian-firstrun` + +Armbian has two "first boot" mechanisms that matter for netboot. They +are often confused: + +| Name | Unit / script | What it does | Netboot treatment | +|---|---|---|---| +| `armbian-firstrun.service` | systemd | Regenerates SSH host keys, calls helper scripts. Non-interactive. | **Kept.** Harmless on NFS root. | +| `armbian-firstlogin` (wizard) | `/etc/profile.d/armbian-check-first-login.sh` on first shell login | Runs the whiptail wizard: root password → create user → timezone → locale. **Interactive unless the trigger file holds `PRESET_*` values for `preset-firstrun`.** | **Conditionally suppressed.** The extension removes `/root/.not_logged_in_yet` during `post_customize_image` **only when it is empty**. A non-empty trigger file (populated by `preset-firstrun` or similar provisioning) is kept so presets still apply non-interactively on first boot. Empty flag removed → wizard skipped, default `root/1234` login continues to work. | + +The extension also drops the `armbian-resize-filesystem.service` +enablement symlink — that unit calls `resize2fs` on the root block +device, which does not exist on an NFS root and errors out. + +A separate `/boot` partition is not needed for netboot, so the +extension sets `BOOTSIZE=0` to disable it. + +**If you want the wizard-set values** (user account, timezone, +locale), bake them into the image at build time: + +```sh +# userpatches/customize-image.sh +useradd -m -s /bin/bash -G sudo,netdev alice +echo 'alice:' | chpasswd # or skip this line and rely on SSH key auth only +mkdir -p /home/alice/.ssh +cat > /home/alice/.ssh/authorized_keys <<'KEY' +ssh-ed25519 AAAAC3... alice@laptop +KEY +chown -R alice:alice /home/alice/.ssh +chmod 700 /home/alice/.ssh +chmod 600 /home/alice/.ssh/authorized_keys + +ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime +echo 'LANG=en_US.UTF-8' > /etc/default/locale +``` + +This gives you the same result as running the wizard, without the +interactive hang on first boot. `armbian-firstrun.service` still runs +once on the first boot of a fresh rootfs to generate SSH host keys +(and other unit-of-work helpers); on an NFS root those keys are then +written into the export tree and persist across subsequent reboots — +the host identity is stable, not regenerated each boot. + +## End-to-end example: helios64 + +Target: Helios64 (`rockchip64/helios64`, `edge`/`trixie`, +`ttyS2@1500000`). Builder and NFS server are the same Linux host at +`192.168.1.125`, reachable as `m1`. DHCP is OpenWRT at `192.168.1.1`. + +### 1. Server + +```sh +apt install tftpd-hpa nfs-kernel-server +cat > /etc/default/tftpd-hpa <<'EOF' +TFTP_USERNAME="tftp" +TFTP_DIRECTORY="/srv/netboot/tftp" +TFTP_ADDRESS=":69" +TFTP_OPTIONS="--secure --ipv4" +EOF +mkdir -p /srv/netboot/{tftp/pxelinux.cfg,rootfs/shared,rootfs/hosts} +cat >> /etc/exports <<'EOF' +# Restrict to your LAN subnet — see the security note in the +# `/etc/exports` section above. Never use `*` with `no_root_squash`. +/srv/netboot/rootfs 192.168.1.0/24(ro,sync,no_subtree_check,no_root_squash,crossmnt,fsid=0) +/srv/netboot/rootfs/shared 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash) +/srv/netboot/rootfs/hosts 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash) +EOF +systemctl enable --now tftpd-hpa nfs-kernel-server +exportfs -ra +``` + +### 2. Router (OpenWRT) + +```sh +ssh root@192.168.1.1 \ + "uci set dhcp.@dnsmasq[0].dhcp_boot='default,m1,192.168.1.125'; \ + uci commit dhcp; /etc/init.d/dnsmasq restart" +``` + +### 3. Build (single-step, builder = NFS server) + +```sh +./compile.sh build \ + BOARD=helios64 BRANCH=edge RELEASE=trixie \ + BUILD_MINIMAL=yes \ + ROOTFS_TYPE=nfs-root \ + NETBOOT_SERVER=192.168.1.125 \ + ROOTFS_COMPRESSION=none \ + ROOTFS_EXPORT_DIR=/srv/netboot/rootfs/shared/rockchip64/helios64/edge-trixie +``` + +### 4. Drop the TFTP tree into place + +> For repeatable multi-board deployments, prefer the `netboot-deploy` +> extension — see [Reference implementation: `netboot-deploy.sh`](#reference-implementation-netboot-deploysh). +> The hook runs after every build with full build-env (`BOARD`, `RELEASE`, +> `BRANCH`, `BOARDFAMILY`, `version`) and rsyncs only the artifacts of +> *this* build, so a single user-config (`NETBOOT_DEPLOY_SSH=...`, +> `NETBOOT_DEPLOY_TFTP_ROOT=...`) covers every board without changes. +> The manual `rsync` below is for one-off builds or troubleshooting. + +```sh +# Substitute your build values (board / release / branch) for the +# wildcards below — the directory name follows +# ${FINALDEST}/-netboot-tftp/ where includes them. +rsync -a output/images/Armbian-*_helios64_trixie_edge_*-netboot-tftp/ /srv/netboot/tftp/ +# -> /srv/netboot/tftp/pxelinux.cfg/helios64-edge-trixie.example +# -> /srv/netboot/tftp/armbian/rockchip64/helios64/edge-trixie/Image +# -> /srv/netboot/tftp/armbian/rockchip64/helios64/edge-trixie/dtb/rockchip/... + +# One-time activation: promote the tagged config into a name U-Boot looks for. +# Pick one of the lookup names depending on scope: +# default — broadest catch-all (any board) +# default-arm-- — board-specific fallback (this example) +# 01- — per-MAC pin (only when NETBOOT_CLIENT_MAC was set) +ln -sfn helios64-edge-trixie.example \ + /srv/netboot/tftp/pxelinux.cfg/default-arm-rk3399-helios64 +``` + +The build writes a tagged file (`--.example`) +that is **not** a name U-Boot looks for, so it never auto-activates — +you must explicitly promote it via the `ln -sfn` (or `mv`) above. +Building another release writes its own tagged file next to this one +without touching the active symlink, so you can keep N variants ready +and switch by re-pointing the symlink — no rebuild, no re-deploy. + +### 5. Boot + +Pull the SD/eMMC out of the Helios64 (or rearrange `boot_targets` so +`pxe` sits before `mmc*`), power it on, and watch the U-Boot console +(`ttyS2 @ 1500000`). + +The captured sample below is a real Helios64 boot. It is from a +slightly different run than the walkthrough above — `edge`/`noble` +instead of `edge`/`trixie`, and the build host at `192.168.1.65` +instead of `192.168.1.125`. The walkthrough remains the authoritative +example; this log is included as a real-world reference. Hosts in +the captured sample: + +| Address | Role | +|---------------|---------------------------------------------------| +| 192.168.1.1 | Gateway and DHCP server (router) | +| 192.168.1.44 | Helios64 — the netboot client being booted | +| 192.168.1.65 | Build host — runs TFTP and NFS server | + +```text +Scanning bootdev 'ethernet@fe300000.bootdev': +ethernet@fe300000 Waiting for PHY auto negotiation to complete. done +Speed: 1000, full duplex +BOOTP broadcast 1 +DHCP client bound to address 192.168.1.44 (8 ms) + +# pxelinux.cfg cascade: U-Boot tries the per-MAC pin first, then +# truncates the 8-hex-char IPv4 progressively, finally falling back +# to the board-specific default symlink. Each miss prints +# "TFTP error: 'File not found' — Not retrying" and is harmless. +Retrieving file: pxelinux.cfg/01-64-62-66-d0-03-cc (not found) +Retrieving file: pxelinux.cfg/C0A8012C (not found) +Retrieving file: pxelinux.cfg/C0A8012 (not found) +... (cascade continues truncating one hex digit at a time) ... +Retrieving file: pxelinux.cfg/default-arm-rk3399-helios64 +Bytes transferred = 482 + + 1 pxe ready ethernet 0 ethernet@fe300000.bootdev extlinux/extlinux.conf +** Booting bootflow 'ethernet@fe300000.bootdev.0' with pxe +1: Armbian helios64 edge noble (netboot) +Retrieving file: armbian/rockchip64/helios64/edge-noble/Image +Retrieving file: armbian/rockchip64/helios64/edge-noble/uInitrd +append: root=/dev/nfs nfsroot=/srv/netboot/rootfs/shared/rockchip64/helios64/edge-noble,tcp,v3 ip=dhcp rw rootwait earlycon loglevel=7 panic=3 +Retrieving file: armbian/rockchip64/helios64/edge-noble/dtb/rockchip/rk3399-kobol-helios64.dtb +Starting kernel ... + +[ 0.000000] Kernel command line: root=/dev/nfs nfsroot=/srv/netboot/rootfs/shared/rockchip64/helios64/edge-noble,tcp,v3 ip=dhcp rw rootwait earlycon loglevel=7 panic=3 +[ 7.999679] rk_gmac-dwmac fe300000.ethernet eth0: Link is Up - 1Gbps/Full +[ 8.022314] Sending DHCP requests ., OK +[ 8.039318] IP-Config: Got DHCP answer from 192.168.1.1, my address is 192.168.1.44 +[ 8.040100] IP-Config: Complete: +[ 8.040420] device=eth0, hwaddr=64:62:66:d0:03:cc, ipaddr=192.168.1.44, mask=255.255.255.0, gw=192.168.1.1 +[ 8.041914] bootserver=192.168.1.65, rootserver=192.168.1.65, rootpath= +[ 8.060050] Run /init as init process +Loading, please wait... +Starting systemd-udevd version 255.4-1ubuntu8.15 +... +Welcome to Armbian-unofficial 26.05.0-trunk noble! +... +[ OK ] Reached target network.target - Network. +[ OK ] Reached target getty.target - Login Prompts. + +helios64 login: +``` + +> Linux 6.x and newer no longer print `VFS: Mounted root (nfs filesystem) on device …` for NFS root mounts (that line was specific to disk-backed root). The canonical "PXE done, NFS root mounted, userspace running" markers are `IP-Config: Complete` immediately followed by `Run /init as init process`. If the second line never appears, the NFS mount itself failed — typically `nfsroot` resolution or export ACLs. + +For quick lab validation, default credentials are `root` / `1234` +because the wizard was suppressed at build time. **Do not leave that +state on any network you don't fully trust** — the wizard is the only +thing that normally forces a password change, and netboot deliberately +skips it. Pick one before the first boot on an untrusted LAN: + +- set `ROOTPWD=` at build time; +- or provision a sudo-capable user + `authorized_keys` via + `userpatches/customize-image.sh` and disable root password login + (`PasswordAuthentication no` / `PermitRootLogin prohibit-password`); +- or write `PRESET_*` into `/root/.not_logged_in_yet` so + `preset-firstrun` applies them non-interactively on first boot + (the extension preserves non-empty trigger files — see the + `armbian-firstlogin` table above). + +The `armbian-firstrun.service` line in the boot log means SSH host keys +have been regenerated on this boot — they'll persist in the NFS rootfs. + +## Troubleshooting + +**`TFTP from server 192.168.1.1` instead of `.125`** — the router is +providing `siaddr` (its own IP). DHCP option 66/67 are not being sent, +or are being sent without `siaddr`. Check `uci show dhcp | grep boot` +on OpenWRT; on other DHCP servers check the equivalent next-server +setting. Also confirm U-Boot is reading the **first** OFFER: it does +not merge multiple OFFERs, so a proxyDHCP server will not help here. + +**`Retrieving file: C0A8012C.img` followed by `ABORT`** — U-Boot +received the OFFER with no bootfile, fell back to requesting a file +named after its own IP in hex (`C0A8012C` = `192.168.1.44`). Fix: set +option 67 (`bootfile`) on the DHCP server to `default`. + +**`pxelinux.cfg/pxelinux.cfg/…` in the tftpd-hpa log** — the DHCP +bootfile is `pxelinux.cfg/default` (or anything with a slash). U-Boot +extracts the directory and re-prefixes `pxelinux.cfg/` itself. Set +bootfile to the bare filename `default`. + +**`VFS: Unable to mount root fs via NFS`** — several causes, check in +order: + +- Kernel was built without `CONFIG_ROOT_NFS`/`CONFIG_IP_PNP_DHCP`. + The extension's `custom_kernel_config__netboot_enable_nfs_root` + hook turns these on; make sure you didn't override it. `zcat + /proc/config.gz | grep -E 'ROOT_NFS|IP_PNP'` on a known-good + image. +- `/etc/exports` path mismatch vs what's in `nfsroot=`. `showmount + -e ` and compare byte-for-byte. +- Server firewall is blocking TCP/2049 (or the randomized mountd + port for NFSv3). Pin mountd and open the port. +- `nfsroot=` is using a hostname the client can't resolve yet (DNS + isn't up during early mount). Use an IP, not a hostname — the + extension does this by default when `NETBOOT_SERVER` is set. +- `nfsmount: need a path` retried forever — the image has + `nfsroot=auto` but `ROOTPATH` ended up empty. Either the router + doesn't advertise DHCP option 17 (`uci add_list dhcp.lan.dhcp_option='17,:'`), + or the build skipped the initramfs plumbing (e.g. an older `uInitrd` + on TFTP from before this extension landed). For a quick check, + unpack the deployed `uInitrd` and confirm `option root_path` is + in `/etc/dhcpcd.conf` and `71-netboot-rootpath` exists under + `/usr/libexec/dhcpcd-hooks/`. If you can't fix the router, set + `NETBOOT_SERVER=` at build time and rebuild — that path skips + option 17 entirely. + +**`MODULE FAILURE` from initramfs** — the kernel is loading an +initramfs and trying to run `/init` that doesn't understand NFS root. +Either drop the initrd from the PXE config (the extension copies +`uInitrd` only if one exists) or rebuild the initramfs with NFS +support (`update-initramfs -u` with `MODULES=most` in +`/etc/initramfs-tools/initramfs.conf`). + +**Board boots from SD/eMMC instead of netboot.** The default +`boot_targets` on most Armbian boards puts local storage first +(`mmc1 mmc0 scsi0 usb0 pxe dhcp`). `pxe`/`dhcp` only trigger when no +local bootflow is found. Either physically remove the local media or +re-order `boot_targets` in U-Boot env: + +```text +=> env set boot_targets "pxe dhcp mmc1 mmc0 scsi0 usb0" +=> env save +``` + +Note that `env save` is per-board and can be wiped by the next U-Boot +flash. + +**Wrong baud rate on serial console.** The extension intentionally +does **not** put `console=…` in the kernel APPEND line. Hardcoding a +baud (e.g. 115200) breaks boards like Helios64 that run at +`1500000`. The kernel resolves the console from DT +`/chosen/stdout-path`; `earlycon` is still passed so you see early +boot output. If you see *no* console output at all, check that the +board's U-Boot `bootargs` template isn't overriding APPEND. + +**`armbian-firstlogin` whiptail wizard still appears.** Two causes: +(1) stale image — built before you enabled the extension, so +`/root/.not_logged_in_yet` was never removed. Rebuild, or +`ssh root@ rm -f /root/.not_logged_in_yet` on the deployed +rootfs. (2) The trigger file is **non-empty** — the extension +intentionally keeps it so `preset-firstrun` can consume `PRESET_*` +values. Check with `stat -c %s /root/.not_logged_in_yet`: zero +bytes → stale image, non-zero → expected, presets will apply on +first boot and then the wizard stops triggering. From 44a88795ea115897a062752ac9b7dee0ef58ec51 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:02:26 +0300 Subject: [PATCH 07/16] fix(rootfs): preserve xattrs/ACLs/sparse + scope archive hook to nfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the rootfs cache and image pipelines, switch tar/rsync calls to: rsync -aHWh -XAXS --numeric-ids ... tar cp --xattrs --xattrs-include='security.*' --acls --selinux --sparse ... Without this, xattrs (notably `security.capability` for setcap'd binaries like /usr/bin/ping) and POSIX ACLs were stripped during the rootfs → tarball → image (or → NFS export) round-trip. The tar xattr include pattern defaults to `security.*` (file capabilities and SELinux contexts) — broader patterns also pick up source-fs internals (`bcachefs_*`, `btrfs.*`, `zfs.*` from the build host) that have no meaning on the target and produce extract-time errors. Tunable via: - `ROOTFS_TAR_XATTR_INCLUDE` (env, replaces default include pattern) - `ROOTFS_TAR_EXTRA_FLAGS` (bash array, appended to tar args) - `ROOTFS_RSYNC_XATTR_FLAGS` (env, replaces rsync xattr/ACL flags) - `pre_create_rootfs_archive` extension hook (set the above lazily, e.g. depending on ARCH/RELEASE) Drive-by: scope the `post_create_rootfs_archive` hook dispatch to `ROOTFS_TYPE=nfs|nfs-root` (the only types that produce an archive) and add `zst` to the `expected:` list in the unknown-compression error message. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- lib/functions/image/rootfs-to-image.sh | 52 +++++++++++++++++++------- lib/functions/rootfs/create-cache.sh | 4 +- lib/functions/rootfs/rootfs-create.sh | 3 +- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/lib/functions/image/rootfs-to-image.sh b/lib/functions/image/rootfs-to-image.sh index 533555f97451..5b88b425263e 100644 --- a/lib/functions/image/rootfs-to-image.sh +++ b/lib/functions/image/rootfs-to-image.sh @@ -35,12 +35,20 @@ function create_image_from_sdcard_rootfs() { declare calculated_image_version="undetermined" calculate_image_version declare -r -g version="${calculated_image_version}" # global readonly from here - declare rsync_ea=" -X " + # -A: POSIX ACLs. -X: xattrs (incl. security.*, e.g. file capabilities). + # -S: sparse files preserved as such on destination. --numeric-ids: skip + # user/group name lookups on receiver, so files written into a foreign + # rootfs keep their numeric uid/gid (the rootfs's own /etc/passwd is what + # matters at boot, not the host's). Without -A and proper -X handling, + # `setcap cap_net_raw+ep /usr/bin/ping` set by iputils-ping's postinst + # inside the build chroot is silently dropped between SDCARD and the + # packaged image. + declare rsync_ea="${ROOTFS_RSYNC_XATTR_FLAGS:- -AXS --numeric-ids }" declare exclude_home="--exclude=\"/home/*\"" # Some usecase requires home directory to be included if [[ ${INCLUDE_HOME_DIR:-no} == yes ]]; then exclude_home=""; fi # nilfs2 fs does not have extended attributes support, and have to be ignored on copy - if [[ $ROOTFS_TYPE == nilfs2 ]]; then rsync_ea=""; fi + if [[ $ROOTFS_TYPE == nilfs2 ]]; then rsync_ea=" --numeric-ids "; fi if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]]; then display_alert "Copying files via rsync to" "/ (MOUNT root)" run_host_command_logged rsync -aHWh $rsync_ea \ @@ -105,7 +113,7 @@ function create_image_from_sdcard_rootfs() { PRE_UMOUNT_FINAL_IMAGE if [[ $ROOTFS_TYPE == nfs || $ROOTFS_TYPE == nfs-root ]]; then - # ROOTFS_COMPRESSION: zstd (default, .tar.zst) | gzip (.tar.gz) | none (skip archive) + # ROOTFS_COMPRESSION: zstd/zst (default, .tar.zst) | gzip (.tar.gz) | none (skip archive) declare rootfs_compression="${ROOTFS_COMPRESSION:-zstd}" declare archive_ext="" archive_filter="" case "${rootfs_compression}" in @@ -118,7 +126,7 @@ function create_image_from_sdcard_rootfs() { archive_filter="zstd -T0 -c" ;; none) ;; - *) exit_with_error "Unknown ROOTFS_COMPRESSION: '${rootfs_compression}' (expected: gzip|zstd|none)" ;; + *) exit_with_error "Unknown ROOTFS_COMPRESSION: '${rootfs_compression}' (expected: gzip|zstd|zst|none)" ;; esac if [[ "${rootfs_compression}" == "none" && -z "${ROOTFS_EXPORT_DIR}" ]]; then exit_with_error "ROOTFS_COMPRESSION=none requires ROOTFS_EXPORT_DIR (otherwise nothing is produced)" @@ -138,10 +146,26 @@ function create_image_from_sdcard_rootfs() { # exit code of the final compressor stage hides truncation mid-archive). declare -a tar_excludes=(--exclude='./boot/*' --exclude='./dev/*' --exclude='./proc/*' --exclude='./run/*' --exclude='./tmp/*' --exclude='./sys/*') [[ "${INCLUDE_HOME_DIR:-no}" != "yes" ]] && tar_excludes+=(--exclude='./home/*') + # Default 'security.*' covers file capabilities (setcap, e.g. + # /usr/bin/ping) and SELinux contexts. Anything broader (e.g. '*') + # also captures source-fs internals like bcachefs_effective.* / + # btrfs.* / zfs.* from the build host that have no meaning on the + # target and produce noisy "Operation not supported" errors at + # extract time. Override via ROOTFS_TAR_XATTR_INCLUDE if needed. + declare -g ROOTFS_TAR_XATTR_INCLUDE="${ROOTFS_TAR_XATTR_INCLUDE:-security.*}" + declare -ga ROOTFS_TAR_EXTRA_FLAGS=("${ROOTFS_TAR_EXTRA_FLAGS[@]}") + call_extension_method "pre_create_rootfs_archive" <<- 'PRE_CREATE_ROOTFS_ARCHIVE' + *called before tar packs the rootfs into ${ROOTFS_ARCHIVE_PATH}* + Only fires for `ROOTFS_TYPE=nfs` and `ROOTFS_TYPE=nfs-root`. + Override `ROOTFS_TAR_XATTR_INCLUDE` (default `security.*`) or + append to `ROOTFS_TAR_EXTRA_FLAGS` to customise tar invocation + (e.g. add `--xattrs-include='user.foo.*'` for app-specific xattrs). + PRE_CREATE_ROOTFS_ARCHIVE rm -f "${rootfs_archive_tmp}" ( set -o pipefail - tar cp --xattrs --directory="$SDCARD/" "${tar_excludes[@]}" . | + tar cp --numeric-owner --xattrs --xattrs-include="${ROOTFS_TAR_XATTR_INCLUDE}" --acls --selinux --sparse "${ROOTFS_TAR_EXTRA_FLAGS[@]}" \ + --directory="$SDCARD/" "${tar_excludes[@]}" . | pv -p -b -r -s "$(du -sb "$SDCARD"/ | cut -f1)" \ -N "$(logging_echo_prefix_for_pv "create_rootfs_archive") rootfs.${archive_ext}" | ${archive_filter} > "${rootfs_archive_tmp}" @@ -179,15 +203,17 @@ function create_image_from_sdcard_rootfs() { $exclude_home \ --info=progress0,stats1 "$SDCARD/" "${ROOTFS_EXPORT_DIR}/" fi - fi - call_extension_method "post_create_rootfs_archive" <<- 'POST_CREATE_ROOTFS_ARCHIVE' - *called after the rootfs archive / export tree is produced* - Runs after pre_umount_final_image and after the archive step, so any - path the archive step sets (e.g. ROOTFS_ARCHIVE_PATH) is populated. - Use this instead of pre_umount_final_image when you need the final - archive path — deploy hooks that ship the archive belong here. - POST_CREATE_ROOTFS_ARCHIVE + call_extension_method "post_create_rootfs_archive" <<- 'POST_CREATE_ROOTFS_ARCHIVE' + *called after the rootfs archive / export tree is produced* + Only fires for `ROOTFS_TYPE=nfs` and `ROOTFS_TYPE=nfs-root`, since + other rootfs types do not produce a standalone archive. Runs after + `pre_umount_final_image` and after the archive step, so any path + the archive step sets (e.g. `ROOTFS_ARCHIVE_PATH`) is populated. + Use this instead of `pre_umount_final_image` when you need the + final archive path — deploy hooks that ship the archive belong here. + POST_CREATE_ROOTFS_ARCHIVE + fi if [[ "${SHOW_DEBUG}" == "yes" ]]; then # Check the partition table after the uboot code has been written diff --git a/lib/functions/rootfs/create-cache.sh b/lib/functions/rootfs/create-cache.sh index 73bfb8ad7da8..fe6cd3db8f15 100644 --- a/lib/functions/rootfs/create-cache.sh +++ b/lib/functions/rootfs/create-cache.sh @@ -109,7 +109,9 @@ function extract_rootfs_artifact() { local date_diff=$((($(date +%s) - $(stat -c %Y "${cache_fname}")) / 86400)) display_alert "Extracting ${artifact_version}" "${date_diff} days old" "info" - pv -p -b -r -c -N "$(logging_echo_prefix_for_pv "extract_rootfs") ${artifact_version}" "${cache_fname}" | zstdmt -dc | tar xp --xattrs -C "${SDCARD}"/ + pv -p -b -r -c -N "$(logging_echo_prefix_for_pv "extract_rootfs") ${artifact_version}" "${cache_fname}" | + zstdmt -dc | + tar xp --numeric-owner --xattrs --xattrs-include="${ROOTFS_TAR_XATTR_INCLUDE:-security.*}" --acls --selinux --sparse -C "${SDCARD}"/ declare -a pv_tar_zstdmt_pipe_status=("${PIPESTATUS[@]}") # capture and the pipe_status array from PIPESTATUS declare one_pipe_status diff --git a/lib/functions/rootfs/rootfs-create.sh b/lib/functions/rootfs/rootfs-create.sh index 436633f508e6..74f351f4929e 100644 --- a/lib/functions/rootfs/rootfs-create.sh +++ b/lib/functions/rootfs/rootfs-create.sh @@ -23,7 +23,8 @@ function create_new_rootfs_cache_tarball() { declare compression_ratio_rootfs="${ROOTFS_COMPRESSION_RATIO:-"5"}" display_alert "zstd tarball of rootfs" "${RELEASE}:: ${cache_name} :: compression ${compression_ratio_rootfs}" "info" - tar cp --xattrs --directory="$SDCARD"/ --exclude='./dev/*' --exclude='./proc/*' --exclude='./run/*' \ + tar cp --numeric-owner --xattrs --xattrs-include="${ROOTFS_TAR_XATTR_INCLUDE:-security.*}" --acls --selinux --sparse \ + --directory="$SDCARD"/ --exclude='./dev/*' --exclude='./proc/*' --exclude='./run/*' \ --exclude='./tmp/*' --exclude='./sys/*' --exclude='./home/*' --exclude='./root/*' . | pv -p -b -r -s "$(du -sb "$SDCARD"/ | cut -f1)" -N "$(logging_echo_prefix_for_pv "store_rootfs") $cache_name" | zstdmt "-${compression_ratio_rootfs}" -c > "${cache_fname}" From d147be944d1f1f8849fcb6e0ed5c99acbafadb6d Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:34:52 +0300 Subject: [PATCH 08/16] feat(extensions/netboot): netboot-deploy.sh reference deploy hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference implementation of the `netboot_artifacts_ready` post-hook: rsyncs the TFTP tree and unpacks the rootfs archive into an NFS export on a remote server over SSH. - Setting `NETBOOT_DEPLOY_SSH=user@host` implies `ROOTFS_TYPE=nfs-root` so the operator does not have to repeat it on the command line. - An early probe hook (`extension_prepare_config__060_…`) tries `touch && rm` on the target TFTP root before the long build, so a misconfigured SSH key / sudo / known_hosts fails fast with a clear diagnostic instead of after `./compile.sh` has run for 40 minutes. - Host identity is explicit: `known_hosts` file by default (`NETBOOT_DEPLOY_SSH_KNOWN_HOSTS`), optional TOFU (`NETBOOT_DEPLOY_SSH_TOFU=yes`) for first-time deployments. - SSH key passed via `NETBOOT_DEPLOY_SSH_KEY`; `sudo` on the target is opt-in via `NETBOOT_DEPLOY_SUDO=yes`. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- extensions/netboot/README.md | 4 +- extensions/netboot/netboot-deploy.sh | 781 +++++++++++++++++++++++++++ 2 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 extensions/netboot/netboot-deploy.sh diff --git a/extensions/netboot/README.md b/extensions/netboot/README.md index 01c8361d71f0..6f92e5bd7212 100644 --- a/extensions/netboot/README.md +++ b/extensions/netboot/README.md @@ -883,7 +883,9 @@ order: on TFTP from before this extension landed). For a quick check, unpack the deployed `uInitrd` and confirm `option root_path` is in `/etc/dhcpcd.conf` and `71-netboot-rootpath` exists under - `/usr/libexec/dhcpcd-hooks/`. If you can't fix the router, set + `/usr/libexec/dhcpcd-hooks/` or `/usr/lib/dhcpcd/dhcpcd-hooks/` (the + initramfs hook copies into whichever the upstream dhcpcd hook used). + If you can't fix the router, set `NETBOOT_SERVER=` at build time and rebuild — that path skips option 17 entirely. diff --git a/extensions/netboot/netboot-deploy.sh b/extensions/netboot/netboot-deploy.sh new file mode 100644 index 000000000000..2c2b8f8f7dd7 --- /dev/null +++ b/extensions/netboot/netboot-deploy.sh @@ -0,0 +1,781 @@ +#!/usr/bin/env bash +# +# SPDX-License-Identifier: GPL-2.0 +# +# Reference implementation of two complementary deploy hooks: +# +# 1. `netboot_artifacts_ready` — fired by netboot.sh after a full image +# build, ships the staged TFTP tree and the rootfs archive. +# 2. `artifact_ready` (with WHAT=kernel) — fired by core after a stand- +# alone `compile.sh kernel ...` run, unpacks the linux-image .deb and +# ships vmlinuz/dtbs to TFTP plus /lib/modules// to the NFS rootfs +# so the next netboot grabs a coherent kernel+modules pair (avoids +# `BPF: Invalid name_offset` from split-BTF mismatches between vmlinux +# and out-of-sync .ko files). +# +# Not auto-loaded. Enable with ENABLE_EXTENSIONS=netboot-deploy — +# the netboot extension is pulled in automatically below. +# +# SSH authentication: this hook shells out to ssh/rsync with default +# OpenSSH behavior. Two ways to feed credentials into a Docker build: +# +# * Interactive — `DOCKER_PASS_SSH_AGENT=yes` forwards the host +# ssh-agent socket. The agent must be live at build time and the +# socket reachable by the container user. +# +# * Batch / CI — bind-mount a single private key into the container +# with NETBOOT_DEPLOY_SSH_KEY=. A host-side hook adds the +# matching `--mount` argument; inside the container the key is +# copied to a root-owned scratch path before use, since OpenSSH +# refuses identity files whose owner is neither root nor the +# current user (the host file stays owned by the invoking user). +# +# Variables: +# NETBOOT_DEPLOY_SSH SSH target, e.g. root@netboot.local +# NETBOOT_DEPLOY_TFTP_ROOT Absolute TFTP root on the server. +# Default: /srv/netboot/tftp +# NETBOOT_DEPLOY_TFTP_DELETE yes|no — rsync --delete on TFTP tree. +# Default: yes (TFTP mirrors the build exactly). +# Set to 'no' when the TFTP root is shared +# with other unrelated deployments. +# NETBOOT_DEPLOY_NFS_DELETE yes|no — wipe NETBOOT_NFS_PATH before tar- +# extracting the new rootfs. Default: no +# (preserve-on-top — see 'Rootfs handling' +# below). Set to 'yes' for CI / immutable +# workflows where the booted rootfs must +# match the build artifact bit-for-bit; +# clears any stale files left over from +# packages dropped or paths renamed +# between rebuilds. +# NETBOOT_DEPLOY_EXCLUDE_FILE Optional rsync --exclude-from file applied +# to the TFTP sync. For rootfs updates, use +# your own rsync step instead — see README. +# NETBOOT_DEPLOY_SUDO yes|no — run remote rsync/mkdir/tar under +# `sudo -n`. Default: no. Required when the +# SSH account is not root: NFS-rootfs writes +# (tar untar with --numeric-owner/xattrs and +# /lib/modules rsync) keep ownership intact +# only with effective root; without it the +# export gets rewritten under the login uid +# and the next netboot fails on permission +# mismatches. The hook probes the remote uid +# before each NFS-side operation and aborts +# with a precise error when uid != 0 and +# NETBOOT_DEPLOY_SUDO=no. Needs passwordless +# sudo for rsync, mkdir, and tar on the server. +# NETBOOT_DEPLOY_SSH_KEY Path to a private key file. The hook bind- +# mounts the file read-only at the same path +# inside the container, copies it to a root- +# owned scratch path on first use, and adds +# `-i ` to the ssh command line. +# Use for batch/CI runs without a live agent. +# NETBOOT_DEPLOY_SSH_KNOWN_HOSTS +# Path to a known_hosts file on the build host. +# Default: ${HOME}/.ssh/known_hosts if present +# (auto-pickup of an interactive workflow's +# known_hosts), otherwise unset. The hook +# bind-mounts the file into the container at +# /root/.ssh/known_hosts:ro so ssh inside docker +# inherits the operator's already-trusted hosts. +# For CI without a populated ${HOME}/.ssh, set +# this to a path with a pre-populated file (e.g. +# via `ssh-keyscan -H target` in a pipeline step +# or as a secret). Mutually exclusive with +# NETBOOT_DEPLOY_SSH_TOFU=yes. +# NETBOOT_DEPLOY_SSH_TOFU yes|no — defaults to "no". When "yes" the +# hook switches ssh to ephemeral TOFU mode: +# `-o UserKnownHostsFile=/dev/null +# -o StrictHostKeyChecking=accept-new`. +# Each connection learns the host key fresh +# and forgets it — no setup needed and no +# MITM protection. For home-lab / trusted- +# segment use only. Mutually exclusive with +# NETBOOT_DEPLOY_SSH_KNOWN_HOSTS / a +# pre-existing ${HOME}/.ssh/known_hosts. +# NETBOOT_DEPLOY_SSH_OPTS Extra ssh options applied to both ssh and +# rsync. Default: `-o BatchMode=yes`. Strict +# by design: BatchMode + the implicit +# StrictHostKeyChecking=ask refuses unknown or +# changed host keys. Use the dedicated +# NETBOOT_DEPLOY_SSH_KNOWN_HOSTS or +# NETBOOT_DEPLOY_SSH_TOFU for host-identity +# control; this variable stays for arbitrary +# other tweaks (timeouts, ProxyCommand, …). +# Caveat: the value is split on whitespace; +# option arguments containing spaces or +# quotes (e.g. ProxyCommand="ssh -W ..." +# or UserKnownHostsFile="/tmp/known hosts") +# will be mangled. For such options, place +# them in ~/.ssh/config (visible to ssh via +# DOCKER_PASS_SSH_AGENT or via your own +# bind-mount of a config file). +# +# Rootfs handling: +# If NETBOOT_ROOTFS_ARCHIVE is a file, it is uploaded and untarred into +# NETBOOT_NFS_PATH. With NETBOOT_DEPLOY_NFS_DELETE=no (default), removed +# files from earlier builds stay on disk and per-host state (ssh host +# keys, machine-id, /home) is preserved across redeploys. With +# NETBOOT_DEPLOY_NFS_DELETE=yes the target is wiped first, so the booted +# rootfs matches the produced image bit-for-bit at the cost of any +# on-target state. For per-host preservation under DELETE=yes, keep that +# state on a separate NFS mount layered on top of the rootfs export. +# +# When ROOTFS_COMPRESSION=none is used together with ROOTFS_EXPORT_DIR, +# there is no archive to ship — the builder has already written straight +# into the export. This hook then only deploys TFTP. +# + +enable_extension netboot + +# netboot-deploy implies ROOTFS_TYPE=nfs-root. Set it in host_pre_docker_launch +# so main-config.sh's case "$ROOTFS_TYPE" block (FIXED_IMAGE_SIZE=256 etc.) +# evaluates it correctly after relaunch. extension_prepare_config below is +# the fallback for the non-relaunch path (PREFER_DOCKER=no), where the case +# block has already run with the ext4 default; mirror its defaults manually. +function host_pre_docker_launch__050_netboot_deploy_imply_nfs_root() { + if [[ "${ROOTFS_TYPE:-}" != "nfs-root" ]]; then + display_alert "${EXTENSION}: implying ROOTFS_TYPE=nfs-root" \ + "was '${ROOTFS_TYPE:-unset}'; injecting into relaunch args" "info" + declare -g ROOTFS_TYPE="nfs-root" + ARMBIAN_CLI_FINAL_RELAUNCH_ARGS+=("ROOTFS_TYPE=nfs-root") + fi +} + +function extension_prepare_config__050_netboot_deploy_imply_nfs_root() { + if [[ "${ROOTFS_TYPE:-}" != "nfs-root" ]]; then + display_alert "${EXTENSION}: forcing ROOTFS_TYPE=nfs-root" \ + "was '${ROOTFS_TYPE:-unset}'; mirroring nfs-root case-block defaults" "info" + declare -g ROOTFS_TYPE="nfs-root" + declare -g FIXED_IMAGE_SIZE="${FIXED_IMAGE_SIZE:-256}" + fi +} + +# Probe the deploy target before docker launch — so missing SSH credentials, +# pasword-locked sudo, or a read-only/non-existent TFTP root surface in the +# first 5 seconds instead of after a 30-50 minute kernel build. The probe is +# `touch + rm` of a per-PID scratch path under NETBOOT_DEPLOY_TFTP_ROOT, which +# exercises the same auth chain (ssh + optional sudo + filesystem write) that +# the real deploy will need later. Set NETBOOT_DEPLOY_PROBE=no to skip — every +# safety check is occasionally the obstacle (jumphost weirdness, custom +# ProxyCommand, target that disallows file creation under TFTP root but +# allows rsync via a wrapper, …). Default: yes. +function extension_prepare_config__060_netboot_deploy_probe_target() { + [[ -n "${NETBOOT_DEPLOY_SSH:-}" ]] || return 0 + if [[ "${NETBOOT_DEPLOY_PROBE:-yes}" != "yes" ]]; then + display_alert "${EXTENSION}: skipping deploy probe" \ + "NETBOOT_DEPLOY_PROBE=${NETBOOT_DEPLOY_PROBE} (you'll only learn at deploy time if creds are wrong)" "warn" + return 0 + fi + declare tftp_root="${NETBOOT_DEPLOY_TFTP_ROOT:-/srv/netboot/tftp}" + # Mirror the actual deploy hook's SSH options. extension_prepare_config + # fires inside the container after docker relaunch, where ssh's default + # ~/.ssh/known_hosts already resolves to the file that + # host_pre_docker_launch__netboot_deploy_mount_known_hosts bind-mounts + # at /root/.ssh/known_hosts — so no explicit UserKnownHostsFile is + # needed for the non-TOFU case. TOFU still passes /dev/null + + # accept-new explicitly. -o ConnectTimeout=5 goes first (OpenSSH + # applies the first occurrence of an option) so the probe stays + # bounded even if the user's SSH_OPTS doesn't set ConnectTimeout. + declare probe_extra="" + if [[ "${NETBOOT_DEPLOY_SSH_TOFU:-no}" == "yes" ]]; then + probe_extra="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new" + fi + # shellcheck disable=SC2206 # word-split intentional, user gives a shell-style string + declare -a probe_cmd=(ssh -o ConnectTimeout=5 ${probe_extra} ${NETBOOT_DEPLOY_SSH_OPTS:--o BatchMode=yes}) + [[ -n "${NETBOOT_DEPLOY_SSH_KEY:-}" ]] && probe_cmd+=(-i "${NETBOOT_DEPLOY_SSH_KEY}") + declare sudo_prefix="" + [[ "${NETBOOT_DEPLOY_SUDO:-no}" == "yes" ]] && sudo_prefix="sudo -n " + # Quote remote path for any POSIX shell (same idiom as the deploy hook below). + declare q_probe="'${tftp_root//\'/\'\\\'\'}/.netboot-deploy-probe.${BASHPID}'" + display_alert "${EXTENSION}: probing deploy target" \ + "ssh ${NETBOOT_DEPLOY_SSH} (sudo=${NETBOOT_DEPLOY_SUDO:-no}) → touch+rm ${tftp_root}/.netboot-deploy-probe.*" "info" + # Capture stderr to a tempfile so a failed probe surfaces the real ssh + # diagnostic ('Permission denied (publickey)', 'Host key verification + # failed', 'Connection refused', 'sudo: a terminal is required', …) + # instead of just a generic «check all of these things». + declare probe_stderr_file + probe_stderr_file=$(mktemp -t netboot-deploy-probe-stderr.XXXXXX) + if ! "${probe_cmd[@]}" "${NETBOOT_DEPLOY_SSH}" \ + "${sudo_prefix}touch ${q_probe} && ${sudo_prefix}rm -f ${q_probe}" 2> "${probe_stderr_file}"; then + declare probe_stderr + probe_stderr=$(head -c 4096 "${probe_stderr_file}" | sed 's/[[:space:]]*$//') + rm -f "${probe_stderr_file}" + exit_with_error "${EXTENSION}: deploy probe failed" \ + "ssh '${NETBOOT_DEPLOY_SSH}' cannot create+remove a file under '${tftp_root}'. ssh stderr: ${probe_stderr:-}. Check NETBOOT_DEPLOY_SSH_KEY, sudo NOPASSWD, target dir existence/permissions, host-key trust (NETBOOT_DEPLOY_SSH_KNOWN_HOSTS or NETBOOT_DEPLOY_SSH_TOFU). Bypass with NETBOOT_DEPLOY_PROBE=no." + fi + rm -f "${probe_stderr_file}" +} + +# Host-side: bind-mount the chosen private key into the build container +# before Docker starts. The file is mounted read-only at the same path +# the host uses, so a deploy hook running as root inside the container +# can read it (CAP_DAC_OVERRIDE) and copy it to a root-owned scratch +# path before handing it to ssh. +function host_pre_docker_launch__netboot_deploy_mount_ssh() { + [[ -n "${NETBOOT_DEPLOY_SSH_KEY:-}" ]] || return 0 + declare host_key + host_key="$(realpath "${NETBOOT_DEPLOY_SSH_KEY}" 2> /dev/null || true)" + if [[ -z "${host_key}" || ! -f "${host_key}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_KEY not found or not a file" \ + "${NETBOOT_DEPLOY_SSH_KEY}" + fi + # `--mount` CSV has no escape syntax — reject paths with commas. + if [[ "${host_key}" == *,* ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_KEY path must not contain a comma" \ + "${host_key}" + fi + # Normalize the variable to the resolved path so the in-container deploy + # hook checks the same file we bind-mounted (a symlinked input would + # otherwise be reported as missing inside the container). + declare -g NETBOOT_DEPLOY_SSH_KEY="${host_key}" + display_alert "${EXTENSION}: mount SSH key" "${host_key}" "info" + DOCKER_EXTRA_ARGS+=("--mount" "type=bind,source=${host_key},target=${host_key},readonly") +} + +# Host-side: bind-mount a known_hosts file into the container so ssh inside +# docker has a host-identity store. Three scenarios — see the variable docs at +# the top of this file: +# - NETBOOT_DEPLOY_SSH_KNOWN_HOSTS=: explicit (CI / unusual location) +# - default + ${HOME}/.ssh/known_hosts on host: auto-pickup (interactive) +# - NETBOOT_DEPLOY_SSH_TOFU=yes: ephemeral, no mount needed (handled at +# ssh invocation time via UserKnownHostsFile=/dev/null) +# KNOWN_HOSTS and TOFU=yes are mutually exclusive — refuse to ambiguate. +function host_pre_docker_launch__netboot_deploy_mount_known_hosts() { + [[ -n "${NETBOOT_DEPLOY_SSH:-}" ]] || return 0 + declare tofu="${NETBOOT_DEPLOY_SSH_TOFU:-no}" + declare khosts="${NETBOOT_DEPLOY_SSH_KNOWN_HOSTS:-}" + # Auto-pickup only when neither is explicitly set. + if [[ -z "${khosts}" && "${tofu}" != "yes" && -f "${HOME}/.ssh/known_hosts" ]]; then + khosts="${HOME}/.ssh/known_hosts" + fi + if [[ "${tofu}" == "yes" && -n "${khosts}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_TOFU=yes is incompatible with a known_hosts source" \ + "pick one: TOFU (ephemeral, /dev/null + accept-new) OR KNOWN_HOSTS (strict, file-based identity). \$HOME/.ssh/known_hosts auto-pickup also counts; unset NETBOOT_DEPLOY_SSH_KNOWN_HOSTS or move/rename your host known_hosts to disambiguate." + fi + [[ -n "${khosts}" ]] || return 0 + declare host_kh + host_kh="$(realpath "${khosts}" 2> /dev/null || true)" + if [[ -z "${host_kh}" || ! -f "${host_kh}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_KNOWN_HOSTS not found or not a file" \ + "${khosts}" + fi + if [[ "${host_kh}" == *,* ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_KNOWN_HOSTS path must not contain a comma" \ + "${host_kh}" + fi + declare -g NETBOOT_DEPLOY_SSH_KNOWN_HOSTS="${host_kh}" + display_alert "${EXTENSION}: mount known_hosts" "${host_kh}" "info" + DOCKER_EXTRA_ARGS+=("--mount" "type=bind,source=${host_kh},target=/root/.ssh/known_hosts,readonly") +} + +# NFS-side writes (tar untar with --numeric-owner/xattrs, /lib/modules rsync) +# preserve ownership only when the remote shell runs as uid 0. With +# NETBOOT_DEPLOY_SUDO=no and a non-root login, the write succeeds but the +# export gets rewritten under the login uid — the next netboot panics on +# broken permissions. Probe `id -u` over the same SSH path the deploy will +# use and fail fast when uid != 0 and sudo is off; sudo -n is the documented +# elevation escape hatch and skips the probe. +# +# Reads ${ssh_opts[@]}, ${NETBOOT_DEPLOY_SSH}, ${NETBOOT_DEPLOY_SUDO} from +# the calling hook's local scope (bash dynamic scoping). Kept as a top-level +# helper so both deploy hooks share one implementation. +function _netboot_deploy_require_remote_root() { + declare context="$1" + [[ "${NETBOOT_DEPLOY_SUDO}" == "yes" ]] && return 0 + declare remote_uid="" uid_stderr_file + uid_stderr_file=$(mktemp -t netboot-deploy-uid-stderr.XXXXXX) + # Capture stdout (the uid) and stderr (ssh banners like + # "Warning: Permanently added 'host' (ED25519) ..." under + # StrictHostKeyChecking=accept-new) separately, so the literal + # uid comparison below is not poisoned by ssh diagnostic lines. + if ! remote_uid=$(ssh "${ssh_opts[@]}" "${NETBOOT_DEPLOY_SSH}" 'id -u' 2> "${uid_stderr_file}"); then + declare uid_stderr + uid_stderr=$(head -c 4096 "${uid_stderr_file}" | sed 's/[[:space:]]*$//') + rm -f "${uid_stderr_file}" + exit_with_error "${EXTENSION}: cannot probe remote uid before ${context}" \ + "ssh '${NETBOOT_DEPLOY_SSH}' 'id -u' failed: ${uid_stderr:-}" + fi + rm -f "${uid_stderr_file}" + if [[ "${remote_uid}" != "0" ]]; then + exit_with_error "${EXTENSION}: ${context} requires effective root on '${NETBOOT_DEPLOY_SSH}'" \ + "remote uid=${remote_uid}, NETBOOT_DEPLOY_SUDO=no. Set NETBOOT_DEPLOY_SUDO=yes (with passwordless sudo for tar/rsync/mkdir on the server) or log in as root; otherwise tar --numeric-owner / xattrs / NFS-rootfs writes rewrite ownership under uid=${remote_uid} and break the next boot." + fi +} + +function netboot_artifacts_ready__deploy_to_remote_server() ( + # Run the body in a subshell so an EXIT trap can clean up scratch_key + # on every failure path (set -e abort, exit_with_error, …) without + # stomping armbian's outer RETURN trace handler. `()` makes the function + # body itself a subshell — no extra indent or refactor needed. + declare scratch_key="" + trap '[[ -n "${scratch_key:-}" && -f "${scratch_key}" ]] && rm -f "${scratch_key}"' EXIT + + declare NETBOOT_DEPLOY_SSH="${NETBOOT_DEPLOY_SSH:-}" + declare NETBOOT_DEPLOY_TFTP_ROOT="${NETBOOT_DEPLOY_TFTP_ROOT:-/srv/netboot/tftp}" + declare NETBOOT_DEPLOY_TFTP_DELETE="${NETBOOT_DEPLOY_TFTP_DELETE:-yes}" + declare NETBOOT_DEPLOY_NFS_DELETE="${NETBOOT_DEPLOY_NFS_DELETE:-no}" + declare NETBOOT_DEPLOY_EXCLUDE_FILE="${NETBOOT_DEPLOY_EXCLUDE_FILE:-}" + declare NETBOOT_DEPLOY_SUDO="${NETBOOT_DEPLOY_SUDO:-no}" + declare NETBOOT_DEPLOY_SSH_KEY="${NETBOOT_DEPLOY_SSH_KEY:-}" + declare NETBOOT_DEPLOY_SSH_TOFU="${NETBOOT_DEPLOY_SSH_TOFU:-no}" + # Strict-by-default: BatchMode=yes + implicit StrictHostKeyChecking=ask + # fails on unknown/changed host keys instead of silently trusting. Identity + # is supplied via either NETBOOT_DEPLOY_SSH_KNOWN_HOSTS (bind-mounted in + # the host hook above) or NETBOOT_DEPLOY_SSH_TOFU=yes (ephemeral, below). + declare NETBOOT_DEPLOY_SSH_OPTS="${NETBOOT_DEPLOY_SSH_OPTS:--o BatchMode=yes}" + # TOFU mode: forget the host key after each connection. /dev/null is a + # standard idiom — ssh "writes" the new key there, accept-new lets the + # session proceed, no persistence, no MITM detection. Mutually exclusive + # with KNOWN_HOSTS bind-mount (enforced in the host hook). + if [[ "${NETBOOT_DEPLOY_SSH_TOFU}" == "yes" ]]; then + NETBOOT_DEPLOY_SSH_OPTS+=" -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new" + fi + + if [[ -z "${NETBOOT_DEPLOY_SSH}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH is required" + fi + + # Fail-fast on a broken partial-deploy state: if NETBOOT_ROOTFS_ARCHIVE + # is set but the file does not exist, something went wrong upstream + # (compression step crashed, path miscomputed, file deleted between + # build and deploy). Letting the deploy proceed would publish the new + # TFTP payload (kernel + dtb + uInitrd) without refreshing the NFS + # rootfs — the next boot mounts a stale rootfs against a fresh kernel + # and panics on missing modules / mismatched libc. Catch it before + # any remote I/O so the server is never left in this inconsistent + # half-state. An empty NETBOOT_ROOTFS_ARCHIVE is a legitimate + # "ROOTFS_EXPORT_DIR-only" case and falls through to the empty-archive + # branch later. + if [[ -n "${NETBOOT_ROOTFS_ARCHIVE:-}" && ! -f "${NETBOOT_ROOTFS_ARCHIVE}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_ROOTFS_ARCHIVE is set but file missing" \ + "${NETBOOT_ROOTFS_ARCHIVE}" + fi + + # If a private key file was bind-mounted by the host hook, copy it to + # a scratch path with root ownership — OpenSSH refuses identity files + # whose owner is neither root nor the current user. Fail fast if the + # user requested a key but the host hook did not (or could not) make + # it visible inside the container; silently falling back to agent or + # default identities would mask a misconfiguration. + if [[ -n "${NETBOOT_DEPLOY_SSH_KEY}" ]]; then + if [[ ! -f "${NETBOOT_DEPLOY_SSH_KEY}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_KEY set but not visible in container" \ + "${NETBOOT_DEPLOY_SSH_KEY}" + fi + scratch_key="$(mktemp -p /tmp "netboot-deploy-key.XXXXXX")" + cp "${NETBOOT_DEPLOY_SSH_KEY}" "${scratch_key}" + chmod 600 "${scratch_key}" + NETBOOT_DEPLOY_SSH_OPTS="-i ${scratch_key} ${NETBOOT_DEPLOY_SSH_OPTS}" + fi + + # Validate exclude file inside the container — host paths reach the + # build only if explicitly bind-mounted, which this hook does not do. + if [[ -n "${NETBOOT_DEPLOY_EXCLUDE_FILE}" && ! -f "${NETBOOT_DEPLOY_EXCLUDE_FILE}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_EXCLUDE_FILE not visible in container" \ + "${NETBOOT_DEPLOY_EXCLUDE_FILE}" + fi + + declare sudo_prefix="" + [[ "${NETBOOT_DEPLOY_SUDO}" == "yes" ]] && sudo_prefix="sudo -n " + + # Word-split SSH_OPTS intentionally: user provides a shell-style string. + # shellcheck disable=SC2206 + declare -a ssh_opts=(${NETBOOT_DEPLOY_SSH_OPTS}) + + # `--mkpath` (rsync 3.2.3+) creates missing parent directories on the + # receiver — needed for a fresh TFTP root or a new board family. + # pxelinux.cfg is admin-managed: the build only stages files it + # generated (01- if NETBOOT_CLIENT_MAC, -- + # .example unconditionally), so it rsync's without --delete to keep + # operator-owned entries. + declare -a rsync_base=(-av --mkpath -e "ssh ${NETBOOT_DEPLOY_SSH_OPTS}") + [[ -n "${NETBOOT_DEPLOY_EXCLUDE_FILE}" ]] && rsync_base+=(--exclude-from="${NETBOOT_DEPLOY_EXCLUDE_FILE}") + [[ "${NETBOOT_DEPLOY_SUDO}" == "yes" ]] && rsync_base+=(--rsync-path="sudo -n rsync") + + declare -a rsync_payload=("${rsync_base[@]}") + if [[ "${NETBOOT_DEPLOY_TFTP_DELETE}" == "yes" ]]; then + if [[ -n "${NETBOOT_TFTP_PREFIX}" ]]; then + rsync_payload+=(--delete) + else + display_alert "${EXTENSION}: skip rsync --delete" \ + "NETBOOT_TFTP_PREFIX is empty; --delete on the TFTP root would clobber other boards" "warn" + fi + fi + + # Publish order — TFTP/NFS coherence (codex P2): + # 1. NFS rootfs untar first — tar/disk/xattr failures abort before + # any boot artifact reaches the server, so the board never finds + # a fresh kernel paired with a stale rootfs. + # 2. TFTP payload via per-PID staging dir + atomic mv-swap, so a + # mid-rsync interruption or partial leftovers never appear under + # the live ${PREFIX}. + # 3. pxelinux.cfg — references the just-swapped ${PREFIX} paths. + + # Stage 1 — NFS rootfs. The "set but missing" case was already + # rejected by the fail-fast check above; reaching here with a + # non-empty value implies the file exists. + if [[ -n "${NETBOOT_ROOTFS_ARCHIVE}" ]]; then + declare archive_name="${NETBOOT_ROOTFS_ARCHIVE##*/}" + # Suffix the scratch path with this shell's BASHPID + a random token so + # parallel deploys of the same artifact to the same server cannot wipe + # each other's upload between rsync and tar. Plain ${archive_name} is a + # fixed path — A's `rm` after unpack would delete B's still-uploading + # file. We deliberately *don't* call `mktemp` over SSH: that would add a + # round-trip and a stray file if we crash between mktemp and the rsync + # that overwrites it. BASHPID+RANDOM is collision-free in practice and + # costs zero remote calls. + declare remote_archive="/tmp/${archive_name}.${BASHPID}.${RANDOM}" + + display_alert "${EXTENSION}: upload rootfs archive" \ + "${archive_name} -> ${NETBOOT_DEPLOY_SSH}:${remote_archive}" "info" + run_host_command_logged_raw rsync -av -e "ssh ${NETBOOT_DEPLOY_SSH_OPTS}" \ + "${NETBOOT_ROOTFS_ARCHIVE}" \ + "${NETBOOT_DEPLOY_SSH}:${remote_archive}" + + _netboot_deploy_require_remote_root "rootfs archive untar into ${NETBOOT_NFS_PATH}" + display_alert "${EXTENSION}: unpack into NFS export" \ + "${NETBOOT_NFS_PATH}" "info" + # Quote remote-side values for any POSIX shell: wrap in single + # quotes and replace each embedded ' with '\''. Avoids the bash- + # only $'…' form that printf '%q' may emit for control chars, + # so the remote sh (dash/ash/bash) parses paths reliably. + declare q_nfs_path q_remote_archive + q_nfs_path="'${NETBOOT_NFS_PATH//\'/\'\\\'\'}'" + q_remote_archive="'${remote_archive//\'/\'\\\'\'}'" + # Opt-in pre-wipe: when DELETE=yes, drop every entry under + # ${NFS_PATH} before unpack so files that disappeared from the + # rebuild (purged packages, renamed configs, stale modules) + # don't survive into the next boot. `find -mindepth 1 -delete` + # preserves the directory itself (its inode/permissions stay + # under whoever owns the NFS export), and is silent on an + # empty/nonexistent path. + if [[ "${NETBOOT_DEPLOY_NFS_DELETE}" == "yes" ]]; then + display_alert "${EXTENSION}: clear NFS export before unpack" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_NFS_PATH}/" "info" + run_host_command_logged_raw ssh "${ssh_opts[@]}" "${NETBOOT_DEPLOY_SSH}" \ + "test -d ${q_nfs_path} && ${sudo_prefix}find ${q_nfs_path} -mindepth 1 -delete ; true" + fi + # tar -p alone restores mode bits but ignores xattrs/ACLs even if the + # archive contains them — extract has its own gating switches. We need + # --xattrs --xattrs-include='*' to restore security.* (e.g. file caps + # placed by iputils-ping's postinst), --acls for POSIX ACLs, --selinux + # for SELinux contexts, and --numeric-owner so user/group resolution + # uses the archive's numeric IDs (the target's /etc/passwd is what + # matters at boot, not the deploy host's). + # Cleanup of the staged archive must run regardless of mkdir/tar + # outcome — without that, a `set -e` shell on the remote exits on + # tar failure and leaves the 200–800 MB rootfs archive under /tmp. + # `&&` keeps mkdir→tar short-circuit; capture the rc, run rm + # unconditionally, propagate the original rc so Armbian's set -e + # still aborts on the real failure. + run_host_command_logged_raw ssh "${ssh_opts[@]}" "${NETBOOT_DEPLOY_SSH}" \ + "${sudo_prefix}mkdir -p ${q_nfs_path} \ + && ${sudo_prefix}tar -xp --numeric-owner --xattrs --xattrs-include='*' --acls --selinux -f ${q_remote_archive} -C ${q_nfs_path}; \ + ret=\$?; rm -f ${q_remote_archive}; exit \${ret}" + else + display_alert "${EXTENSION}: no archive" \ + "ROOTFS_EXPORT_DIR path — builder writes directly, skip" "info" + fi + + # Stage 2 — TFTP payload via staging + atomic mv-swap. Empty + # ${PREFIX} falls back to direct rsync (same edge case already noted + # for --delete: sibling-board clobber risk). Staging is pre-populated + # with a cp -al hard-linked copy of current production so + # NETBOOT_DEPLOY_TFTP_DELETE=no merge semantics survive — pre-existing + # admin-placed artifacts are seeded into staging, and the staging- + # side rsync's --delete (when DELETE=yes) prunes them only when the + # user opted in. + if [[ -n "${NETBOOT_TFTP_PREFIX}" ]]; then + declare tftp_prefix_path="${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}" + declare staging_path="${tftp_prefix_path}.staging.${BASHPID}.${RANDOM}" + declare prev_path="${tftp_prefix_path}.prev.${BASHPID}.${RANDOM}" + declare q_tftp_prefix q_staging_path q_prev_path + q_tftp_prefix="'${tftp_prefix_path//\'/\'\\\'\'}'" + q_staging_path="'${staging_path//\'/\'\\\'\'}'" + q_prev_path="'${prev_path//\'/\'\\\'\'}'" + + run_host_command_logged_raw ssh "${ssh_opts[@]}" "${NETBOOT_DEPLOY_SSH}" \ + "if ${sudo_prefix}test -d ${q_tftp_prefix}; then ${sudo_prefix}cp -al ${q_tftp_prefix} ${q_staging_path}; fi" + + display_alert "${EXTENSION}: rsync TFTP payload (staged)" \ + "${NETBOOT_TFTP_OUT}/${NETBOOT_TFTP_PREFIX}/ -> ${NETBOOT_DEPLOY_SSH}:${staging_path}/" "info" + run_host_command_logged_raw rsync "${rsync_payload[@]}" \ + "${NETBOOT_TFTP_OUT}/${NETBOOT_TFTP_PREFIX}/" \ + "${NETBOOT_DEPLOY_SSH}:${staging_path}/" + + # Atomic mv-swap with rollback. If the final mv fails after we + # moved production aside, restore the original so the live + # ${PREFIX} is never absent. Same-filesystem rename is sub- + # second; PXE clients hitting the gap get a no-file (boot retry), + # not a half-updated payload. + display_alert "${EXTENSION}: swap staging into production" \ + "${tftp_prefix_path}" "info" + run_host_command_logged_raw ssh "${ssh_opts[@]}" "${NETBOOT_DEPLOY_SSH}" \ + "if ${sudo_prefix}test -d ${q_tftp_prefix}; then \ + ${sudo_prefix}mv ${q_tftp_prefix} ${q_prev_path} && \ + { ${sudo_prefix}mv ${q_staging_path} ${q_tftp_prefix} || \ + { ${sudo_prefix}mv ${q_prev_path} ${q_tftp_prefix}; exit 1; }; } && \ + ${sudo_prefix}rm -rf ${q_prev_path}; \ + else \ + ${sudo_prefix}mv ${q_staging_path} ${q_tftp_prefix}; \ + fi" + else + display_alert "${EXTENSION}: rsync TFTP payload" \ + "${NETBOOT_TFTP_OUT}/${NETBOOT_TFTP_PREFIX}/ -> ${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/" "info" + run_host_command_logged_raw rsync "${rsync_payload[@]}" \ + "${NETBOOT_TFTP_OUT}/${NETBOOT_TFTP_PREFIX}/" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/" + fi + + # Stage 3 — pxelinux.cfg. + if [[ -d "${NETBOOT_TFTP_OUT}/pxelinux.cfg" ]]; then + display_alert "${EXTENSION}: rsync pxelinux.cfg" \ + "${NETBOOT_TFTP_OUT}/pxelinux.cfg/ -> ${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/pxelinux.cfg/" "info" + run_host_command_logged_raw rsync "${rsync_base[@]}" \ + "${NETBOOT_TFTP_OUT}/pxelinux.cfg/" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/pxelinux.cfg/" + fi + + display_alert "${EXTENSION}: done" "${NETBOOT_DEPLOY_SSH}" "info" +) + +# Standalone-kernel deploy: fired by core's `artifact_ready` after a +# `compile.sh kernel ...` run. No image, no rootfs archive — the canonical +# linux-image-${BRANCH}-${LINUXFAMILY} .deb is unpacked locally; vmlinuz + +# dtbs go to TFTP, /lib/modules// goes to the NFS rootfs. +# +# Scope: kernel + modules only. Initramfs is intentionally NOT regenerated +# here — it depends on the rootfs that was customized at full image build +# time (customize_image hooks, /etc/initramfs-tools tweaks, board-specific +# extensions, userpatches overlay), and that context does not survive on +# the build host past the original image deploy. The configured rootfs now +# lives only on the NFS server, which may be an OpenWRT box or anything +# else that cannot run chroot+update-initramfs. Regenerating from the +# generic post-debootstrap rootfs cache would produce a *different* +# initramfs than the one the full image build would have made — fake. +# +# Therefore: any pre-existing uInitrd on TFTP is removed so U-Boot does +# not load an initramfs whose modules have stale vermagic against the +# new kernel. Boards with built-in boot networking (mvneta, etc.) come +# up cleanly without initramfs via root=/dev/nfs ip=dhcp; boards that +# need modular drivers in initramfs (USB-eth, modular NICs) will fail +# fast at networking — the fix for those is a full image rebuild, not +# a kernel-only refresh. +# +# Closes the split-BTF coherence gap: a fresh kernel was getting paired +# with stale modules in the on-NFS rootfs, producing `BPF: Invalid +# name_offset` spam until a full image rebuild. +function artifact_ready__netboot_kernel_deploy() ( + declare scratch_key="" + declare scratch_dir="" + trap ' + [[ -n "${scratch_key:-}" && -f "${scratch_key}" ]] && rm -f "${scratch_key}" + [[ -n "${scratch_dir:-}" && -d "${scratch_dir}" ]] && rm -rf "${scratch_dir}" + ' EXIT + + # Fire only on standalone `compile.sh kernel ...` runs. A full image build + # (`compile.sh build ...`) also obtains a kernel artifact internally and + # triggers `artifact_ready` with WHAT=kernel — letting this hook proceed + # there would rsync /lib/modules// into the NFS export ahead of the + # full-image rootfs deploy, which then fails when tar tries to restore the + # `lib -> usr/lib` usrmerge symlink against the now-existing `lib/` + # directory. The full image flow already deploys kernel + modules via the + # rootfs archive in `netboot_artifacts_ready__deploy_to_remote_server`; + # this kernel-only handler is the optimization for the no-rebuild path. + [[ "${ARMBIAN_COMMAND:-}" == "kernel" ]] || return 0 + # Filter out kernel-config/kernel-patch interactive sessions and kernel-dtb + # where modules are not produced; require an SSH target. + [[ "${WHAT:-}" == "kernel" ]] || return 0 + [[ -n "${NETBOOT_DEPLOY_SSH:-}" ]] || return 0 + + # Same defaults and SSH-options shaping as the full-deploy hook above — + # kept inline rather than refactored into a helper to keep each hook self- + # contained for readers who jump in from a stack trace. + declare NETBOOT_DEPLOY_TFTP_ROOT="${NETBOOT_DEPLOY_TFTP_ROOT:-/srv/netboot/tftp}" + declare NETBOOT_DEPLOY_SUDO="${NETBOOT_DEPLOY_SUDO:-no}" + declare NETBOOT_DEPLOY_SSH_KEY="${NETBOOT_DEPLOY_SSH_KEY:-}" + declare NETBOOT_DEPLOY_SSH_TOFU="${NETBOOT_DEPLOY_SSH_TOFU:-no}" + declare NETBOOT_DEPLOY_SSH_OPTS="${NETBOOT_DEPLOY_SSH_OPTS:--o BatchMode=yes}" + if [[ "${NETBOOT_DEPLOY_SSH_TOFU}" == "yes" ]]; then + NETBOOT_DEPLOY_SSH_OPTS+=" -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new" + fi + + if [[ -n "${NETBOOT_DEPLOY_SSH_KEY}" ]]; then + if [[ ! -f "${NETBOOT_DEPLOY_SSH_KEY}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_KEY set but not visible in container" \ + "${NETBOOT_DEPLOY_SSH_KEY}" + fi + mkdir -p "${SRC}/.tmp" + scratch_key="$(mktemp -p "${SRC}/.tmp" "netboot-kdeploy-key.XXXXXX")" + cp "${NETBOOT_DEPLOY_SSH_KEY}" "${scratch_key}" + chmod 600 "${scratch_key}" + NETBOOT_DEPLOY_SSH_OPTS="-i ${scratch_key} ${NETBOOT_DEPLOY_SSH_OPTS}" + fi + + declare sudo_prefix="" + [[ "${NETBOOT_DEPLOY_SUDO}" == "yes" ]] && sudo_prefix="sudo -n " + # shellcheck disable=SC2206 + declare -a ssh_opts=(${NETBOOT_DEPLOY_SSH_OPTS}) + + # Defaults are computed lazily by netboot.sh — calling here, at hook time, + # guarantees `${LINUXFAMILY}` is populated before the path is materialized + # (the same helper is what the full-image deploy hooks call). + _netboot_compute_runtime_defaults + [[ -n "${NETBOOT_TFTP_PREFIX:-}" && -n "${NETBOOT_NFS_PATH:-}" ]] || + exit_with_error "${EXTENSION}: NETBOOT_TFTP_PREFIX / NETBOOT_NFS_PATH unset after compute" \ + "netboot extension not loaded? check ENABLE_EXTENSIONS contains 'netboot'" + + # Pick the reversioned linux-image .deb. Source of truth is core's + # `artifact_map_debs_reversioned[linux-image]`, populated by + # `artifact_reversion_for_deployment` immediately before + # `artifact_ready` fires (see lib/functions/artifacts/artifacts-obtain.sh + # around line 313 and 320). Using the map avoids a filesystem glob, + # which can match leftover .debs from prior builds since `${DEB_STORAGE}` + # is not cleaned between runs and would silently pick the alphabetically- + # first stale match. Quoted index ("linux-image") prevents shfmt from + # misparsing the hyphen as a minus operator and inserting spaces. + declare linux_image_basename="${artifact_map_debs_reversioned["linux-image"]:-}" + if [[ -z "${linux_image_basename}" ]]; then + exit_with_error "${EXTENSION}: linux-image not in artifact_map_debs_reversioned" \ + "available keys: ${!artifact_map_debs_reversioned[*]}" + fi + declare linux_image_deb="${DEB_STORAGE}/${linux_image_basename}" + if [[ ! -f "${linux_image_deb}" ]]; then + exit_with_error "${EXTENSION}: linux-image .deb missing on disk after reversion" \ + "${linux_image_deb}" + fi + + # A linux-image .deb is ~50-100 MB extracted; /tmp on Armbian build hosts + # is typically tmpfs (RAM-bound) and shared with the kernel build itself. + # Stay under the project tree — same FS as cache/, generous, predictable. + mkdir -p "${SRC}/.tmp" + scratch_dir="$(mktemp -d -p "${SRC}/.tmp" "netboot-kdeploy.XXXXXX")" + display_alert "${EXTENSION}: unpack kernel deb" \ + "${linux_image_deb##*/} -> ${scratch_dir}" "info" + run_host_command_logged_raw dpkg-deb -x "${linux_image_deb}" "${scratch_dir}" + + # Single /lib/modules// tree per linux-image deb. Resolve once. + declare kver="" + for d in "${scratch_dir}"/lib/modules/*/; do + [[ -d "${d}" ]] || continue + kver="${d%/}" + kver="${kver##*/}" + break + done + if [[ -z "${kver}" ]]; then + exit_with_error "${EXTENSION}: no kernel version dir under ${scratch_dir}/lib/modules" + fi + + declare -a rsync_base=(-av --mkpath -e "ssh ${NETBOOT_DEPLOY_SSH_OPTS}") + [[ "${NETBOOT_DEPLOY_SUDO}" == "yes" ]] && rsync_base+=(--rsync-path="sudo -n rsync") + + # Kernel binary: a raw `linux-image-*.deb` from `make bindeb-pkg` carries + # `/boot/vmlinuz-`. The on-target TFTP layout that netboot.sh's own + # `pre_umount_final_image__900_collect_netboot_artifacts` produces, on the + # other hand, places the binary directly at `${NETBOOT_TFTP_PREFIX}/Image` + # (arm64) or `${NETBOOT_TFTP_PREFIX}/zImage` (armv7) — alongside `uInitrd` + # and `dtb/`. Mirror that exact naming so a kernel refresh overwrites the + # same path the bootloader fetches, instead of sprouting a `boot/` subdir + # the bootloader doesn't look in. + declare kernel_src="${scratch_dir}/boot/vmlinuz-${kver}" + if [[ ! -f "${kernel_src}" ]]; then + exit_with_error "${EXTENSION}: ${kernel_src} missing in unpacked deb" \ + "linux-image deb has unexpected layout; expected /boot/vmlinuz-${kver}" + fi + # Mirror netboot.sh's full-image kernel-naming fallback (see netboot.sh + # around the `kernel_name=Image|zImage` switch): arm64 → Image, anything + # else → zImage. The kernel-only deploy must write to the same TFTP path + # the bootloader was told to fetch by the original full-image build — + # otherwise a kernel-only refresh on amd64/etc. would put a new + # `vmlinuz-` next to the still-booted `zImage`, silently shipping + # nothing to the next PXE boot. + declare kernel_name + case "${ARCH}" in + arm64) kernel_name="Image" ;; + *) kernel_name="zImage" ;; + esac + display_alert "${EXTENSION}: rsync kernel to TFTP" \ + "vmlinuz-${kver} -> ${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/${kernel_name}" "info" + run_host_command_logged_raw rsync "${rsync_base[@]}" \ + "${kernel_src}" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/${kernel_name}" + + # Drop any pre-existing uInitrd from TFTP. See the function's header + # comment for the full rationale: kernel-only deploy intentionally does + # not regenerate initramfs (cannot, without the configured rootfs context), + # and leaving the previous full-image build's uInitrd next to a fresh + # kernel would have U-Boot load it and the kernel hit vermagic mismatches + # inside initramfs's modprobe. Removing it makes U-Boot proceed without + # initramfs — clean boot on built-in-NIC boards, fail-fast on modular-NIC + # ones (the operator then knows to do a full image rebuild). + declare q_uinitrd_path + q_uinitrd_path="'${NETBOOT_DEPLOY_TFTP_ROOT//\'/\'\\\'\'}/${NETBOOT_TFTP_PREFIX//\'/\'\\\'\'}/uInitrd'" + display_alert "${EXTENSION}: drop stale uInitrd from TFTP" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/uInitrd" "info" + run_host_command_logged_raw ssh "${ssh_opts[@]}" "${NETBOOT_DEPLOY_SSH}" \ + "${sudo_prefix}rm -f ${q_uinitrd_path}" + + # Also drop the matching `INITRD ${NETBOOT_TFTP_PREFIX}/uInitrd` stanza + # from any pxelinux.cfg/* that still references it. U-Boot's PXE/extlinux + # loader (boot/pxe_utils.c, get_relfile_envaddr) aborts a label with + # "Skipping ... for failure retrieving initrd" when INITRD is specified + # but the file is gone — so dropping just the file would brick the next + # PXE boot instead of letting it fall back to an initramfs-less boot. + # Match only lines whose path equals exactly `${NETBOOT_TFTP_PREFIX}/uInitrd` + # so labels for unrelated boards/branches/releases under the same + # admin-shared pxelinux.cfg/ directory are left untouched. `#` as the + # sed-address delimiter avoids escaping the `/`-rich path inside + # NETBOOT_TFTP_PREFIX. Trailing `; true` keeps the ssh exit clean when + # pxelinux.cfg/ does not exist yet (first deploy with no prior full + # image build, or an admin who keeps PXE configs elsewhere). + declare initrd_pattern q_initrd_pattern q_pxelinux_dir + initrd_pattern="^[[:space:]]*INITRD[[:space:]][[:space:]]*${NETBOOT_TFTP_PREFIX}/uInitrd[[:space:]]*\$" + q_initrd_pattern="'${initrd_pattern//\'/\'\\\'\'}'" + q_pxelinux_dir="'${NETBOOT_DEPLOY_TFTP_ROOT//\'/\'\\\'\'}/pxelinux.cfg'" + display_alert "${EXTENSION}: drop matching INITRD line from pxelinux.cfg" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/pxelinux.cfg/* (INITRD ${NETBOOT_TFTP_PREFIX}/uInitrd)" "info" + run_host_command_logged_raw ssh "${ssh_opts[@]}" "${NETBOOT_DEPLOY_SSH}" \ + "test -d ${q_pxelinux_dir} && ${sudo_prefix}find ${q_pxelinux_dir} -type f -exec sed -i '\\#'${q_initrd_pattern}'#d' {} + ; true" + + # DTBs: linux-image deb stages them under /usr/lib/linux-image-/. + # netboot.sh on a full image rebuild flattens them into ${NETBOOT_TFTP_PREFIX}/dtb/ + # (e.g. dtb/rockchip/rk3399-helios64.dtb). Reproduce that layout exactly so + # the bootloader's `fdt_addr_r=...; load tftp ${fdt_addr_r} ${tftp_prefix}/dtb//.dtb` + # keeps working. Skip silently when the deb has no dtbs (KERNEL_BUILD_DTBS=no, x86). + # + # --delete mirrors the full-image deploy: dtb/ lives entirely under the + # board+branch+release-scoped NETBOOT_TFTP_PREFIX, so pruning here only + # touches DTBs that disappeared from this kernel's package — a renamed + # or removed BOOT_FDT_FILE would otherwise keep loading a stale DTB + # against the fresh kernel, the exact coherence gap this hook closes. + if [[ -d "${scratch_dir}/usr/lib/linux-image-${kver}" ]]; then + declare -a rsync_dtbs=("${rsync_base[@]}" --delete) + display_alert "${EXTENSION}: rsync dtbs to TFTP" \ + "usr/lib/linux-image-${kver}/ -> ${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/dtb/" "info" + run_host_command_logged_raw rsync "${rsync_dtbs[@]}" \ + "${scratch_dir}/usr/lib/linux-image-${kver}/" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_DEPLOY_TFTP_ROOT}/${NETBOOT_TFTP_PREFIX}/dtb/" + fi + + # NFS side: --delete inside this kernel version's /lib/modules// + # only. Stale .ko files from the previous build of *this* kernel are the + # whole point of this hook (split-BTF coherence). Other kernel versions + # in /lib/modules/ — e.g. dual-boot setups, older edge alongside current + # — are untouched. + _netboot_deploy_require_remote_root "/lib/modules/${kver}/ NFS rsync" + declare -a rsync_modules=("${rsync_base[@]}" --delete) + display_alert "${EXTENSION}: rsync modules to NFS rootfs" \ + "lib/modules/${kver}/ -> ${NETBOOT_DEPLOY_SSH}:${NETBOOT_NFS_PATH}/lib/modules/${kver}/" "info" + run_host_command_logged_raw rsync "${rsync_modules[@]}" \ + "${scratch_dir}/lib/modules/${kver}/" \ + "${NETBOOT_DEPLOY_SSH}:${NETBOOT_NFS_PATH}/lib/modules/${kver}/" + + display_alert "${EXTENSION}: kernel deploy done" \ + "${NETBOOT_DEPLOY_SSH} (kver=${kver})" "info" + # Initramfs is not produced here — see the function's header comment. + # The previous uInitrd was already dropped from TFTP above; this alert + # exists so the operator sees, in the build log, that initramfs is a + # concern of the full image build and not of kernel-only deploy. + display_alert "${EXTENSION}: skipped initramfs refresh" \ + "kernel-only deploy cannot regenerate initramfs (depends on configured rootfs context); for a fresh initramfs do a full image rebuild" "warn" +) From b16c764b53996b137f497c1df8449c4bd1e70eff Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Fri, 1 May 2026 05:55:51 +0300 Subject: [PATCH 09/16] docs(extensions/netboot): troubleshooting for NFS over TCP / DHCP siaddr Add troubleshooting entries for the most common netboot failure modes: 'NFS over TCP not available from ' (DHCP boot-server / siaddr unset or wrong) and the corresponding dnsmasq/OpenWRT 'dhcp-boot' fix. Show how to verify with /proc/net/pnp on a booted client. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- extensions/netboot/README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/extensions/netboot/README.md b/extensions/netboot/README.md index 6f92e5bd7212..760db6eef1a4 100644 --- a/extensions/netboot/README.md +++ b/extensions/netboot/README.md @@ -876,18 +876,15 @@ order: - `nfsroot=` is using a hostname the client can't resolve yet (DNS isn't up during early mount). Use an IP, not a hostname — the extension does this by default when `NETBOOT_SERVER` is set. -- `nfsmount: need a path` retried forever — the image has - `nfsroot=auto` but `ROOTPATH` ended up empty. Either the router - doesn't advertise DHCP option 17 (`uci add_list dhcp.lan.dhcp_option='17,:'`), - or the build skipped the initramfs plumbing (e.g. an older `uInitrd` - on TFTP from before this extension landed). For a quick check, - unpack the deployed `uInitrd` and confirm `option root_path` is - in `/etc/dhcpcd.conf` and `71-netboot-rootpath` exists under - `/usr/libexec/dhcpcd-hooks/` or `/usr/lib/dhcpcd/dhcpcd-hooks/` (the - initramfs hook copies into whichever the upstream dhcpcd hook used). - If you can't fix the router, set - `NETBOOT_SERVER=` at build time and rebuild — that path skips - option 17 entirely. +- `NFS over TCP not available from ` — the initramfs is + trying to mount from the default gateway instead of the NFS server. + The DHCP boot-server (`siaddr`) is not set or points to the wrong + host. Set `dhcp-boot=default,nfsserver,` in dnsmasq + (or `uci set dhcp.@dnsmasq[0].dhcp_boot='default,nfsserver,'` + in OpenWRT) so the router announces the NFS server as siaddr. Check + `/proc/net/pnp` on a booted client — `bootserver` must match your + NFS server IP. If you can't configure the router, set + `NETBOOT_SERVER=` at build time and rebuild. **`MODULE FAILURE` from initramfs** — the kernel is loading an initramfs and trying to run `/init` that doesn't understand NFS root. From 9bfa53ba8d8c1e33061732a1e0b272297177e737 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Sat, 2 May 2026 02:45:14 +0300 Subject: [PATCH 10/16] feat(extensions/netboot): initramfs stall watchdog for nfs-root boot Add a 10-minute mount-stall safety net via two initramfs hooks: - init-premount/zz-netboot-watchdog forks a background shell that sleeps 600 s and then triggers an immediate reboot via `echo b > /proc/sysrq-trigger` if the NFS root mount has not completed by then. - nfs-bottom/zz-netboot-watchdog-cancel kills that background shell after a successful mount. Active only for ROOTFS_TYPE=nfs-root. Without the watchdog, a misconfigured server or transient network failure would hang the target in initramfs forever; with it, the board self-recovers and tries again. Assisted-by: Claude:claude-opus-4.7 Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- .../init-premount/zz-netboot-watchdog | 24 +++++++++++++++++++ .../nfs-bottom/zz-netboot-watchdog-cancel | 21 ++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100755 extensions/netboot/files/initramfs-scripts/init-premount/zz-netboot-watchdog create mode 100644 extensions/netboot/files/initramfs-scripts/nfs-bottom/zz-netboot-watchdog-cancel diff --git a/extensions/netboot/files/initramfs-scripts/init-premount/zz-netboot-watchdog b/extensions/netboot/files/initramfs-scripts/init-premount/zz-netboot-watchdog new file mode 100755 index 000000000000..008a052f64ce --- /dev/null +++ b/extensions/netboot/files/initramfs-scripts/init-premount/zz-netboot-watchdog @@ -0,0 +1,24 @@ +#!/bin/sh +PREREQ="" +prereqs() { echo "$PREREQ"; } +case $1 in + prereqs) prereqs; exit 0 ;; +esac + +# Only arm on NFS root boots. +grep -q "root=/dev/nfs" /proc/cmdline || exit 0 + +# Trigger SysRq-B (immediate reboot, not a kernel panic — different kernel path, +# reboot via PSCI on arm64) after 10 min if NFS root mount hangs. +# nfs-bottom/zz-netboot-watchdog-cancel kills this on successful mount. +( + sleep 600 + echo "netboot-watchdog: 10 min elapsed, forcing reboot" > /dev/kmsg 2>/dev/null + # Force-enable SysRq before triggering. If kernel.sysrq is 0 at runtime + # (hardened image, sysctl override, etc.), `echo b` writes succeed at the + # shell level but are no-ops in the kernel — the watchdog would log its + # message and then hang. Setting `1` enables the full command set. + echo 1 > /proc/sys/kernel/sysrq 2>/dev/null || true + echo b > /proc/sysrq-trigger +) & +echo $! > /run/netboot-watchdog.pid diff --git a/extensions/netboot/files/initramfs-scripts/nfs-bottom/zz-netboot-watchdog-cancel b/extensions/netboot/files/initramfs-scripts/nfs-bottom/zz-netboot-watchdog-cancel new file mode 100644 index 000000000000..90462e420479 --- /dev/null +++ b/extensions/netboot/files/initramfs-scripts/nfs-bottom/zz-netboot-watchdog-cancel @@ -0,0 +1,21 @@ +#!/bin/sh +PREREQ="" +prereqs() { echo "$PREREQ"; } +case $1 in + prereqs) prereqs; exit 0 ;; +esac + +# Cancel the NFS watchdog started by init-premount/zz-netboot-watchdog. +# Validate the pidfile content before signalling: a stale or corrupted file +# (truncated, non-numeric, or PID 1 from kernel/init) must not produce an +# unintended kill. Only proceed for a numeric PID strictly greater than 1. +if [ -f /run/netboot-watchdog.pid ]; then + read -r watchdog_pid < /run/netboot-watchdog.pid 2>/dev/null || watchdog_pid="" + case "${watchdog_pid}" in + '' | *[!0-9]*) ;; + *) + [ "${watchdog_pid}" -gt 1 ] && kill "${watchdog_pid}" 2> /dev/null || true + ;; + esac + rm -f /run/netboot-watchdog.pid +fi From 80f89b9faedb9335c353adde47ee4006abb19071 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Mon, 4 May 2026 08:21:45 +0300 Subject: [PATCH 11/16] fix(host-utils): use chown -h to tolerate dangling symlinks in reset_uid_owner netboot extension exports an unpacked rootfs tree under output/netboot-export// and reset_uid_owner runs over it. Debian/Ubuntu rootfs trees routinely contain dangling symlinks (e.g. /etc/systemd/system/multi-user.target.wants/ entries referring to services from packages that aren't installed). GNU chown without -h follows the symlink and fails on a missing target with 'cannot dereference', so post-docker cleanup returns exit 2 even though the build itself succeeded. chown -h sets the owner of the symlink inode itself rather than its target; it is a no-op for regular files and directories. This is the semantically correct choice when walking a filesystem tree, regardless of the netboot use case. Surfaced by armbian/build#9656 (netboot extension); applies to any extension that performs reset_uid_owner over a real rootfs tree on host. Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- lib/functions/host/host-utils.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/functions/host/host-utils.sh b/lib/functions/host/host-utils.sh index 00a27dfd7ea0..f35c88203121 100644 --- a/lib/functions/host/host-utils.sh +++ b/lib/functions/host/host-utils.sh @@ -279,10 +279,10 @@ function reset_uid_owner() { for arg in "$@"; do display_alert "reset_uid_owner: '${arg}' will be owner id '${SET_OWNER_TO_UID}'" "reset_uid_owner" "debug" if [[ -d "${arg}" ]]; then - chown "${SET_OWNER_TO_UID}" "${arg}" - find "${arg}" -uid 0 -print0 | xargs --no-run-if-empty -0 chown "${SET_OWNER_TO_UID}" + chown -h "${SET_OWNER_TO_UID}" "${arg}" + find "${arg}" -uid 0 -print0 | xargs --no-run-if-empty -0 chown -h "${SET_OWNER_TO_UID}" elif [[ -f "${arg}" ]]; then - chown "${SET_OWNER_TO_UID}" "${arg}" + chown -h "${SET_OWNER_TO_UID}" "${arg}" else display_alert "reset_uid_owner: '${arg}' is not a file or directory" "skipping" "debug" return 1 @@ -300,9 +300,9 @@ function reset_uid_owner_non_recursive() { for arg in "$@"; do display_alert "reset_uid_owner_non_recursive: '${arg}' will be owner id '${SET_OWNER_TO_UID}'" "reset_uid_owner_non_recursive" "debug" if [[ -d "${arg}" ]]; then - chown "${SET_OWNER_TO_UID}" "${arg}" + chown -h "${SET_OWNER_TO_UID}" "${arg}" elif [[ -f "${arg}" ]]; then - chown "${SET_OWNER_TO_UID}" "${arg}" + chown -h "${SET_OWNER_TO_UID}" "${arg}" else display_alert "reset_uid_owner_non_recursive: '${arg}' is not a file or directory" "skipping" "debug" return 1 From f9e363c41d896f20799e0616e4be3f21cab4cf28 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Mon, 4 May 2026 23:12:02 +0300 Subject: [PATCH 12/16] style: shfmt alignment in main-config.sh and rootfs-create.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whitespace-only fixes picked up by shfmt — comment column alignment in configuration/main-config.sh, tab indent on a stray line, and a stripped trailing blank line in rootfs/rootfs-create.sh. No behavioural change. Signed-off-by: Igor Velkov <325961+iav@users.noreply.github.com> --- lib/functions/configuration/main-config.sh | 8 ++++---- lib/functions/rootfs/rootfs-create.sh | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/functions/configuration/main-config.sh b/lib/functions/configuration/main-config.sh index 87630e23fbf3..daeab7708232 100644 --- a/lib/functions/configuration/main-config.sh +++ b/lib/functions/configuration/main-config.sh @@ -47,7 +47,7 @@ function do_main_configuration() { unset VENDORSUPPORT,VENDORPRIVACY,VENDORBUGS,VENDORLOGO,ROOTPWD,MAINTAINER,MAINTAINERMAIL fi - [[ -z $VENDORCOLOR ]] && VENDORCOLOR="247;16;0" # RGB values for MOTD logo + [[ -z $VENDORCOLOR ]] && VENDORCOLOR="247;16;0" # RGB values for MOTD logo [[ -z $VENDORURL ]] && VENDORURL="https://duckduckgo.com/" [[ -z $VENDORSUPPORT ]] && VENDORSUPPORT="https://community.armbian.com/" [[ -z $VENDORPRIVACY ]] && VENDORPRIVACY="https://duckduckgo.com/" @@ -60,7 +60,7 @@ function do_main_configuration() { DEST_LANG="${DEST_LANG:-"en_US.UTF-8"}" # en_US.UTF-8 is default locale for target display_alert "DEST_LANG..." "DEST_LANG: ${DEST_LANG}" "debug" - declare -g USE_CCACHE="${USE_CCACHE:-no}" # stop using ccache as our worktree is more effective + declare -g USE_CCACHE="${USE_CCACHE:-no}" # stop using ccache as our worktree is more effective # Armbian config is central tool used in all builds. As its build externally, we have moved it to extension. Enable it here. enable_extension "armbian-config" @@ -180,7 +180,7 @@ function do_main_configuration() { # Support for LUKS / cryptroot if [[ $CRYPTROOT_ENABLE == yes ]]; then - enable_extension "fs-cryptroot-support" # add the tooling needed, cryptsetup + enable_extension "fs-cryptroot-support" # add the tooling needed, cryptsetup if [[ -z $CRYPTROOT_PASSPHRASE ]] && [[ -z $CRYPTROOT_AUTOUNLOCK ]]; then # a passphrase is mandatory if rootfs encryption is enabled, unless CRYPTROOT_AUTOUNLOCK is wanted exit_with_error "Root encryption is enabled but CRYPTROOT_PASSPHRASE or CRYPTROOT_AUTOUNLOCK is not set" fi @@ -333,7 +333,7 @@ function do_main_configuration() { ;; esac - # enable APA extension for Debian Unstable release + # enable APA extension for Debian Unstable release # loong64 is not supported now # [ "$RELEASE" = "sid" ] && [ "$ARCH" != "loong64" ] && enable_extension "apa" diff --git a/lib/functions/rootfs/rootfs-create.sh b/lib/functions/rootfs/rootfs-create.sh index 74f351f4929e..c664daa498f9 100644 --- a/lib/functions/rootfs/rootfs-create.sh +++ b/lib/functions/rootfs/rootfs-create.sh @@ -62,7 +62,7 @@ function create_new_rootfs_cache_via_debootstrap() { local debootstrap_apt_mirror="http://localhost:3142/${APT_MIRROR}" acng_check_status_or_restart ;; - no) ;& # do nothing, fallthrough + no) ;& # do nothing, fallthrough "") : # still do nothing ;; # stop falling @@ -140,7 +140,6 @@ function create_new_rootfs_cache_via_debootstrap() { skip_target_check="yes" local_apt_deb_cache_prepare "for mmdebstrap" # just for size reference in logs - [[ ! -f "${SDCARD}/bin/bash" ]] && exit_with_error "mmdebstrap did not produce /bin/bash" # Done with mmdebstrap. Clean-up its litterbox. From 8e04c310438fc24e256bb13e0b3dc12db7cbface Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Tue, 12 May 2026 03:39:41 +0300 Subject: [PATCH 13/16] fix(extensions/netboot-deploy): copy SSH_KEY to root-owned scratch before probe (codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy probe at `extension_prepare_config__060_netboot_deploy_probe_target` was passing ${NETBOOT_DEPLOY_SSH_KEY} directly to ssh via `-i`, but that path is the host-bind-mounted file owned by the host user. OpenSSH refuses identity files whose owner is neither root nor the current user, so probe fails with 'Bad owner' even on CI/batch credentials that would work fine at deploy time — the deploy hook already copies the key to a root-owned scratch path (lines 354-369). Mirror the deploy's scratch-key handling in the probe so the same configuration exercises the same auth chain at both phases. Cleanup runs right after the probe completes (no subshell EXIT trap here — the function lives in the caller's scope, a trap would leak). Reported by chatgpt-codex on PR #9656. Assisted-by: Claude:claude-opus-4.7 --- extensions/netboot/netboot-deploy.sh | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/extensions/netboot/netboot-deploy.sh b/extensions/netboot/netboot-deploy.sh index 2c2b8f8f7dd7..5056b08f46cf 100644 --- a/extensions/netboot/netboot-deploy.sh +++ b/extensions/netboot/netboot-deploy.sh @@ -182,7 +182,24 @@ function extension_prepare_config__060_netboot_deploy_probe_target() { fi # shellcheck disable=SC2206 # word-split intentional, user gives a shell-style string declare -a probe_cmd=(ssh -o ConnectTimeout=5 ${probe_extra} ${NETBOOT_DEPLOY_SSH_OPTS:--o BatchMode=yes}) - [[ -n "${NETBOOT_DEPLOY_SSH_KEY:-}" ]] && probe_cmd+=(-i "${NETBOOT_DEPLOY_SSH_KEY}") + # Mirror the deploy hook's scratch-key dance: OpenSSH refuses identity + # files whose owner is neither root nor the current user, and a key + # bind-mounted from the host keeps the host user's ownership. Without + # this the probe would reject valid CI/batch credentials that the + # real deploy (which copies into a scratch path) handles fine. Clean + # up right after the probe — extension_prepare_config runs in the + # caller's scope, so a trap would leak. + declare probe_scratch_key="" + if [[ -n "${NETBOOT_DEPLOY_SSH_KEY:-}" ]]; then + if [[ ! -f "${NETBOOT_DEPLOY_SSH_KEY}" ]]; then + exit_with_error "${EXTENSION}: NETBOOT_DEPLOY_SSH_KEY set but not visible in container" \ + "${NETBOOT_DEPLOY_SSH_KEY}" + fi + probe_scratch_key="$(mktemp -p /tmp "netboot-deploy-probe-key.XXXXXX")" + cp "${NETBOOT_DEPLOY_SSH_KEY}" "${probe_scratch_key}" + chmod 600 "${probe_scratch_key}" + probe_cmd+=(-i "${probe_scratch_key}") + fi declare sudo_prefix="" [[ "${NETBOOT_DEPLOY_SUDO:-no}" == "yes" ]] && sudo_prefix="sudo -n " # Quote remote path for any POSIX shell (same idiom as the deploy hook below). @@ -199,11 +216,11 @@ function extension_prepare_config__060_netboot_deploy_probe_target() { "${sudo_prefix}touch ${q_probe} && ${sudo_prefix}rm -f ${q_probe}" 2> "${probe_stderr_file}"; then declare probe_stderr probe_stderr=$(head -c 4096 "${probe_stderr_file}" | sed 's/[[:space:]]*$//') - rm -f "${probe_stderr_file}" + rm -f "${probe_stderr_file}" "${probe_scratch_key}" exit_with_error "${EXTENSION}: deploy probe failed" \ "ssh '${NETBOOT_DEPLOY_SSH}' cannot create+remove a file under '${tftp_root}'. ssh stderr: ${probe_stderr:-}. Check NETBOOT_DEPLOY_SSH_KEY, sudo NOPASSWD, target dir existence/permissions, host-key trust (NETBOOT_DEPLOY_SSH_KNOWN_HOSTS or NETBOOT_DEPLOY_SSH_TOFU). Bypass with NETBOOT_DEPLOY_PROBE=no." fi - rm -f "${probe_stderr_file}" + rm -f "${probe_stderr_file}" "${probe_scratch_key}" } # Host-side: bind-mount the chosen private key into the build container From 7aa19b8465b3b74884a05cac7eeb393601e5206b Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Tue, 12 May 2026 03:40:48 +0300 Subject: [PATCH 14/16] fix(rootfs): exclude_home array instead of embedded-quotes string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `declare exclude_home="--exclude=\"/home/*\""` evaluates to a single string `--exclude="/home/*"` — the inner quotes are part of the value, not shell syntax. Bash does not re-parse quotes stored in variables, so when expanded unquoted on the rsync command line rsync receives `--exclude="/home/*"` as the pattern (with literal quote characters). /home then matches nothing, gets copied through, and `--delete-excluded` cannot purge stale home directories. Switch to an array of plain arguments: `exclude_home=(--exclude=/home/*)`. The catch-all bash quoting around the value goes away, the single glob char is fine inside a single-element array literal because the array is bound before any pathname expansion would happen, and `"${exclude_home[@]}"` on the rsync command line expands to zero arguments when ${INCLUDE_HOME_DIR}=yes (array empty) or one argument `--exclude=/home/*` otherwise. Both call sites (lines 61 and 203) updated. Pre-existing since c45a63e63b (2023-11-21), surfaced by chatgpt-codex review on PR #9656 while netboot work touched the surrounding function. Assisted-by: Claude:claude-opus-4.7 --- lib/functions/image/rootfs-to-image.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/functions/image/rootfs-to-image.sh b/lib/functions/image/rootfs-to-image.sh index 5b88b425263e..03f1d6d7ea27 100644 --- a/lib/functions/image/rootfs-to-image.sh +++ b/lib/functions/image/rootfs-to-image.sh @@ -44,9 +44,12 @@ function create_image_from_sdcard_rootfs() { # inside the build chroot is silently dropped between SDCARD and the # packaged image. declare rsync_ea="${ROOTFS_RSYNC_XATTR_FLAGS:- -AXS --numeric-ids }" - declare exclude_home="--exclude=\"/home/*\"" + # Use an array so the value reaches rsync as one argument without embedded + # quote characters. The previous string form `--exclude="/home/*"` made rsync + # treat the literal quotes as part of the pattern and never excluded /home. + declare -a exclude_home=(--exclude=/home/\*) # Some usecase requires home directory to be included - if [[ ${INCLUDE_HOME_DIR:-no} == yes ]]; then exclude_home=""; fi + if [[ ${INCLUDE_HOME_DIR:-no} == yes ]]; then exclude_home=(); fi # nilfs2 fs does not have extended attributes support, and have to be ignored on copy if [[ $ROOTFS_TYPE == nilfs2 ]]; then rsync_ea=" --numeric-ids "; fi if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]]; then @@ -58,7 +61,7 @@ function create_image_from_sdcard_rootfs() { --exclude="/run/*" \ --exclude="/tmp/*" \ --exclude="/sys/*" \ - $exclude_home \ + "${exclude_home[@]}" \ --info=progress0,stats1 $SDCARD/ $MOUNT/ fi @@ -200,7 +203,7 @@ function create_image_from_sdcard_rootfs() { --exclude="/run/*" \ --exclude="/tmp/*" \ --exclude="/sys/*" \ - $exclude_home \ + "${exclude_home[@]}" \ --info=progress0,stats1 "$SDCARD/" "${ROOTFS_EXPORT_DIR}/" fi From 944684178850299c733d2fb92e19f05c68d9ae9d Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Tue, 12 May 2026 04:50:49 +0300 Subject: [PATCH 15/16] fix(rootfs): exclude_home as single-quoted string for run_host_command_logged (coderabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit noted that `run_host_command_logged` rebuilds its argv via `bash -c "$*"`, so an array passed as "${exclude_home[@]}" loses its quoting at the bash -c re-parse step and the pattern's '*' becomes a glob candidate again. Switch to a single-quoted string carried inside the variable's value: `exclude_home="'--exclude=/home/*'"`. Used $exclude_home unquoted at the call sites: - Empty case (INCLUDE_HOME_DIR=yes): variable is empty → expansion is zero args → rsync sees nothing. - Non-empty case: expansion is one word `'--exclude=/home/*'` whose embedded single-quotes survive bash -c re-parse and hide the '*' from globbing, so rsync gets the literal pattern. Cleaner than ${array[*]@Q} which would emit a literal `''` (two-char quoted-empty) on the empty-array path and pass an empty arg to rsync. Reported by coderabbit on PR #9656 review of 7aa19b8465. Assisted-by: Claude:claude-opus-4.7 --- lib/functions/image/rootfs-to-image.sh | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/functions/image/rootfs-to-image.sh b/lib/functions/image/rootfs-to-image.sh index 03f1d6d7ea27..923f4d8783af 100644 --- a/lib/functions/image/rootfs-to-image.sh +++ b/lib/functions/image/rootfs-to-image.sh @@ -44,16 +44,23 @@ function create_image_from_sdcard_rootfs() { # inside the build chroot is silently dropped between SDCARD and the # packaged image. declare rsync_ea="${ROOTFS_RSYNC_XATTR_FLAGS:- -AXS --numeric-ids }" - # Use an array so the value reaches rsync as one argument without embedded - # quote characters. The previous string form `--exclude="/home/*"` made rsync - # treat the literal quotes as part of the pattern and never excluded /home. - declare -a exclude_home=(--exclude=/home/\*) - # Some usecase requires home directory to be included - if [[ ${INCLUDE_HOME_DIR:-no} == yes ]]; then exclude_home=(); fi + # Carry a single-quoted shell token in the variable's value so it + # survives `run_host_command_logged`'s `bash -c "$*"` re-parse. The + # previous form `--exclude="/home/*"` failed because the value was + # `--exclude="/home/*"` *with literal double-quotes*, which rsync + # treated as part of the pattern and never excluded /home. A bare + # array (`(--exclude=/home/*)`) doesn't help here either: the helper + # re-parses argv via `bash -c "$*"`, which restores the `*` as a + # live glob char. Single-quoted form below hides `*` from globbing + # at the bash -c re-parse step. + declare exclude_home="" + # shellcheck disable=SC2089 # embedded single-quotes are intentional — survive run_host_command_logged's bash -c "$*" re-parse + [[ "${INCLUDE_HOME_DIR:-no}" != "yes" ]] && exclude_home="'--exclude=/home/*'" # nilfs2 fs does not have extended attributes support, and have to be ignored on copy if [[ $ROOTFS_TYPE == nilfs2 ]]; then rsync_ea=" --numeric-ids "; fi if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]]; then display_alert "Copying files via rsync to" "/ (MOUNT root)" + # shellcheck disable=SC2090 # embedded single-quotes in $exclude_home are intentional — see declaration above run_host_command_logged rsync -aHWh $rsync_ea \ --exclude="/boot" \ --exclude="/dev/*" \ @@ -61,7 +68,7 @@ function create_image_from_sdcard_rootfs() { --exclude="/run/*" \ --exclude="/tmp/*" \ --exclude="/sys/*" \ - "${exclude_home[@]}" \ + $exclude_home \ --info=progress0,stats1 $SDCARD/ $MOUNT/ fi @@ -196,6 +203,7 @@ function create_image_from_sdcard_rootfs() { # reused export tree (otherwise the NFS root silently drifts from the image). # --delete-excluded additionally purges receiver-side files that match our # excludes (e.g. stale /home/* left over from a prior INCLUDE_HOME_DIR=yes build). + # shellcheck disable=SC2090 # embedded single-quotes in $exclude_home are intentional — see declaration above run_host_command_logged rsync -aHWh --delete --delete-excluded $rsync_ea \ --exclude="/boot/*" \ --exclude="/dev/*" \ @@ -203,7 +211,7 @@ function create_image_from_sdcard_rootfs() { --exclude="/run/*" \ --exclude="/tmp/*" \ --exclude="/sys/*" \ - "${exclude_home[@]}" \ + $exclude_home \ --info=progress0,stats1 "$SDCARD/" "${ROOTFS_EXPORT_DIR}/" fi From 36ac04d40ba7305c4151dc9ddad394ed6a6ba326 Mon Sep 17 00:00:00 2001 From: Igor Velkov <325961+iav@users.noreply.github.com> Date: Wed, 13 May 2026 17:48:26 +0300 Subject: [PATCH 16/16] fix(netboot): silence "Couldn't identify type of root file system" for /dev/nfs update-initramfs runs four times during an nfs-root build: 1. linux-image kernel-install (in chroot, before customize_image) 2. post_customize_image__netboot_request_root_path (this extension) 3. post_customize_image__netboot_initramfs_watchdog (this extension) 4. any later kernel-package upgrade on the running netboot host Each call emits the cosmetic warning W: Couldn't identify type of root file system '/dev/nfs' for fsck hook because initramfs-tools can't classify /dev/nfs and the fsck hook is unconditionally probed. fsck is meaningless on an NFS-mounted root, so ship /etc/initramfs-tools/conf.d/netboot-no-fsck with FSCKFIX=no. Installed via a new post_customize_image hook ordered before the existing request_root_path / watchdog hooks (definition order in extension files maps to hook invocation order), so calls 2-4 above pick it up. Call 1 runs before customize_image hooks and still emits the warning once; suppressing it would require an earlier hook point that doesn't fit this extension. Assisted-by: Claude:claude-opus-4.7 --- .../files/initramfs-conf.d/netboot-no-fsck | 10 ++++++++++ extensions/netboot/netboot.sh | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 extensions/netboot/files/initramfs-conf.d/netboot-no-fsck diff --git a/extensions/netboot/files/initramfs-conf.d/netboot-no-fsck b/extensions/netboot/files/initramfs-conf.d/netboot-no-fsck new file mode 100644 index 000000000000..d0f7a1a57b94 --- /dev/null +++ b/extensions/netboot/files/initramfs-conf.d/netboot-no-fsck @@ -0,0 +1,10 @@ +# Installed by the Armbian `netboot` extension for ROOTFS_TYPE=nfs-root. +# +# update-initramfs probes the root filesystem to decide whether to include +# the fsck hook. For nfs-root images the root device shows up as "/dev/nfs", +# which initramfs-tools cannot classify, producing: +# +# W: Couldn't identify type of root file system '/dev/nfs' for fsck hook +# +# fsck is not applicable to NFS anyway, so suppress the probe entirely. +FSCKFIX=no diff --git a/extensions/netboot/netboot.sh b/extensions/netboot/netboot.sh index 4f9b4b53419d..5dcbdcf02ebd 100644 --- a/extensions/netboot/netboot.sh +++ b/extensions/netboot/netboot.sh @@ -393,6 +393,22 @@ function post_customize_image__netboot_skip_firstlogin_wizard() { run_host_command_logged rm -f "${SDCARD}/root/.not_logged_in_yet" } +# Suppress the update-initramfs probe warning: +# W: Couldn't identify type of root file system '/dev/nfs' for fsck hook +# fsck is not applicable to NFS-mounted roots, and the warning otherwise +# repeats on every initramfs rebuild (our own request_root_path + watchdog +# hooks below, plus any later kernel package upgrade on the booted host). +# Drop the snippet before the subsequent update-initramfs calls so they +# pick it up. +function post_customize_image__netboot_disable_initramfs_fsck() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + + declare conf_d="${SDCARD}/etc/initramfs-tools/conf.d/netboot-no-fsck" + display_alert "${EXTENSION}: installing initramfs.conf.d snippet" "FSCKFIX=no — silence /dev/nfs fsck warning" "info" + run_host_command_logged install -D -m 0644 \ + "${EXTENSION_DIR}/files/initramfs-conf.d/netboot-no-fsck" "${conf_d}" +} + # Fix ROOTSERVER in initramfs for path-only nfsroot= boots. The stock # 70-net-conf dhcpcd-hook sets ROOTSERVER to the default gateway (new_routers), # which makes /scripts/nfs in initramfs-tools mount the rootfs from the wrong