#!/usr/bin/env bash # # Container host ISO — the Phase 1 machine (Home Assistant, Mosquitto, Zigbee2MQTT, # Frigate, Grocy, and this repo's own services), as an unattended-install ISO with # everything burnt in. # # Normally invoked via tools/build-core-pair.sh, which builds this and its LLM-host # twin from the same config. Runnable on its own when only this half changed. # # WHAT'S BURNT IN: static network config, hostname, admin user + SSH key, this repo's # source, and — the part that matters — **every service env file, generated from # CoreSystemConfig.json**. Those env files were previously copied from .env.example # templates and hand-edited on the host, which is exactly how `chores.env` ended up # shipping `IDENTITY_URL=http://127.0.0.1:8097` (project-plan open decision #38): an # address that could never work from inside a container, in a file nobody re-read # after copying it. Generating them from derived values removes that whole class of # mistake permanently — no hand-editing, no stale template, no address typed twice. # # The machine boots, installs unattended, and on first boot runs # setup-container-host.sh with the env files already in place. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/coreconfig.sh source "${SCRIPT_DIR}/lib/coreconfig.sh" core_load core_require_root HOST_DIR="${CORE_REPO_ROOT}/hosts/container-host" LIVE_BUILD_DIR="${HOST_DIR}/live-build" INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot" PAYLOAD="${INCLUDES}/opt/smart-home" OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}" command -v lb >/dev/null 2>&1 || core_die "live-build is not installed (apt install live-build)" core_log "Preparing ${LIVE_BUILD_DIR}" rm -rf "$INCLUDES" mkdir -p \ "$INCLUDES/etc/systemd/system" \ "$INCLUDES/etc/default" \ "$INCLUDES/etc/network/interfaces.d" \ "$LIVE_BUILD_DIR/config/package-lists" \ "$PAYLOAD/src" \ "$OUTPUT_DIR" # --------------------------------------------------------------------------- # 1. This repo's source, for the services that build from it. # setup-container-host.sh expects each service's directory to exist on the host # (its *_SRC variables); shipping them inside the image is what makes the install # unattended instead of "now go git clone something". # --------------------------------------------------------------------------- core_log "Copying service sources into the image" for svc in identity pantry-vision chores digest-engine admin-canvas trash-calendar transit; do if [[ -d "${CORE_REPO_ROOT}/${svc}" ]]; then cp -r "${CORE_REPO_ROOT}/${svc}" "$PAYLOAD/src/" # __pycache__ from a developer machine is architecture- and version-specific # noise that must never ship in an image. find "$PAYLOAD/src/${svc}" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true fi done mkdir -p "$PAYLOAD/scripts" cp "${CORE_TOOLS_DIR}/setup-container-host.sh" "$PAYLOAD/scripts/" chmod +x "$PAYLOAD/scripts/setup-container-host.sh" # setup-container-host.sh reads every config value as ${VAR:-default}, so this file — # sourced by the first-boot unit — configures it without the script being edited. The # ENABLE_* flags and ports come from CoreSystemConfig.json, which is what keeps the # ports the services actually bind to identical to the ports the kiosk images were # built to call. core_log "Generating the container host's setup overrides" cat > "$PAYLOAD/setup.env" < "$PAYLOAD/identity/identity.env" chmod 600 "$PAYLOAD/identity/identity.env" fi if [[ "$CORE_ENABLE_CHORES" == "true" ]]; then # Container-name DNS, not 127.0.0.1 — see this script's header comment. { gen_header cat < "$PAYLOAD/chores/chores.env" chmod 600 "$PAYLOAD/chores/chores.env" fi if [[ "$CORE_ENABLE_PANTRY_VISION" == "true" ]]; then { gen_header cat < "$PAYLOAD/pantry-vision/pantry-vision.env" chmod 600 "$PAYLOAD/pantry-vision/pantry-vision.env" fi if [[ "$CORE_ENABLE_TRANSIT" == "true" ]]; then { gen_header cat < "$PAYLOAD/transit/transit.env" chmod 600 "$PAYLOAD/transit/transit.env" fi # --------------------------------------------------------------------------- # 3. Static networking. The whole point of a fixed address here is that every kiosk # image was built with this exact IP compiled into its URLs — DHCP would break # every one of them the first time the lease moved. # --------------------------------------------------------------------------- cat > "$INCLUDES/etc/network/interfaces.d/smarthome" < "$INCLUDES/etc/hostname" cat > "$INCLUDES/etc/hosts" < "$INCLUDES/etc/default/keyboard" < "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh/authorized_keys" chmod 700 "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh" chmod 600 "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh/authorized_keys" else core_warn "No ssh_authorized_key in the config — this headless host will have no SSH access." fi # --------------------------------------------------------------------------- # 4. First-boot unit. Runs ONCE, then disables itself: setup-container-host.sh is # idempotent, but a first-boot job that re-runs on every reboot would fight # whatever you changed by hand afterwards. # --------------------------------------------------------------------------- cat > "$INCLUDES/etc/systemd/system/smarthome-firstboot.service" <<'EOF' [Unit] Description=SmartestHome first-boot setup (container host) After=network-online.target Wants=network-online.target ConditionPathExists=!/opt/smart-home/.firstboot-done [Service] Type=oneshot RemainAfterExit=yes EnvironmentFile=/opt/smart-home/setup.env # Needs the network: it pulls container images. Deliberately not Restart=on-failure — # a half-finished run should be looked at, not retried in a loop that buries the # original error in the journal. ExecStart=/opt/smart-home/scripts/setup-container-host.sh ExecStartPost=/usr/bin/touch /opt/smart-home/.firstboot-done ExecStartPost=/bin/systemctl disable smarthome-firstboot.service StandardOutput=journal+console StandardError=journal+console TimeoutStartSec=3600 [Install] WantedBy=multi-user.target EOF mkdir -p "$INCLUDES/etc/systemd/system/multi-user.target.wants" ln -sf /etc/systemd/system/smarthome-firstboot.service \ "$INCLUDES/etc/systemd/system/multi-user.target.wants/smarthome-firstboot.service" cat > "$LIVE_BUILD_DIR/config/package-lists/container-host.list.chroot" <<'EOF' ca-certificates curl gnupg openssh-server sudo python3 git rsync EOF # --------------------------------------------------------------------------- # 5. Preseed for the unattended install. # --------------------------------------------------------------------------- mkdir -p "$LIVE_BUILD_DIR/config/includes.installer" cat > "$LIVE_BUILD_DIR/config/includes.installer/preseed.cfg" </dev/null 2>&1 || true lb config \ --distribution "$CORE_DEBIAN_RELEASE" \ --architecture amd64 \ --binary-images iso-hybrid \ --debian-installer netinst \ --debian-installer-gui false \ --archive-areas "main contrib non-free non-free-firmware" \ --iso-application "SmartestHome container host" \ --iso-volume "smarthome-core-$(core_pair_id)" core_log "Running lb build (long, needs network)" lb build ISO="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name 'live-image-amd64.hybrid.iso' -print -quit)" [[ -n "$ISO" ]] || core_die "lb build finished but no ISO was produced — check the log above." DEST="${OUTPUT_DIR}/smarthome-container-host-$(core_pair_id).iso" mv "$ISO" "$DEST" core_log "Container host ISO: ${DEST}" core_warn "This ISO contains every secret from CoreSystemConfig.json. Treat it as a credential."