#!/bin/bash # # Build Archipelago Auto-Installer ISO (StartOS-like) # # This creates an ISO that automatically installs to the internal disk # with minimal user interaction - similar to StartOS experience. # # CRITICAL: This script CAPTURES the LIVE SERVER state by default. # Set DEV_SERVER to point to your development server. # # Usage: # DEV_SERVER=archipelago@192.0.2.10 ./build-auto-installer-iso.sh # OR just: ./build-auto-installer-iso.sh (uses default server) # # To build from source instead: # BUILD_FROM_SOURCE=1 ./build-auto-installer-iso.sh # # Features: # - Pre-built root filesystem (no network needed during install) # - Auto-detects internal disk (skips USB boot drive) # - Automatic installation with progress display # - Boots directly to web UI after install # # Image versions: sourced from scripts/image-versions.sh (single source of truth). # All container image references MUST use the $*_IMAGE variables defined there. # # --- PLANNED REFACTOR (post-beta) --- # This script is ~1870 lines and should be split into a modular library. # Proposed structure: # image-recipe/ # build-auto-installer-iso.sh — Main orchestrator (config, CLI args, step sequencing) # lib/ # rootfs.sh — Step 1: Build root filesystem via Docker (~185 lines) # installer-env.sh — Step 2: Build minimal installer via debootstrap (~80 lines) # components.sh — Step 3: Add Archipelago components (binary, configs, web UI) (~120 lines) # container-images.sh — Step 3b: Bundle container images for offline install (~330 lines) # auto-install-script.sh — Step 4: Generate the embedded auto-install.sh (~615 lines) # boot-config.sh — Step 5: Configure live boot auto-start + overlay squashfs (~215 lines) # create-iso.sh — Step 6: Build final bootable ISO with xorriso/grub (~140 lines) # Each lib/ script exports functions; main script sources them and calls in sequence. # DO NOT split until tested on the build server — this is critical infrastructure. # --- # set -e # Source pinned image versions (single source of truth) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ] && . "$SCRIPT_DIR/../../scripts/image-versions.sh" # Configuration DEV_SERVER="${DEV_SERVER:-archipelago@192.0.2.10}" BUILD_FROM_SOURCE="${BUILD_FROM_SOURCE:-0}" UNBUNDLED="${UNBUNDLED:-0}" ARCH="${ARCH:-x86_64}" # ── Sequential build numbering ───────────────────────────────────────── # Increments on each build. Users see this in UI (Settings, sidebar). # Counter persists in /opt/archipelago/build-counter (on build machine). BUILD_COUNTER_FILE="/opt/archipelago/build-counter" if [ -f "$BUILD_COUNTER_FILE" ]; then BUILD_NUM=$(( $(cat "$BUILD_COUNTER_FILE") + 1 )) else BUILD_NUM=1 fi echo "$BUILD_NUM" | sudo tee "$BUILD_COUNTER_FILE" > /dev/null 2>/dev/null || BUILD_NUM=1 GIT_SHORT=$(cd "$SCRIPT_DIR/.." && git rev-parse --short HEAD 2>/dev/null || echo "dev") # Version format: major.minor.patch-prerelease (semver) # Read version from Cargo.toml (single source of truth) BUILD_VERSION=$(grep '^version' "$SCRIPT_DIR/../../core/archipelago/Cargo.toml" 2>/dev/null | head -1 | sed 's/version = "//;s/"//' || echo "0.0.0") echo "Build #${BUILD_NUM} (${BUILD_VERSION}, commit ${GIT_SHORT})" # Architecture-dependent variables case "$ARCH" in x86_64|amd64) ARCH="x86_64" DEB_ARCH="amd64" LINUX_IMAGE_PKG="linux-image-amd64" GRUB_EFI_PKG="grub-efi-amd64" GRUB_EFI_SIGNED_PKG="grub-efi-amd64-signed" GRUB_PC_PKG="grub-pc-bin" GRUB_TARGET="x86_64-efi" GRUB_BIOS_TARGET="i386-pc" CONTAINER_PLATFORM="linux/amd64" LIB_DIR="${LIB_DIR}" ;; arm64|aarch64) ARCH="arm64" DEB_ARCH="arm64" LINUX_IMAGE_PKG="linux-image-arm64" GRUB_EFI_PKG="grub-efi-arm64" GRUB_EFI_SIGNED_PKG="grub-efi-arm64-signed" GRUB_PC_PKG="" GRUB_TARGET="arm64-efi" GRUB_BIOS_TARGET="" CONTAINER_PLATFORM="linux/arm64" LIB_DIR="aarch64-linux-gnu" ;; *) echo "❌ Unsupported architecture: $ARCH (use x86_64 or arm64)" exit 1 ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" WORK_DIR="$SCRIPT_DIR/build/auto-installer" OUTPUT_DIR="$SCRIPT_DIR/results" ROOTFS_DIR="$WORK_DIR/rootfs" INSTALLER_DIR="$WORK_DIR/installer" if [ "$UNBUNDLED" = "1" ]; then echo "╔════════════════════════════════════════════════════════════════╗" echo "║ Building Archipelago UNBUNDLED ISO (no pre-loaded apps) ║" echo "╚════════════════════════════════════════════════════════════════╝" else echo "╔════════════════════════════════════════════════════════════════╗" echo "║ Building Archipelago Auto-Installer ISO (StartOS-like) ║" echo "╚════════════════════════════════════════════════════════════════╝" fi echo "" if [ "$BUILD_FROM_SOURCE" = "1" ]; then echo "📦 Mode: Building from SOURCE CODE" elif [ "$UNBUNDLED" = "1" ]; then echo "📦 Mode: UNBUNDLED (apps downloaded on-demand from Marketplace)" echo " Server: $DEV_SERVER (backend + web UI only)" else echo "📦 Mode: Capturing LIVE SERVER state" echo " Server: $DEV_SERVER" fi echo "🏗️ Architecture: $ARCH ($DEB_ARCH)" echo "" # Check for required tools check_tools() { local missing="" local can_install=false # Check if we can auto-install (running as root on Debian/Ubuntu) if [ "$EUID" -eq 0 ] && [ -f /etc/debian_version ]; then can_install=true fi # Check for docker or podman if command -v docker >/dev/null 2>&1; then CONTAINER_CMD="docker" elif command -v podman >/dev/null 2>&1; then CONTAINER_CMD="podman" else missing="$missing docker-or-podman" fi for tool in xorriso mksquashfs; do if ! command -v $tool >/dev/null 2>&1; then missing="$missing $tool" fi done # Check for isolinux MBR (needed for hybrid USB boot) if [ ! -f /usr/lib/ISOLINUX/isohdpfx.bin ] && [ ! -f /usr/share/syslinux/isohdpfx.bin ]; then missing="$missing isolinux" fi if [ -n "$missing" ]; then echo "Missing required tools:$missing" if [ "$can_install" = true ]; then echo " Auto-installing missing dependencies..." apt-get update -qq if [[ "$missing" == *"xorriso"* ]]; then apt-get install -y xorriso fi if [[ "$missing" == *"mksquashfs"* ]]; then apt-get install -y squashfs-tools fi if [[ "$missing" == *"isolinux"* ]]; then apt-get install -y isolinux syslinux-common fi if [[ "$missing" == *"docker-or-podman"* ]]; then echo " Installing podman..." apt-get install -y podman CONTAINER_CMD="podman" fi echo " Dependencies installed successfully!" else echo " Install with: sudo apt install xorriso squashfs-tools isolinux podman" echo " Or run this script with sudo to auto-install" exit 1 fi fi # Re-check after potential installation if command -v docker >/dev/null 2>&1; then CONTAINER_CMD="docker" elif command -v podman >/dev/null 2>&1; then CONTAINER_CMD="podman" else echo "❌ Container runtime still not available after installation" exit 1 fi echo "Using container runtime: $CONTAINER_CMD" # Fix root podman D-Bus issue (sd-bus: Transport endpoint is not connected) # When running as sudo, systemd cgroup manager can't reach the user D-Bus session. if [ "$CONTAINER_CMD" = "podman" ] && [ "$(id -u)" = "0" ]; then if ! $CONTAINER_CMD run --rm debian:trixie true 2>/dev/null; then echo " Root podman D-Bus issue detected, using cgroupfs manager" CONTAINER_CMD="podman --cgroup-manager=cgroupfs" fi fi # Ensure insecure registry config for Archipelago app registries that are # intentionally served over HTTP during ISO builds. Rootless podman reads # the per-user config dir; only root can write /etc. Never fatal — the # node may already carry this config. if [[ "$CONTAINER_CMD" == podman* ]]; then if [ "$(id -u)" = "0" ]; then REGCONF_DIR="/etc/containers/registries.conf.d" else REGCONF_DIR="${HOME}/.config/containers/registries.conf.d" fi if mkdir -p "$REGCONF_DIR" 2>/dev/null && cat > "$REGCONF_DIR/archipelago.conf" 2>/dev/null <<'REGCONF' [[registry]] location = "source.archipelago-foundation.org" insecure = true REGCONF then : else echo " ⚠️ Could not write $REGCONF_DIR/archipelago.conf — assuming registry config already present" fi fi } check_tools mkdir -p "$WORK_DIR" mkdir -p "$OUTPUT_DIR" container_pull() { local image="$1" if [[ "$CONTAINER_CMD" == podman* && "$image" == source.archipelago-foundation.org/* ]]; then $CONTAINER_CMD pull --tls-verify=false --platform "$CONTAINER_PLATFORM" "$image" else $CONTAINER_CMD pull --platform "$CONTAINER_PLATFORM" "$image" fi } # ============================================================================= # STEP 1: Build complete root filesystem using Docker # ============================================================================= echo "📦 Step 1: Building root filesystem..." ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar" ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256" # The cached rootfs must be invalidated when its recipe changes: a stale # archipelago-rootfs.tar on the build machine shipped ISOs with NO # wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages # were added to the Dockerfile below — the cache condition never looked at # the recipe. Hash the rootfs-defining region of this script; any edit to it # forces a rebuild. `--rebuild` still forces one unconditionally. RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1) if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then echo " Using Docker to create Debian root filesystem..." # Create a Dockerfile for building the rootfs # The Dockerfile body is written with a QUOTED heredoc delimiter. # # It used to be unquoted, which meant the build shell performed command # substitution on the body: any backtick in a Dockerfile COMMENT was executed # on the build host and its output spliced into the Dockerfile. Six comments # did that, and one of them ran "systemctl start archipelago-fips.service" # against the build machine on every ISO build. The comment text was also # silently deleted from the generated Dockerfile. # # bash -n cannot see this class of bug — the script is syntactically perfect # either way — so quoting the delimiter is the fix, not vigilance about # backticks. tests/first-boot-secrets/run-tests.sh case 7 fails if the # delimiter is ever unquoted again, or if a substitution appears in the body. # # The whole body needs exactly four build-time values, all package names, and # they are interpolated explicitly by the printf between the two halves. cat > "$WORK_DIR/Dockerfile.rootfs" <<'DOCKERFILE_HEAD' # ─── Stage 1: Build the FIPS mesh daemon .deb at a pinned tag ──────────── # # FIPS (github.com/jmcorgan/fips) is a fast Nostr-keyed mesh routing # protocol archipelago uses as its preferred non-Tor transport. # Pinned so the shipped version is knowable: an unpinned --depth 1 clone of # main made every ISO carry whatever upstream happened to be that day. # v0.4.1 is the version fips/config.rs renders its typed config against and # the one validated in the field. Bump the two together. # The .deb is rebuilt every ISO build; Docker layer caching keeps the # incremental cost low. Failure here fails the ISO build on purpose: # we don't want to ship an ISO that silently skips FIPS. FROM rust:1-slim-bookworm AS fips-builder ENV DEBIAN_FRONTEND=noninteractive # Build deps tracked as upstream fips adds transitive native deps: # - libdbus-1-dev: libdbus-sys (observed 2026-04-19 rebuild) # - libssl-dev: openssl dependencies # - libnftnl-dev, libmnl-dev, clang, libclang-dev: rustables → # bindgen (the gateway feature enables rustables for nftables # integration). bindgen panics without libclang.so. RUN apt-get update && apt-get install -y --no-install-recommends \ git ca-certificates build-essential pkg-config dpkg-dev \ libdbus-1-dev libssl-dev \ clang libclang-dev libnftnl-dev libmnl-dev \ && rm -rf /var/lib/apt/lists/* RUN cargo install --locked cargo-deb ARG FIPS_VERSION=v0.4.1 RUN git clone --depth 1 --branch "$FIPS_VERSION" \ https://github.com/jmcorgan/fips.git /src/fips WORKDIR /src/fips # fips-gateway is gated behind the `gateway` Cargo feature (depends on # `rustables`). Without the feature, cargo doesn't build it, and # cargo deb --no-build panics hunting for target/release/fips-gateway. # Inspected upstream Cargo.toml 2026-04-19 — features.gateway = ["dep:rustables"]. RUN cargo build --release RUN cargo deb --no-build RUN cp target/debian/fips_*_amd64.deb /tmp/fips.deb # ─── Stage 2: The actual Archipelago rootfs ────────────────────────────── FROM debian:trixie ENV DEBIAN_FRONTEND=noninteractive # Preseed keyboard/console config to prevent console-setup.service failure RUN echo "keyboard-configuration keyboard-configuration/layoutcode string us" | debconf-set-selections && \ echo "keyboard-configuration keyboard-configuration/model select Generic 105-key PC" | debconf-set-selections && \ echo "console-setup console-setup/charmap47 select UTF-8" | debconf-set-selections && \ echo "console-setup console-setup/codeset47 select Uni2" | debconf-set-selections && \ echo "console-setup console-setup/fontface47 select Terminus" | debconf-set-selections && \ echo "console-setup console-setup/fontsize-fb47 select 16" | debconf-set-selections # Enable non-free-firmware repo — replace DEB822 sources with traditional format # (DEB822 sed was silently failing, so just overwrite with known-good sources.list) RUN echo "deb http://deb.debian.org/debian trixie main non-free-firmware" > /etc/apt/sources.list && \ echo "deb http://deb.debian.org/debian trixie-updates main non-free-firmware" >> /etc/apt/sources.list && \ echo "deb http://deb.debian.org/debian-security trixie-security main non-free-firmware" >> /etc/apt/sources.list && \ rm -f /etc/apt/sources.list.d/debian.sources # Install all packages we need including nginx, podman, tor, and openssl (for self-signed certs) RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends \ DOCKERFILE_HEAD # The ONLY build-time interpolation in the entire Dockerfile: the kernel and # GRUB package names, which vary by architecture and Debian suite. printf ' %s \\\n' \ "$LINUX_IMAGE_PKG" "$GRUB_EFI_PKG" "$GRUB_EFI_SIGNED_PKG" "$GRUB_PC_PKG" \ >> "$WORK_DIR/Dockerfile.rootfs" cat >> "$WORK_DIR/Dockerfile.rootfs" <<'DOCKERFILE_TAIL' systemd \ systemd-sysv \ dbus \ sudo \ network-manager \ wpasupplicant \ wireless-regdb \ iw \ rfkill \ polkitd \ openssh-server \ nginx \ avahi-daemon \ avahi-utils \ libnss-mdns \ podman \ catatonit \ uidmap \ slirp4netns \ passt \ aardvark-dns \ netavark \ nftables \ fuse-overlayfs \ tor \ python3 \ curl \ git \ vim-tiny \ nano \ ca-certificates \ openssl \ chrony \ iputils-ping \ esptool \ python3-venv \ binutils \ libpython3.13 \ locales \ console-setup \ keyboard-configuration \ cryptsetup \ cryptsetup-initramfs \ e2fsprogs \ firmware-realtek \ firmware-iwlwifi \ firmware-misc-nonfree \ firmware-linux-nonfree \ firmware-intel-graphics \ firmware-amd-graphics \ intel-microcode \ amd64-microcode \ xorg \ xdotool \ chromium \ pipewire \ pipewire-pulse \ pipewire-alsa \ wireplumber \ alsa-utils \ unclutter \ fonts-liberation \ fonts-noto-color-emoji \ xfonts-base \ plymouth \ plymouth-themes \ zstd \ socat \ python3 \ apache2-utils \ wireguard-tools \ acpid \ acpi-support-base \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Strip docs, man pages, and unused locales RUN find /usr/share/doc -depth -type f ! -name copyright -delete 2>/dev/null || true && \ find /usr/share/doc -empty -delete 2>/dev/null || true && \ rm -rf /usr/share/man /usr/share/info /usr/share/lintian /usr/share/linda && \ find /usr/share/locale -maxdepth 1 -mindepth 1 ! -name 'en_US' ! -name 'locale.alias' -exec rm -rf {} + 2>/dev/null || true # Install Tailscale from official repo RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null && \ curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list && \ apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends tailscale && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Install FIPS mesh daemon from the .deb built in stage 1. apt-get install # resolves dependencies from trixie so a cross-dist build still lands cleanly. COPY --from=fips-builder /tmp/fips.deb /tmp/fips.deb RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends /tmp/fips.deb && \ apt-get clean && rm -rf /var/lib/apt/lists/* && rm /tmp/fips.deb # Configure locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen # Create archipelago user with password "archipelago" # audio group: PipeWire runs under the lingering user manager (no logind seat # session), so udev's seat ACLs on /dev/snd never apply — group access is the # only way the kiosk's audio can open the hardware. RUN useradd -m -s /bin/bash -G sudo,dialout,audio archipelago && \ echo "archipelago:archipelago" | chpasswd && \ echo "root:archipelago" | chpasswd && \ echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago # Verify password hash was set (not locked) RUN grep -q "^archipelago:$" /etc/shadow && echo "Password set OK" || echo "WARNING: password may not be set" # Set hostname RUN echo "archipelago" > /etc/hostname # Configure SSH RUN mkdir -p /etc/ssh && \ sed -i 's/#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config || true && \ sed -i 's/#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true # Configure nginx for Archipelago RUN rm -f /etc/nginx/sites-enabled/default COPY nginx-archipelago.conf /etc/nginx/sites-available/archipelago RUN ln -sf /etc/nginx/sites-available/archipelago /etc/nginx/sites-enabled/archipelago # Install nginx snippets (PWA config, HTTPS app proxies) COPY snippets/ /etc/nginx/snippets/ # The self-signed HTTPS keypair is NOT generated here (audit F-03). # # It used to be: this layer ran "openssl req" and baked one keypair into the # shared image, which meant every node flashed from one ISO — and everyone who # downloaded the ISO — held the same TLS private key. The strip layer at the # end of this Dockerfile would delete it again anyway, so generating it here # now only creates a SECOND piece of code that can mint a TLS key with its own # accounting. There is exactly one producer of this keypair, and it is # first-boot-secrets.sh, which retries and reports. # # The ssl directory is created so that producer's staging swap has somewhere # to land. RUN mkdir -p /etc/archipelago/ssl # Fail the BUILD if the rootfs cannot generate per-device secrets. # # The one realistic way first-boot generation fails on every retry is a missing # generator binary, and that failure is deterministic, not transient — retries # and reboots will never fix it. A node in the field must not be where we # discover it. openssl and openssh-server are both in the package list above # (and openssh-server hard-depends openssh-client, which ships ssh-keygen), so # today this assertion is cheap insurance rather than a fix. It earns its place # by turning a silent fleet-wide brick into a loud build failure the first time # anyone edits that package list. RUN set -e; \ for bin in /usr/bin/openssl /usr/bin/ssh-keygen; do \ if [ ! -x "$bin" ]; then \ echo "FATAL: $bin missing or not executable in the rootfs." >&2; \ echo "first-boot-secrets.sh cannot generate per-device SSH host keys" >&2; \ echo "or the TLS keypair without it, and that failure is permanent." >&2; \ echo "Restore openssl / openssh-server in the package list above." >&2; \ exit 1; \ fi; \ done; \ echo "first-boot secret generators present: openssl, ssh-keygen" # Create archipelago systemd service COPY archipelago.service /etc/systemd/system/archipelago.service COPY archipelago-update.service /etc/systemd/system/archipelago-update.service COPY archipelago-update.timer /etc/systemd/system/archipelago-update.timer COPY archipelago-doctor.service /etc/systemd/system/archipelago-doctor.service COPY archipelago-doctor.timer /etc/systemd/system/archipelago-doctor.timer COPY archipelago-tor-helper.service /etc/systemd/system/archipelago-tor-helper.service COPY archipelago-tor-helper.path /etc/systemd/system/archipelago-tor-helper.path COPY nostr-vpn.service /etc/systemd/system/nostr-vpn.service COPY archipelago-wg.service /etc/systemd/system/archipelago-wg.service COPY archipelago-wg-address.service /etc/systemd/system/archipelago-wg-address.service COPY archipelago-fips.service /etc/systemd/system/archipelago-fips.service COPY nostr-relay.service /etc/systemd/system/nostr-relay.service COPY nostr-relay-config.toml /etc/archipelago/nostr-relay-config.toml # WireGuard kernel module auto-load on boot RUN echo "wireguard" >> /etc/modules-load.d/wireguard.conf # Copy container doctor + reconcile scripts (referenced by services and the # OTA update RPC; the reconcile systemd timer is gone as of Step 8a, but the # script stays until Step 8b/c ports all manifests — update.rs still shells # out to it during package updates). RUN mkdir -p /home/archipelago/archy/scripts/lib COPY container-doctor.sh /home/archipelago/archy/scripts/container-doctor.sh COPY reconcile-containers.sh /home/archipelago/archy/scripts/reconcile-containers.sh COPY container-specs.sh /home/archipelago/archy/scripts/container-specs.sh COPY tor-helper.sh /opt/archipelago/scripts/tor-helper.sh COPY lib/ /home/archipelago/archy/scripts/lib/ RUN chmod +x /home/archipelago/archy/scripts/*.sh /home/archipelago/archy/scripts/lib/*.sh /opt/archipelago/scripts/*.sh && \ chown -R archipelago:archipelago /home/archipelago/archy # Enable cgroup delegation for rootless podman (CPU/memory limits require this) RUN mkdir -p /etc/systemd/system/user@.service.d && \ printf '[Service]\nDelegate=cpu cpuset io memory pids\n' > /etc/systemd/system/user@.service.d/delegate.conf # Allow unprivileged ping inside rootless containers RUN printf 'net.ipv4.ping_group_range=0 2147483647\n' > /etc/sysctl.d/90-podman-ping.conf # Archipelago's web UI manages Wi-Fi through the backend service, not a local # desktop seat. Allow the dedicated system user to control NetworkManager. RUN mkdir -p /etc/polkit-1/rules.d && \ printf '%s\n' \ 'polkit.addRule(function(action, subject) {' \ ' if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {' \ ' return polkit.Result.YES;' \ ' }' \ '});' \ > /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules && \ chmod 644 /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules # Enable services RUN systemctl enable NetworkManager || true && \ systemctl enable polkit || systemctl enable polkit.service || true && \ systemctl enable ssh || true && \ systemctl enable nginx || true && \ systemctl enable avahi-daemon || true && \ systemctl enable archipelago || true && \ systemctl enable tor || true && \ systemctl enable tailscaled || true && \ systemctl enable chrony || true && \ systemctl enable archipelago-update.timer || true && \ systemctl enable archipelago-doctor.timer || true && \ systemctl enable archipelago-tor-helper.path || true && \ systemctl enable nostr-relay || true # archipelago-fips.service + archipelago-wg.service + archipelago-wg-address.service # stay installed and enabled. They all use `ConditionPathExists=` on their # respective seed-derived key files, so on a fresh pre-onboarding boot # systemd quietly skips them with no [FAILED] in the MOTD. Once the user # completes the seed onboarding flow, archipelago writes the key files, # the archipelago backend calls `systemctl start archipelago-fips.service` # (see server.rs post-onboarding auto-activate block) and the WG setup # path runs `archipelago-wg setup` directly. No masking, no user-facing # "Activate" button — install → onboard → FIPS + WG are just running. RUN systemctl enable archipelago-fips.service || true # nostr-vpn is the legacy nostr-tunnel service — deprecated in favour of # the upstream FIPS daemon. It still crash-loops on boot if left enabled # (env file doesn't exist until onboarding) so we mask it outright. # `systemctl mask` alone doesn't stick because the real .service file is # already in place — explicit rm + /dev/null symlink is what sticks. RUN rm -f /etc/systemd/system/nostr-vpn.service && \ ln -sf /dev/null /etc/systemd/system/nostr-vpn.service # Remove policy-rc.d so services can start on first boot RUN rm -f /usr/sbin/policy-rc.d # Create directories (including Cloud storage for FileBrowser) RUN mkdir -p /var/lib/archipelago/data /var/lib/archipelago/config /var/lib/archipelago/containers /var/lib/archipelago/nostr-relay /var/lib/archipelago/nostr-vpn && \ mkdir -p /etc/archipelago && \ mkdir -p /opt/archipelago/bin /opt/archipelago/scripts /opt/archipelago/web-ui && \ mkdir -p /var/lib/archipelago/data/cloud/Documents /var/lib/archipelago/data/cloud/Photos /var/lib/archipelago/data/cloud/Music /var/lib/archipelago/data/cloud/Videos /var/lib/archipelago/data/cloud/Downloads && \ cp /etc/archipelago/nostr-relay-config.toml /var/lib/archipelago/nostr-relay/config.toml && \ chown -R archipelago:archipelago /var/lib/archipelago /opt/archipelago # Persist journalctl across reboots — without /var/log/journal systemd # journal uses tmpfs and everything before the last boot is lost. We # need the full history to diagnose first-boot / install / onboarding # issues after the fact. Size cap keeps it from eating the disk, and the # explicit rate limit stops a single chatty service (e.g. a container # spamming conmon->journald during Bitcoin IBD) from drowning the journal. # Keep this byte-identical to image-recipe/configs/journald-archipelago.conf — # the backend self-heals the same file onto deployed nodes (bootstrap.rs). RUN mkdir -p /var/log/journal && \ systemd-tmpfiles --create --prefix /var/log/journal 2>/dev/null || true && \ install -d -m 0755 /etc/systemd/journald.conf.d && \ printf '[Journal]\nStorage=persistent\nSystemMaxUse=500M\nRuntimeMaxUse=100M\nForwardToSyslog=no\nRateLimitIntervalSec=30s\nRateLimitBurst=10000\n' > /etc/systemd/journald.conf.d/10-archipelago-persistent.conf # Clean up RUN apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # ─── Strip fleet-shared identity material (audit finding F-03) ────────────── # # This image is exported to a tar and extracted VERBATIM onto every disk # flashed from the resulting ISO, and the ISO is a published artefact. Anything # identity-shaped left in here is therefore held by every node AND by every # person who downloaded the ISO. # # NOTE: this heredoc is UNQUOTED, so backticks here are command substitution # and would run at build time. Never put backticks in these comments. # # The TLS keypair is no longer generated in this Dockerfile at all (see the # note where that layer used to be). What still gets baked without anyone # asking for it is Debian's openssh-server postinst, which generates # /etc/ssh/ssh_host_* at package install time — i.e. inside this container # build — plus /etc/machine-id, which systemd populates during the build and # which correlates every node flashed from one ISO. This layer removes both. # # archipelago-first-boot-secrets.service recreates all of it per device on # first boot, retrying on a timer until it succeeds. The point of removing it # HERE is to change what a generation failure costs: with the material # stripped, a failure degrades to "no key, the service refuses to start" # instead of "fleet-shared key, silently" — which is the whole of F-03. That # makes fail-closed structural rather than procedural. # # This must stay the LAST layer: anything that installs packages after it can # reintroduce host keys. The rm of the TLS keypair is kept as belt-and-braces # even though nothing in this build creates one any more — if a future layer # starts baking a cert, this catches it. Keep the /etc/archipelago/ssl # directory itself so the first-boot script's staging swap has somewhere to # land. RUN rm -f /etc/ssh/ssh_host_* && \ rm -f /etc/archipelago/ssl/archipelago.key /etc/archipelago/ssl/archipelago.crt && \ mkdir -p /etc/archipelago/ssl && \ : > /etc/machine-id && \ { [ -L /var/lib/dbus/machine-id ] || rm -f /var/lib/dbus/machine-id ; } && \ mkdir -p /opt/archipelago && \ printf 'F-03 identity strip: this rootfs was built with the identity-strip layer.\nRemoved:\n /etc/ssh/ssh_host_*\n /etc/archipelago/ssl/archipelago.key\n /etc/archipelago/ssl/archipelago.crt\nTruncated:\n /etc/machine-id\nRecreated per device by archipelago-first-boot-secrets.service on first boot.\n' > /opt/archipelago/rootfs-identity-stripped DOCKERFILE_TAIL # Copy nginx snippets for HTTPS (PWA, app proxies) if [ -d "$SCRIPT_DIR/../configs/snippets" ]; then mkdir -p "$WORK_DIR/snippets" cp "$SCRIPT_DIR/../configs/snippets/"*.conf "$WORK_DIR/snippets/" 2>/dev/null || true echo " Using nginx snippets from configs/snippets/" else mkdir -p "$WORK_DIR/snippets" echo " ⚠ No nginx snippets found, HTTPS features may not work" fi # Use nginx config from configs/ (includes app proxies for Nextcloud, Vaultwarden, etc.) if [ -f "$SCRIPT_DIR/../configs/nginx-archipelago.conf" ]; then cp "$SCRIPT_DIR/../configs/nginx-archipelago.conf" "$WORK_DIR/nginx-archipelago.conf" echo " Using nginx config from configs/nginx-archipelago.conf" else echo " ⚠ configs/nginx-archipelago.conf not found, using minimal config" cat > "$WORK_DIR/nginx-archipelago.conf" <<'NGINXCONF' server { listen 80; server_name _; root /opt/archipelago/web-ui; index index.html; location / { try_files $uri $uri/ /index.html; } location /archipelago/ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /rpc/ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_connect_timeout 300s; proxy_send_timeout 300s; proxy_read_timeout 300s; } location /ws { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400s; } } NGINXCONF fi # Copy udev rule for mesh radio stable naming if [ -f "$SCRIPT_DIR/../configs/99-mesh-radio.rules" ]; then cp "$SCRIPT_DIR/../configs/99-mesh-radio.rules" "$WORK_DIR/99-mesh-radio.rules" echo " Using 99-mesh-radio.rules from configs/" fi # Copy update service and timer if [ -f "$SCRIPT_DIR/../configs/archipelago-update.service" ]; then cp "$SCRIPT_DIR/../configs/archipelago-update.service" "$WORK_DIR/archipelago-update.service" cp "$SCRIPT_DIR/../configs/archipelago-update.timer" "$WORK_DIR/archipelago-update.timer" echo " Using archipelago-update.service + timer from configs/" fi # Copy container doctor timer + reconcile script (the reconcile systemd # timer is gone as of Step 8a — BootReconciler replaces it — but the # reconcile-containers.sh script stays, invoked by the OTA update RPC # until Step 8b/c ports all manifests to the Rust orchestrator). if [ -f "$SCRIPT_DIR/../configs/archipelago-doctor.service" ]; then cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$WORK_DIR/archipelago-doctor.service" cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$WORK_DIR/archipelago-doctor.timer" # Copy the actual scripts the services / update RPC reference for s in container-doctor.sh reconcile-containers.sh container-specs.sh tor-helper.sh; do if [ -f "$SCRIPT_DIR/../../scripts/$s" ]; then cp "$SCRIPT_DIR/../../scripts/$s" "$WORK_DIR/$s" fi done # Copy shared script library (mem_limit etc.) if [ -d "$SCRIPT_DIR/../../scripts/lib" ]; then mkdir -p "$WORK_DIR/lib" cp "$SCRIPT_DIR/../../scripts/lib/"*.sh "$WORK_DIR/lib/" 2>/dev/null || true fi echo " Using container doctor timer from configs/" fi # Copy Tor helper path-activated service (allows backend to manage Tor as non-root) if [ -f "$SCRIPT_DIR/../configs/archipelago-tor-helper.service" ]; then cp "$SCRIPT_DIR/../configs/archipelago-tor-helper.service" "$WORK_DIR/archipelago-tor-helper.service" cp "$SCRIPT_DIR/../configs/archipelago-tor-helper.path" "$WORK_DIR/archipelago-tor-helper.path" echo " Using tor-helper path unit from configs/" fi # Copy NostrVPN system service (native mesh VPN, not a container) if [ -f "$SCRIPT_DIR/../configs/nostr-vpn.service" ]; then cp "$SCRIPT_DIR/../configs/nostr-vpn.service" "$WORK_DIR/nostr-vpn.service" echo " Using nostr-vpn.service from configs/" fi if [ -f "$SCRIPT_DIR/../configs/archipelago-wg.service" ]; then cp "$SCRIPT_DIR/../configs/archipelago-wg.service" "$WORK_DIR/archipelago-wg.service" echo " Using archipelago-wg.service from configs/" fi if [ -f "$SCRIPT_DIR/../configs/archipelago-wg-address.service" ]; then cp "$SCRIPT_DIR/../configs/archipelago-wg-address.service" "$WORK_DIR/archipelago-wg-address.service" echo " Using archipelago-wg-address.service from configs/" fi if [ -f "$SCRIPT_DIR/../configs/archipelago-fips.service" ]; then cp "$SCRIPT_DIR/../configs/archipelago-fips.service" "$WORK_DIR/archipelago-fips.service" echo " Using archipelago-fips.service from configs/" fi # Copy private Nostr relay service (native, for NostrVPN signaling) if [ -f "$SCRIPT_DIR/../configs/nostr-relay.service" ]; then cp "$SCRIPT_DIR/../configs/nostr-relay.service" "$WORK_DIR/nostr-relay.service" echo " Using nostr-relay.service from configs/" fi if [ -f "$SCRIPT_DIR/../configs/nostr-relay-config.toml" ]; then cp "$SCRIPT_DIR/../configs/nostr-relay-config.toml" "$WORK_DIR/nostr-relay-config.toml" echo " Using nostr-relay-config.toml from configs/" fi # Copy WireGuard helper script (privileged peer management) if [ -f "$SCRIPT_DIR/../../scripts/archipelago-wg" ]; then cp "$SCRIPT_DIR/../../scripts/archipelago-wg" "$WORK_DIR/archipelago-wg" echo " Using archipelago-wg helper from scripts/" fi # Use archipelago.service from configs/ (User=root for Podman container access) if [ -f "$SCRIPT_DIR/../configs/archipelago.service" ]; then cp "$SCRIPT_DIR/../configs/archipelago.service" "$WORK_DIR/archipelago.service" echo " Using archipelago.service from configs/" else cat > "$WORK_DIR/archipelago.service" <<'SYSTEMDSERVICE' [Unit] Description=Archipelago Backend After=network-online.target archipelago-setup-tor.service Wants=network-online.target [Service] Type=simple User=archipelago Environment="ARCHIPELAGO_BIND=127.0.0.1:5678" Environment="XDG_RUNTIME_DIR=/run/user/1000" ExecStartPre=/bin/bash -c 'mkdir -p /run/user/1000 && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000' ExecStart=/usr/local/bin/archipelago Restart=on-failure RestartSec=5 ProtectHome=no [Install] WantedBy=multi-user.target SYSTEMDSERVICE fi echo " Building $CONTAINER_CMD image (this may take a few minutes)..." $CONTAINER_CMD build --no-cache --platform $CONTAINER_PLATFORM -t archipelago-rootfs -f "$WORK_DIR/Dockerfile.rootfs" "$WORK_DIR" echo " Exporting filesystem..." $CONTAINER_CMD rm -f archipelago-rootfs-tmp 2>/dev/null || true $CONTAINER_CMD create --platform $CONTAINER_PLATFORM --name archipelago-rootfs-tmp archipelago-rootfs $CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR" $CONTAINER_CMD rm archipelago-rootfs-tmp echo "$RECIPE_HASH" > "$ROOTFS_STAMP" echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)" else echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)" fi # ============================================================================= # STEP 2: Build minimal installer environment (replaces Debian Live) # ============================================================================= echo "" echo "Step 2: Building minimal installer environment via debootstrap..." INSTALLER_ISO="$WORK_DIR/installer-iso" INSTALLER_SQUASHFS="$WORK_DIR/installer-squashfs" rm -rf "$INSTALLER_ISO" "$INSTALLER_SQUASHFS" mkdir -p "$INSTALLER_ISO/live" "$INSTALLER_ISO/archipelago" mkdir -p "$INSTALLER_ISO/boot/grub" "$INSTALLER_ISO/isolinux" mkdir -p "$INSTALLER_ISO/EFI/BOOT" # Build the installer filesystem inside a container # This creates: vmlinuz, initrd.img, filesystem.squashfs # NOTE: the installer-env script is written to a file and bind-mounted into the # container rather than passed via `bash -c '...'`. On some hosts, the inline # form somehow interferes with debootstrap's dpkg-deb|tar extraction (repro'd # on this box: bash -c fails at "Extracting apt...", bash /script.sh succeeds). _INSTALLER_ENV_SCRIPT="$WORK_DIR/_installer-env.sh" cat > "$_INSTALLER_ENV_SCRIPT" <<'INSTALLER_ENV_EOF' set -e apt-get update -qq apt-get install -y -qq debootstrap squashfs-tools initramfs-tools dosfstools mtools \ grub-efi-amd64-bin grub-pc-bin grub-common isolinux syslinux-common echo " [container] Running debootstrap --variant=minbase..." # ifupdown + isc-dhcp-client added because live-boot's /init writes # /etc/network/interfaces on the target — without ifupdown, /etc/network/ # doesn't exist and the initramfs throws a non-fatal but noisy # "can't create /root/etc/network/interfaces: nonexistent directory". debootstrap --variant=minbase --arch=${DEB_ARCH} \ --include=systemd,systemd-sysv,udev,dbus,bash,coreutils,mount,util-linux,\ kmod,procps,iproute2,ca-certificates,gdisk,\ cryptsetup,cryptsetup-initramfs,parted,dosfstools,e2fsprogs,\ linux-image-${DEB_ARCH},grub-efi-${DEB_ARCH},grub-pc-bin,\ ifupdown,isc-dhcp-client,\ wpasupplicant,wireless-regdb,iw,rfkill,\ pciutils,usbutils,less,nano \ trixie /installer http://deb.debian.org/debian # Install live-boot via chroot — debootstrap minbase resolver cannot handle it. # The chroot approach works (confirmed in CI run 90) — just needs proc/sys/dev mounts. echo " [container] Installing live-boot for squashfs root support..." cp /etc/resolv.conf /installer/etc/resolv.conf 2>/dev/null || true mount --bind /proc /installer/proc mount --bind /sys /installer/sys mount --bind /dev /installer/dev chroot /installer apt-get update -qq chroot /installer apt-get -y -qq full-upgrade chroot /installer apt-get install -y --no-install-recommends live-boot live-boot-initramfs-tools chroot /installer apt-get clean umount /installer/dev 2>/dev/null || true umount /installer/sys 2>/dev/null || true umount /installer/proc 2>/dev/null || true # Verify live-boot hooks are in place (scripts/live is a FILE not a directory) if [ -e /installer/usr/share/initramfs-tools/scripts/live ]; then echo " [container] live-boot initramfs hooks: OK" else echo " [container] FATAL: live-boot hooks not found after install!" ls -la /installer/usr/share/initramfs-tools/scripts/ 2>/dev/null exit 1 fi echo " [container] Configuring installer environment..." # Set hostname echo "archipelago-installer" > /installer/etc/hostname # Set root password echo "root:archipelago" | chroot /installer chpasswd # Auto-login on tty1 mkdir -p /installer/etc/systemd/system/getty@tty1.service.d cat > /installer/etc/systemd/system/getty@tty1.service.d/autologin.conf < /installer/etc/profile.d/z99-archipelago-installer.sh </dev/null || exit 0 fi export INSTALLER_STARTED=1 sleep 1 clear echo "" echo -e "\033[38;5;208m ▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█\033[0m" echo -e "\033[38;5;208m █▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █\033[0m" echo -e "\033[38;5;208m ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀\033[0m" echo -e " \033[38;5;130mbitcoin node os\033[0m" echo "" BOOT_MEDIA="" for dev in /run/live/medium /lib/live/mount/medium /run/archiso /cdrom /media/cdrom /mnt/iso; do if [ -f "\$dev/archipelago/auto-install.sh" ]; then BOOT_MEDIA="\$dev" break fi done # If standard mount points failed, actively find and mount the boot device if [ -z "\$BOOT_MEDIA" ]; then echo -e " \033[37mSearching for boot device...\033[0m" mkdir -p /run/archiso 2>/dev/null for blk in /dev/sr0 /dev/sd[a-z] /dev/sd[a-z][0-9] /dev/nvme[0-9]n[0-9]p[0-9]; do [ -b "\$blk" ] || continue mount -o ro "\$blk" /run/archiso 2>/dev/null || continue if [ -f /run/archiso/archipelago/auto-install.sh ]; then BOOT_MEDIA="/run/archiso" break fi umount /run/archiso 2>/dev/null done fi if [ -n "\$BOOT_MEDIA" ]; then echo -e " \033[37mFound installer at: \$BOOT_MEDIA\033[0m" echo "" echo -e " Press Enter to install | \033[1;37mCtrl+C\033[0m for shell" read -s bash "\$BOOT_MEDIA/archipelago/auto-install.sh" else echo -e " \033[37mInstaller not found on boot media.\033[0m" echo "" echo -e " \033[37mDebug info:\033[0m" ls -la /run/live/ 2>/dev/null || echo " /run/live/ does not exist" mount | grep -E "iso9660|squashfs|overlay" 2>/dev/null echo "" echo -e " \033[37mTry: mount /dev/sdX /mnt/iso && bash /mnt/iso/archipelago/auto-install.sh\033[0m" echo "" fi PROFILE chmod +x /installer/etc/profile.d/z99-archipelago-installer.sh # Custom initramfs hook: mount ISO boot media at /run/archiso mkdir -p /installer/etc/initramfs-tools/hooks cat > /installer/etc/initramfs-tools/hooks/archipelago </dev/null || true copy_exec /sbin/blkid manual_add_modules iso9660 vfat squashfs overlay HOOK chmod +x /installer/etc/initramfs-tools/hooks/archipelago mkdir -p /installer/etc/initramfs-tools/scripts/local-bottom cat > /installer/etc/initramfs-tools/scripts/local-bottom/archipelago-mount </dev/null || continue mount -o ro "\$dev" /run/archiso 2>/dev/null || continue if [ -d /run/archiso/archipelago ]; then log_end_msg 0 echo "Found Archipelago media on \$dev" exit 0 fi umount /run/archiso 2>/dev/null || true done log_end_msg 1 echo "Archipelago boot media not found (will retry from userspace)" INITSCRIPT chmod +x /installer/etc/initramfs-tools/scripts/local-bottom/archipelago-mount # Strip docs and man pages from installer rm -rf /installer/usr/share/man/* /installer/usr/share/doc/* rm -rf /installer/var/lib/apt/lists/* /installer/var/cache/apt/* # Extract kernel KVER=$(ls /installer/lib/modules/ | sort -V | tail -1) echo " [container] Kernel version: $KVER" cp /installer/boot/vmlinuz-$KVER /output/vmlinuz # Mount virtual filesystems for proper initramfs generation mount --bind /proc /installer/proc mount --bind /sys /installer/sys mount --bind /dev /installer/dev # Build initramfs with live-boot hooks + our custom hooks chroot /installer update-initramfs -c -k $KVER cp /installer/boot/initrd.img-$KVER /output/initrd.img # Cleanup mounts umount /installer/dev 2>/dev/null || true umount /installer/sys 2>/dev/null || true umount /installer/proc 2>/dev/null || true # Create squashfs echo " [container] Creating installer squashfs..." mksquashfs /installer /output/filesystem.squashfs -comp xz -Xbcj x86 -noappend -quiet # Build GRUB EFI image with embedded bootstrap config (grub-mkstandalone) echo " [container] Building GRUB EFI image..." cat > /tmp/grub-embed.cfg </dev/null mkfs.vfat /output/efi.img >/dev/null mmd -i /output/efi.img ::/EFI ::/EFI/BOOT mcopy -i /output/efi.img /output/BOOTX64.EFI ::/EFI/BOOT/BOOTX64.EFI # Copy ISOLINUX files for legacy BIOS boot cp /usr/lib/ISOLINUX/isolinux.bin /output/isolinux.bin cp /usr/lib/syslinux/modules/bios/ldlinux.c32 /output/ldlinux.c32 cp /usr/lib/syslinux/modules/bios/menu.c32 /output/menu.c32 2>/dev/null || true cp /usr/lib/syslinux/modules/bios/vesamenu.c32 /output/vesamenu.c32 2>/dev/null || true cp /usr/lib/syslinux/modules/bios/libutil.c32 /output/libutil.c32 2>/dev/null || true cp /usr/lib/syslinux/modules/bios/libcom32.c32 /output/libcom32.c32 2>/dev/null || true cp /usr/lib/ISOLINUX/isohdpfx.bin /output/isohdpfx.bin # Generate GRUB fonts for theme echo " [container] Generating GRUB fonts..." apt-get install -y -qq fonts-dejavu-core grub-common >/dev/null 2>&1 mkdir -p /output/grub-fonts grub-mkfont -s 12 -o /output/grub-fonts/dejavu_12.pf2 /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf grub-mkfont -s 14 -o /output/grub-fonts/dejavu_14.pf2 /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf grub-mkfont -s 16 -o /output/grub-fonts/dejavu_16.pf2 /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf grub-mkfont -s 24 -o /output/grub-fonts/dejavu_24.pf2 /usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf echo " [container] Done!" INSTALLER_ENV_EOF $CONTAINER_CMD run --rm --privileged --platform $CONTAINER_PLATFORM \ -v "$WORK_DIR:/output" \ -v "$_INSTALLER_ENV_SCRIPT:/installer-env.sh:ro" \ -e DEB_ARCH="$DEB_ARCH" \ -e LIB_DIR="$LIB_DIR" \ debian:trixie bash /installer-env.sh # Verify artifacts for artifact in vmlinuz initrd.img filesystem.squashfs BOOTX64.EFI efi.img isolinux.bin isohdpfx.bin; do if [ ! -f "$WORK_DIR/$artifact" ]; then echo " FATAL: Missing build artifact: $artifact" exit 1 fi done # Place artifacts into ISO directory structure cp "$WORK_DIR/vmlinuz" "$INSTALLER_ISO/live/vmlinuz" cp "$WORK_DIR/initrd.img" "$INSTALLER_ISO/live/initrd.img" cp "$WORK_DIR/filesystem.squashfs" "$INSTALLER_ISO/live/filesystem.squashfs" cp "$WORK_DIR/BOOTX64.EFI" "$INSTALLER_ISO/EFI/BOOT/BOOTX64.EFI" cp "$WORK_DIR/efi.img" "$INSTALLER_ISO/boot/grub/efi.img" cp "$WORK_DIR/isolinux.bin" "$INSTALLER_ISO/isolinux/isolinux.bin" cp "$WORK_DIR/ldlinux.c32" "$INSTALLER_ISO/isolinux/ldlinux.c32" cp "$WORK_DIR/menu.c32" "$INSTALLER_ISO/isolinux/menu.c32" 2>/dev/null || true cp "$WORK_DIR/vesamenu.c32" "$INSTALLER_ISO/isolinux/vesamenu.c32" 2>/dev/null || true cp "$WORK_DIR/libutil.c32" "$INSTALLER_ISO/isolinux/libutil.c32" 2>/dev/null || true cp "$WORK_DIR/libcom32.c32" "$INSTALLER_ISO/isolinux/libcom32.c32" 2>/dev/null || true # Install GRUB theme THEME_SRC="$SCRIPT_DIR/branding/grub-theme" THEME_DST="$INSTALLER_ISO/boot/grub/themes/archipelago" mkdir -p "$THEME_DST" if [ -f "$THEME_SRC/theme.txt" ]; then cp "$THEME_SRC/theme.txt" "$THEME_DST/" echo " Installed GRUB theme from branding/grub-theme/" fi # Install generated fonts if [ -d "$WORK_DIR/grub-fonts" ]; then cp "$WORK_DIR/grub-fonts/"*.pf2 "$THEME_DST/" # Also copy unicode font for GRUB to load cp "$WORK_DIR/grub-fonts/dejavu_16.pf2" "$INSTALLER_ISO/boot/grub/font.pf2" fi # Copy GRUB background image (static asset or generate if missing) GRUB_BG="$SCRIPT_DIR/branding/grub-theme/background.png" if [ -f "$GRUB_BG" ]; then cp "$GRUB_BG" "$THEME_DST/background.png" echo " Installed GRUB background" elif [ -f "$SCRIPT_DIR/branding/generate-grub-background.py" ]; then echo " Generating GRUB background..." python3 "$SCRIPT_DIR/branding/generate-grub-background.py" "$THEME_DST/background.png" 2>/dev/null || \ echo " WARNING: Could not generate GRUB background" fi echo " Installer squashfs: $(du -h "$INSTALLER_ISO/live/filesystem.squashfs" | cut -f1)" echo " Kernel: $(du -h "$INSTALLER_ISO/live/vmlinuz" | cut -f1)" echo " Initrd: $(du -h "$INSTALLER_ISO/live/initrd.img" | cut -f1)" echo " Step 2 complete (custom minimal base, no Debian Live)" # ============================================================================= # STEP 3: Add Archipelago components # ============================================================================= echo "" echo "📦 Step 3: Adding Archipelago components..." ARCH_DIR="$INSTALLER_ISO/archipelago" mkdir -p "$ARCH_DIR" mkdir -p "$ARCH_DIR/bin" mkdir -p "$ARCH_DIR/scripts" # netavark + aardvark-dns are installed in the rootfs via Dockerfile.rootfs (Debian 13 packages). # Do NOT copy from the build host — the host may run a different glibc version. echo " netavark + aardvark-dns: included in rootfs (Debian 13 packages)" # Copy the pre-built rootfs echo " Including root filesystem..." cp "$ROOTFS_TAR" "$ARCH_DIR/rootfs.tar" # Ship the canonical systemd unit on the ISO. The rootfs tar is a cached # artifact that can predate unit fixes (B17: RequiresMountsFor on the data # volume — without it fresh installs boot-loop "[FAILED]" until the LUKS # mount lands), so the installer overwrites the rootfs copy with this one. mkdir -p "$ARCH_DIR/configs" cp "$SCRIPT_DIR/../configs/archipelago.service" "$ARCH_DIR/configs/archipelago.service" echo " Using archipelago.service from configs/ (installer overrides rootfs copy)" # Capture backend binary from live server if [ "$BUILD_FROM_SOURCE" = "1" ]; then echo " Building backend binary from source..." else echo " Capturing backend binary from live server..." fi # Try to get backend binary: local release build → local install → remote → container build BACKEND_CAPTURED=0 # The captured binary MUST report the same version as the checked-out # core/archipelago/Cargo.toml, otherwise we're shipping a stale binary # from an earlier version bump (which is what happened with the 14:40 # ISO — it grabbed an Apr-18 1.4.0 binary and the fleet rejected the # fips.yaml it wrote out on Activate). The expected version is the one # compiled into this build run. EXPECTED_VERSION="$(grep '^version' "$(cd "$SCRIPT_DIR/../.." && pwd)/core/archipelago/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/')" echo " Expected backend version (from Cargo.toml): $EXPECTED_VERSION" verify_backend_version() { local bin="$1" # CARGO_PKG_VERSION is compiled into the binary as a string literal. # `strings` output concatenates adjacent printable bytes, so the # version rarely sits on its own line — a fixed-string substring # match is the right tool. The version is specific enough (e.g. # "1.5.0-alpha") that accidental collisions with unrelated data # are vanishingly unlikely. if strings "$bin" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then echo " ✅ Version match: binary contains $EXPECTED_VERSION" return 0 fi echo " ⚠️ Captured binary does NOT contain expected version $EXPECTED_VERSION — it is stale" return 1 } # Check for local release binary first (works for both BUILD_FROM_SOURCE and normal mode) LOCAL_RELEASE="$(cd "$SCRIPT_DIR/../.." && pwd)/core/target/release/archipelago" if [ -f "$LOCAL_RELEASE" ]; then if verify_backend_version "$LOCAL_RELEASE"; then cp "$LOCAL_RELEASE" "$ARCH_DIR/bin/archipelago" chmod +x "$ARCH_DIR/bin/archipelago" echo " ✅ Backend from local release build ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))" BACKEND_CAPTURED=1 else echo " Skipping stale local release binary; trying next source" fi fi if [ "$BACKEND_CAPTURED" = "0" ] && [ "$BUILD_FROM_SOURCE" != "1" ]; then # Direct copy from ARCHIPELAGO_BIN env or local install BIN="${ARCHIPELAGO_BIN:-/usr/local/bin/archipelago}" if [ -f "$BIN" ] && verify_backend_version "$BIN"; then cp "$BIN" "$ARCH_DIR/bin/archipelago" chmod +x "$ARCH_DIR/bin/archipelago" echo " ✅ Backend captured from local system ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))" BACKEND_CAPTURED=1 fi # Remote copy via SCP if local failed if [ "$BACKEND_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then if scp "$DEV_SERVER:/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago" 2>/dev/null && verify_backend_version "$ARCH_DIR/bin/archipelago"; then chmod +x "$ARCH_DIR/bin/archipelago" echo " ✅ Backend captured from remote server ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))" BACKEND_CAPTURED=1 else rm -f "$ARCH_DIR/bin/archipelago" fi fi fi # Bundle the Reticulum RNode daemon alongside the backend. install-to-disk # copies everything in archipelago/bin/ to /usr/local/bin, and the mesh # listener spawns /usr/local/bin/archy-reticulum-daemon for RNode radios — # a node imaged without it can never connect a Reticulum stick # (a test node, 2026-07-22: silent connect failures until hand-copied). RETICULUM_DAEMON="${ARCHY_RETICULUM_DAEMON:-/usr/local/bin/archy-reticulum-daemon}" if [ -f "$RETICULUM_DAEMON" ]; then cp "$RETICULUM_DAEMON" "$ARCH_DIR/bin/archy-reticulum-daemon" chmod +x "$ARCH_DIR/bin/archy-reticulum-daemon" echo " ✅ Reticulum daemon bundled ($(du -h "$ARCH_DIR/bin/archy-reticulum-daemon" | cut -f1))" else echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded" fi # archy-rnodeconf drives the in-app "Flash LoRa" flow for RNode firmware # (mesh/flash.rs spawns /usr/local/bin/archy-rnodeconf --autoinstall). A node # imaged without it fails every RNode flash with "No such file or directory" # (a test node, 2026-07-29, v1.7.117). RNODECONF="${ARCHY_RNODECONF:-/usr/local/bin/archy-rnodeconf}" if [ -f "$RNODECONF" ]; then cp "$RNODECONF" "$ARCH_DIR/bin/archy-rnodeconf" chmod +x "$ARCH_DIR/bin/archy-rnodeconf" echo " ✅ rnodeconf bundled ($(du -h "$ARCH_DIR/bin/archy-rnodeconf" | cut -f1))" else echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded" fi if [ "$BACKEND_CAPTURED" = "0" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then echo " ⚠️ Could not capture from live server, building from source..." fi BACKEND_DOCKERFILE="$WORK_DIR/Dockerfile.backend" cat > "$BACKEND_DOCKERFILE" <<'BACKENDFILE' FROM rust:1.93-trixie as builder WORKDIR /build COPY core ./core COPY scripts ./scripts COPY image-recipe/configs ./image-recipe/configs RUN cd core && cargo build --release --bin archipelago BACKENDFILE BACKEND_IMAGE="localhost/archipelago-backend:iso" if $CONTAINER_CMD build --platform $CONTAINER_PLATFORM -t "$BACKEND_IMAGE" -f "$BACKEND_DOCKERFILE" "$SCRIPT_DIR/../.."; then echo " Extracting backend binary..." BACKEND_CONTAINER=$($CONTAINER_CMD create --platform $CONTAINER_PLATFORM "$BACKEND_IMAGE") $CONTAINER_CMD cp "$BACKEND_CONTAINER:/build/core/target/release/archipelago" "$ARCH_DIR/bin/" && \ echo " ✅ Backend binary built ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))" $CONTAINER_CMD rm "$BACKEND_CONTAINER" else echo " ❌ Backend build failed and server capture failed" exit 1 fi fi # NostrVPN (the native `nvpn` mesh-VPN daemon) has been removed from the # product — the active VPN path is WireGuard/Tailscale (see core vpn.rs). Its # service is already masked below (ln -sf /dev/null nostr-vpn.service) and the # binary is never spawned at runtime, so we no longer extract it. The # nostr-vpn image was deleted from the registry, which is why hard-requiring # it here bricked the build. Intentionally left out; do not re-add without # restoring the daemon. # Extract nostr-rs-relay binary from container image (native system service for VPN signaling) echo " Extracting nostr-rs-relay binary..." RELAY_IMAGE="$($CONTAINER_CMD images -q source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)" if [ -z "$RELAY_IMAGE" ]; then $CONTAINER_CMD pull source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true fi RELAY_CONTAINER=$($CONTAINER_CMD create source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true if [ -n "$RELAY_CONTAINER" ]; then # The relay image builds to its WORKDIR /usr/src/app and execs # ./nostr-rs-relay from there (not /usr/local/bin — that path was from an # older image build and silently broke extraction once the image was # rebuilt to the standard layout). $CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/src/app/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \ chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \ echo " ✅ nostr-rs-relay binary extracted ($(du -h "$ARCH_DIR/bin/nostr-rs-relay" | cut -f1))" $CONTAINER_CMD rm "$RELAY_CONTAINER" 2>/dev/null || true else echo " ⚠ nostr-rs-relay image not available — relay binary will be missing" fi # A missing nostr-rs-relay used to be a warning, and the resulting ISO shipped # an enabled nostr-relay unit that crash-looped on every install. Refuse to # produce that ISO unless explicitly overridden. (nvpn is intentionally no # longer required — NostrVPN was removed; see the note above.) MISSING_VPN_BINARIES="" [ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay" if [ -n "$MISSING_VPN_BINARIES" ]; then if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)" else echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES" echo " The registry (source.archipelago-foundation.org) must be reachable and hold the images," echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling." exit 1 fi fi # Copy WireGuard helper script if [ -f "$WORK_DIR/archipelago-wg" ]; then cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg" chmod +x "$ARCH_DIR/bin/archipelago-wg" echo " ✅ WireGuard helper script included" fi # Copy NostrVPN UI dashboard for nginx serving if [ -d "$SCRIPT_DIR/../../docker/nostr-vpn-ui" ]; then mkdir -p "$ARCH_DIR/web-ui/nostr-vpn" cp "$SCRIPT_DIR/../../docker/nostr-vpn-ui/index.html" "$ARCH_DIR/web-ui/nostr-vpn/" echo " ✅ NostrVPN UI dashboard included" fi # Capture web UI from live server if [ "$BUILD_FROM_SOURCE" = "1" ]; then echo " Building web UI from source..." else echo " Capturing web UI from live server..." fi mkdir -p "$ARCH_DIR/web-ui" # Try to get from live server first (unless BUILD_FROM_SOURCE=1) WEBUI_CAPTURED=0 if [ "$BUILD_FROM_SOURCE" != "1" ]; then # Direct copy from local filesystem (when running on target with sudo) if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/" echo " ✅ Web UI captured from local system ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))" WEBUI_CAPTURED=1 fi # Remote copy via rsync if local failed if [ "$WEBUI_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then if rsync -az "$DEV_SERVER:/opt/archipelago/web-ui/" "$ARCH_DIR/web-ui/" 2>/dev/null && [ "$(ls -A "$ARCH_DIR/web-ui")" ]; then echo " ✅ Web UI captured from remote server ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))" WEBUI_CAPTURED=1 fi fi fi if [ "$WEBUI_CAPTURED" = "0" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then echo " ⚠️ Could not capture from live server, building from source..." fi cd "$SCRIPT_DIR/../../neode-ui" echo " Installing frontend dependencies..." npm ci --prefer-offline 2>&1 | tail -3 if npm run build 2>&1 | tail -5; then if [ -d "$SCRIPT_DIR/../../web/dist/neode-ui" ]; then echo " Including web UI from web/dist/neode-ui..." cp -r "$SCRIPT_DIR/../../web/dist/neode-ui/"* "$ARCH_DIR/web-ui/" echo " ✅ Web UI built ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))" fi else echo " ⚠️ Web UI build failed" # Try to use existing build if [ -d "$SCRIPT_DIR/../../web/dist/neode-ui" ]; then echo " Using existing web UI build..." cp -r "$SCRIPT_DIR/../../web/dist/neode-ui/"* "$ARCH_DIR/web-ui/" elif [ -d "$SCRIPT_DIR/../../neode-ui/dist" ]; then echo " Using neode-ui/dist..." cp -r "$SCRIPT_DIR/../../neode-ui/dist/"* "$ARCH_DIR/web-ui/" else echo " ❌ No web UI available" exit 1 fi fi cd "$SCRIPT_DIR" fi # Include AIUI web app (Claude chat interface) AIUI_INCLUDED=0 # Search multiple locations for a pre-built AIUI app. # demo/aiui is the canonical AIUI bundle checked into the repo and is # tried first so ISO builds on a fresh clone work without needing any # external AIUI checkout. for AIUI_DIR in \ "$SCRIPT_DIR/../../demo/aiui" \ "$SCRIPT_DIR/../../AIUI/packages/app/dist" \ "$HOME/AIUI/packages/app/dist" \ "/home/archipelago/AIUI/packages/app/dist" \ "/opt/archipelago/web-ui/aiui" \ "/home/archipelago/archy/AIUI/packages/app/dist"; do if [ -d "$AIUI_DIR" ] && [ -f "$AIUI_DIR/index.html" ]; then echo " Including AIUI from $AIUI_DIR..." mkdir -p "$ARCH_DIR/web-ui/aiui" # Use rsync to handle same-file (CI workspace == /opt/archipelago) gracefully if command -v rsync >/dev/null 2>&1; then rsync -a "$AIUI_DIR/" "$ARCH_DIR/web-ui/aiui/" else cp -r "$AIUI_DIR/"* "$ARCH_DIR/web-ui/aiui/" 2>/dev/null || true fi echo " ✅ AIUI included ($(du -sh "$ARCH_DIR/web-ui/aiui" | cut -f1))" AIUI_INCLUDED=1 break fi done if [ "$AIUI_INCLUDED" = "0" ]; then echo " ⚠️ AIUI not found — build it first:" echo " cd ~/AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build" echo " Searched: demo/aiui, ~/AIUI, /home/archipelago/AIUI, /opt/archipelago/web-ui/aiui" fi # Copy app manifests if [ -d "$SCRIPT_DIR/../../apps" ]; then echo " Including app manifests..." cp -r "$SCRIPT_DIR/../../apps" "$ARCH_DIR/" fi # Copy Plymouth theme files for installation on target PLYMOUTH_SRC="$SCRIPT_DIR/branding/plymouth-theme" if [ -d "$PLYMOUTH_SRC" ]; then mkdir -p "$ARCH_DIR/plymouth-theme" cp "$PLYMOUTH_SRC/"* "$ARCH_DIR/plymouth-theme/" echo " Included Plymouth theme" fi # ============================================================================= # STEP 3b: Bundle container images for offline installation # ============================================================================= echo "" if [ "$UNBUNDLED" = "1" ]; then echo "📦 Step 3b: Bundling core containers only (UNBUNDLED mode)" echo " Optional apps will be downloaded on-demand from the Marketplace after install." # Marker file: first-boot-containers.sh checks this to skip app creation touch "$ARCH_DIR/.unbundled" IMAGES_DIR="$ARCH_DIR/container-images" # Clean stale images from previous builds (e.g. bundled build tars leaking into unbundled) rm -rf "$IMAGES_DIR" mkdir -p "$IMAGES_DIR" # Core baseline apps created by first-boot-containers.sh even in # unbundled mode — their images must ride on the ISO so a fresh install # works with no internet: FileBrowser (Cloud file manager) and fmcd # (fedimint-clientd, ecash/sats out of the box). # Shipped zstd-compressed: podman load auto-detects compression, and an # uncompressed fmcd.tar alone added ~220MB to the ISO (RC9 size regression). CORE_BUNDLE=" ${FILEBROWSER_IMAGE} filebrowser.tar.zst ${FMCD_IMAGE} fmcd.tar.zst " echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do [ -n "$CORE_IMAGE" ] || continue if [ -f "$IMAGES_DIR/$CORE_FILE" ]; then echo " ✅ Using cached: $CORE_FILE" else echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..." if container_pull "$CORE_IMAGE"; then RAW_TAR="$IMAGES_DIR/${CORE_FILE%.zst}" if $CONTAINER_CMD save "$CORE_IMAGE" -o "$RAW_TAR" 2>/dev/null && \ zstd -q -T0 -15 --rm "$RAW_TAR" -o "$IMAGES_DIR/$CORE_FILE"; then echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" else rm -f "$RAW_TAR" "$IMAGES_DIR/$CORE_FILE" echo " ⚠️ Failed to save $CORE_IMAGE" fi else echo " ⚠️ Failed to pull $CORE_IMAGE — baseline app won't work offline" fi fi done else echo "📦 Step 3b: Bundling container images for offline use..." IMAGES_DIR="$ARCH_DIR/container-images" mkdir -p "$IMAGES_DIR" # When DEV_SERVER is set (and not localhost), try to capture images from live server # so the ISO includes the same set as the dev server (including custom UIs: bitcoin-ui, lnd-ui). IMAGES_CAPTURED_FROM_SERVER=0 if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then echo " Capturing container images from live server ($DEV_SERVER)..." # Patterns match against `podman images` repository names (not container names) CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server vaultwarden searxng mariadb valkey nginx-alpine portainer nginx-proxy-manager adguard" REMOTE_TMP="/tmp/archipelago-image-capture-$$" SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true for p in $SAVED_LIST; do if [ -n "$p" ] && scp "$DEV_SERVER:$REMOTE_TMP/$p.tar" "$IMAGES_DIR/$p.tar" 2>/dev/null; then echo " ✅ Captured from server: $p.tar" IMAGES_CAPTURED_FROM_SERVER=1 fi done ssh "$DEV_SERVER" "rm -rf $REMOTE_TMP" 2>/dev/null || true if [ "$IMAGES_CAPTURED_FROM_SERVER" = "0" ]; then echo " ⚠️ No images captured from server, will use registry pull fallback" fi fi # Define images to bundle for fallback (when not from server or missing). Includes filebrowser. # bitcoin-ui and lnd-ui are custom and normally captured from server or built separately. # Alpha: core Bitcoin/Lightning stack + essential apps. Others pulled on-demand from Marketplace. CONTAINER_IMAGES=" ${BITCOIN_KNOTS_IMAGE} bitcoin-knots.tar ${LND_IMAGE} lnd.tar ${HOMEASSISTANT_IMAGE} homeassistant.tar ${BTCPAY_IMAGE} btcpayserver.tar ${NBXPLORER_IMAGE} nbxplorer.tar ${POSTGRES_IMAGE} postgres-btcpay.tar ${MEMPOOL_BACKEND_IMAGE} mempool-backend.tar ${MEMPOOL_WEB_IMAGE} mempool-frontend.tar ${ELECTRUMX_IMAGE} electrumx.tar ${MARIADB_IMAGE} mariadb-mempool.tar ${FEDIMINT_IMAGE} fedimint.tar ${FEDIMINT_GATEWAY_IMAGE} fedimint-gateway.tar ${FMCD_IMAGE} fmcd.tar ${FILEBROWSER_IMAGE} filebrowser.tar ${ALPINE_TOR_IMAGE} alpine-tor.tar ${NGINX_ALPINE_IMAGE} nginx-alpine.tar ${DWN_SERVER_IMAGE} dwn-server.tar ${GRAFANA_IMAGE} grafana.tar ${UPTIME_KUMA_IMAGE} uptime-kuma.tar ${VAULTWARDEN_IMAGE} vaultwarden.tar ${SEARXNG_IMAGE} searxng.tar ${PORTAINER_IMAGE} portainer.tar ${TAILSCALE_IMAGE} tailscale.tar ${JELLYFIN_IMAGE} jellyfin.tar ${PHOTOPRISM_IMAGE} photoprism.tar ${NEXTCLOUD_IMAGE} nextcloud.tar ${NPM_IMAGE} nginx-proxy-manager.tar ${ONLYOFFICE_IMAGE} onlyoffice.tar ${ADGUARDHOME_IMAGE} adguardhome.tar " # Pull and save each image (force target arch) only if not already present echo "$CONTAINER_IMAGES" | while read -r image filename; do [ -z "$image" ] && continue tarpath="$IMAGES_DIR/$filename" if [ -f "$tarpath" ]; then echo " ✅ Using cached: $filename" else echo " Pulling $image ($CONTAINER_PLATFORM)..." if container_pull "$image"; then echo " Saving $filename..." if $CONTAINER_CMD save "$image" -o "$tarpath" 2>/dev/null; then echo " ✅ Saved: $(du -h "$tarpath" | cut -f1)" else echo " ⚠️ Failed to save $image (zstd/format issue) - skipping" rm -f "$tarpath" fi else echo " ⚠️ Failed to pull $image - skipping" fi fi done fi # end UNBUNDLED check # Create first-boot service to load images into Podman echo " Creating first-boot image loader service..." cat > "$WORK_DIR/archipelago-load-images.service" <<'LOADSERVICE' [Unit] Description=Load Archipelago Container Images After=network.target podman.service ConditionPathExists=/opt/archipelago/container-images ConditionPathExists=!/var/lib/archipelago/.images-loaded [Service] Type=oneshot ExecStart=/opt/archipelago/scripts/load-container-images.sh ExecStartPost=/usr/bin/touch /var/lib/archipelago/.images-loaded RemainAfterExit=yes [Install] WantedBy=multi-user.target LOADSERVICE cat > "$WORK_DIR/load-container-images.sh" <<'LOADSCRIPT' #!/bin/bash # Load pre-bundled container images into Podman # # CRITICAL: all Archipelago containers run ROOTLESS as the archipelago user. # This script runs as root (systemd oneshot), so a plain `podman load` here # puts the images into root's storage where the rootless runtime can never # see them — containers then silently depend on registry pulls, and a fresh # install without internet gets no apps at all. Always load into the # archipelago user's storage. IMAGES_DIR="/opt/archipelago/container-images" LOG_FILE="/var/log/archipelago-images.log" echo "$(date): Starting container image load" >> "$LOG_FILE" if [ ! -d "$IMAGES_DIR" ]; then echo "$(date): No images directory found" >> "$LOG_FILE" exit 0 fi ARCH_UID=$(id -u archipelago) # Linger gives the archipelago user a runtime dir (/run/user/UID) at boot, # before any login — required for rootless podman. loginctl enable-linger archipelago 2>/dev/null || true for _ in $(seq 1 30); do [ -d "/run/user/$ARCH_UID" ] && break sleep 1 done PODMAN="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$ARCH_UID podman" $PODMAN system migrate >> "$LOG_FILE" 2>&1 || true for tarfile in "$IMAGES_DIR"/*.tar "$IMAGES_DIR"/*.tar.zst; do if [ -f "$tarfile" ]; then echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE" $PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \ echo "$(date): Successfully loaded $(basename "$tarfile")" >> "$LOG_FILE" || \ echo "$(date): Failed to load $(basename "$tarfile")" >> "$LOG_FILE" fi done # Ensure archy-net exists for mempool stack (db, api, frontend) $PODMAN network create archy-net 2>/dev/null || true echo "$(date): Container image load complete" >> "$LOG_FILE" echo "$(date): Available images:" >> "$LOG_FILE" $PODMAN images >> "$LOG_FILE" 2>&1 LOADSCRIPT chmod +x "$WORK_DIR/load-container-images.sh" # Copy scripts to ISO mkdir -p "$ARCH_DIR/scripts" cp "$WORK_DIR/load-container-images.sh" "$ARCH_DIR/scripts/" cp "$WORK_DIR/archipelago-load-images.service" "$ARCH_DIR/scripts/" # First-boot per-device secrets: the squashfs bakes one TLS key and one set # of SSH host keys at build time, so every device flashed from the same image # would share them. Regenerate both on the installed system's first boot, # before the network-facing services come up. echo " Creating first-boot secrets regeneration service..." cat > "$WORK_DIR/archipelago-first-boot-secrets.service" <<'SECRETSSERVICE' [Unit] Description=Regenerate per-device secrets (TLS key, SSH host keys) DefaultDependencies=no After=local-fs.target # No random-seed file is baked into the rootfs today (verified by the entropy # audit's C-4 tar listing), so this ordering is a no-op right now. It is here # so that if one is ever introduced, the pool is credited before this unit — # the first consumer of entropy on a freshly-flashed machine — draws from it. After=systemd-random-seed.service Before=ssh.service nginx.service archipelago.service # There is deliberately NO ConditionPathExists=!.secrets-regenerated here. # # It used to short-circuit the unit once the marker existed, which meant a node # that had completed generation could never be re-examined. That is fine while # the only question is "do the keys exist", and wrong as soon as the question # is "are they still trustworthy" — a cert minted under a wrong clock succeeds # at generation and is only detectable afterwards. Skipping the unit is exactly # how such a node stays broken forever. # # The script owns the decision instead: it exits within milliseconds when the # material is present and correctly dated. One place decides, and it is the # place that can see the whole picture. [Service] Type=oneshot ExecStart=/opt/archipelago/scripts/first-boot-secrets.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target SECRETSSERVICE # Self-heal timer. Fail-closed governs SERVING (never present a key we did not # generate); this timer governs RECOVERING (never dead-end a node). # # Without it, a node whose generators failed all their in-boot retries would sit # with no SSH host key and no TLS key until somebody walked to it with a # keyboard. With it, a transient cause that later clears — a full disk that gets # freed, a pool that eventually seeds — repairs the node unattended. # # The service's own ConditionPathExists=! is what stops this: once the marker # exists, every subsequent trigger is a no-op that systemd records as success, # so the timer costs nothing on a healthy node and needs no separate teardown. # This uses systemd's own facilities on purpose; a sleep loop inside the script # would hold a oneshot open for hours and hide the failure from systemctl. cat > "$WORK_DIR/archipelago-first-boot-secrets.timer" <<'SECRETSTIMER' [Unit] Description=Retry per-device secret generation until it succeeds Documentation=man:archipelago-first-boot-secrets.service(8) [Timer] # First retry shortly after boot has settled — by then the disk, the entropy # pool and any late-mounting filesystem have had a chance to become healthy. OnBootSec=5min OnUnitActiveSec=15min AccuracySec=30s Unit=archipelago-first-boot-secrets.service [Install] WantedBy=timers.target SECRETSTIMER cat > "$WORK_DIR/first-boot-secrets.sh" <<'SECRETSSCRIPT' #!/bin/bash # Create this device's own TLS keypair and SSH host keys on first boot. # # ── FAIL CLOSED — read this before changing anything below (audit F-03) ── # # The rootfs tar is byte-identical on every node flashed from one ISO, and the # ISO is a published artefact. It therefore no longer carries any identity # material: the rootfs Dockerfile in STEP 1 of this builder strips the SSH # host keys, the TLS keypair and machine-id out of the shared image. THIS # SCRIPT IS THE ONLY THING THAT CREATES THEM. That is deliberate. # # SINGLE PRODUCER. gen_tls() below is the only code anywhere in the ISO build # that creates /etc/archipelago/ssl/archipelago.{key,crt}; gen_ssh() is the only # code that creates /etc/ssh/ssh_host_*. The Dockerfile no longer bakes a # keypair and the installer's old "ensure SSL cert exists" fallback is gone. # That is the actual lesson of F-03: the bug was never "a second attempt to # create a key exists", it was that failure was silent and the marker lied # about it. A second producer is dangerous precisely because it has its own # accounting — its own idea of success, its own (absent) retry policy, its own # (absent) failure record. One producer means one place that can fail, one # place that retries, one place that reports. # # FAIL CLOSED applies to SERVING: if generation fails, no key exists, so sshd # and the nginx TLS listener refuse to start. They never come up on a # placeholder, a zero-length file, or a key from anywhere else. gen_tls swaps # into place only after openssl has parsed both halves back, so a truncated or # corrupt artefact is never what a service reads. # # SELF-HEAL applies to RECOVERING, and it is a different thing: a failure must # never dead-end the node. Three attempts with backoff inside the boot, then # archipelago-first-boot-secrets.timer retries every 15 minutes, and every # subsequent boot retries too — all because the marker is never written on # failure. A transient cause that later clears (a full disk that gets freed, a # pool that eventually seeds) repairs the node with nobody at a console. On # success the script restarts whatever refused to start, so recovery is # complete rather than pending-a-reboot. # # The only failure that survives all of that is a deterministic one — a missing # generator binary — and the rootfs build asserts openssl and ssh-keygen are # present and executable, so the build fails rather than the fleet. # # What this replaces was worse in every direction: log a warning, set the # completion marker anyway, never retry, and run forever on the SSH host key and # TLS private key that every downloader of the ISO also holds — undetectable # host impersonation and transparent MITM of the web UI, on a node whose # operator has no idea. # # Testability seam: FIRST_BOOT_SECRETS_ROOT prefixes every absolute path. It is # unset in production — the expansion is empty and behaviour is identical to a # script with the paths hard-coded — and set to a temp dir by # tests/first-boot-secrets/run-tests.sh, which is what makes the fail-closed # property assertable instead of merely claimed. set -u ROOT="${FIRST_BOOT_SECRETS_ROOT:-}" # Waits between attempts at one generator. The attempt count is the number of # entries; the wait after the final attempt is skipped, because a failed last # attempt is terminal and there is nothing left to wait for. With the default # 3-entry list that means 3 attempts at t=0s, t=2s and t=10s, and the trailing # 20 is the ceiling that applies if the list is ever lengthened. Tests override # this with zeros so the suite does not sleep. BACKOFF="${FIRST_BOOT_SECRETS_BACKOFF:-2 8 20}" LOG="$ROOT/var/log/archipelago-first-boot-secrets.log" MARKER="$ROOT/var/lib/archipelago/.secrets-regenerated" FAILED="$ROOT/var/lib/archipelago/first-boot-secrets.failed" CONSOLE="$ROOT/dev/console" SSL_DIR="$ROOT/etc/archipelago/ssl" SSH_DIR="$ROOT/etc/ssh" # Clock plausibility window. A node cannot legitimately believe it is running # before this software existed, and one that thinks it is decades ahead is just # as broken. Used to decide whether the clock can be trusted to date a # certificate — see the CLOCK section below. CLOCK_FLOOR_EPOCH=1767225600 # 2026-01-01T00:00:00Z CLOCK_CEIL_EPOCH=2713910400 # 2056-01-01T00:00:00Z # How far back to date notBefore, so ordinary skew between this node and a # client cannot make a freshly minted cert "not yet valid". BACKDATE_SECONDS=86400 mkdir -p "$ROOT/var/lib/archipelago" "$ROOT/var/log" NODE_NAME=$(hostname 2>/dev/null || echo archipelago) log() { echo "$(date): $*" >> "$LOG"; } # Test seam, same idea as FIRST_BOOT_SECRETS_ROOT: unset in production this is # the real clock. A wrong clock is the whole subject of the CLOCK section and # cannot be exercised otherwise. now_epoch() { echo "${FIRST_BOOT_SECRETS_NOW:-$(date -u +%s)}"; } # A terminal failure must be impossible to miss: journal, console and stderr, # on top of the durable on-disk record. Every channel is guarded so that a # missing /dev/console (test root, or an early boot without one) cannot itself # make the failure path fail. shout() { log "$*" if command -v logger >/dev/null 2>&1; then logger -t archipelago-first-boot-secrets "$*" 2>/dev/null || true fi if [ -w "$CONSOLE" ]; then printf '%s\n' "$*" | tee -a "$CONSOLE" >/dev/null 2>&1 || true fi printf '%s\n' "$*" >&2 } # retry