SmartestHome/tools/build-container-host-iso.sh

431 lines
17 KiB
Bash
Executable File

#!/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" <<EOF
# GENERATED at image build time by tools/build-container-host-iso.sh from
# CoreSystemConfig.json. Pair ID: $(core_pair_id)
BASE_DIR=/opt/smart-home
TIMEZONE=${CORE_TIMEZONE}
ENABLE_IDENTITY=${CORE_ENABLE_IDENTITY}
ENABLE_PANTRY_VISION=${CORE_ENABLE_PANTRY_VISION}
ENABLE_DIGEST_ENGINE=${CORE_ENABLE_DIGEST_ENGINE}
ENABLE_ADMIN_CANVAS=${CORE_ENABLE_ADMIN_CANVAS}
ENABLE_TRASH_CALENDAR=${CORE_ENABLE_TRASH_CALENDAR}
ENABLE_TRANSIT=${CORE_ENABLE_TRANSIT}
ENABLE_TRIP_PLANNING=${CORE_ENABLE_TRIP_PLANNING}
ENABLE_CHORES=${CORE_ENABLE_CHORES}
ENABLE_NTFY=${CORE_ENABLE_NTFY}
ENABLE_NODERED=${CORE_ENABLE_NODE_RED}
ENABLE_NETDATA=${CORE_ENABLE_NETDATA}
ENABLE_HOMEPAGE=${CORE_ENABLE_HOMEPAGE}
ENABLE_PORTAINER=${CORE_ENABLE_PORTAINER}
ENABLE_MEALIE=${CORE_ENABLE_MEALIE}
ENABLE_GALLERY_SMB=${CORE_ENABLE_GALLERY_SMB}
ENABLE_MUSIC_ASSISTANT=${CORE_ENABLE_MUSIC_ASSISTANT}
ENABLE_BACKUPS=${CORE_ENABLE_BACKUPS}
IDENTITY_PORT=${CORE_PORT_IDENTITY}
IDENTITY_WEB_PORT=${CORE_PORT_IDENTITY_WEB}
PANTRY_VISION_PORT=${CORE_PORT_PANTRY_VISION}
PANTRY_WEB_PORT=${CORE_PORT_PANTRY_WEB}
DIGEST_WEB_PORT=${CORE_PORT_DIGEST_WEB}
ADMIN_WEB_PORT=${CORE_PORT_ADMIN_WEB}
TRANSIT_PORT=${CORE_PORT_TRANSIT}
OTP_PORT=${CORE_PORT_OTP}
ENABLE_PROXY=${CORE_PROXY_ENABLED}
PROXY_HOSTNAME=${CORE_PROXY_HOSTNAME}
PROXY_TLS=${CORE_PROXY_TLS}
PROXY_CERT_FILE=${CORE_PROXY_CERT_FILE}
PROXY_KEY_FILE=${CORE_PROXY_KEY_FILE}
PROXY_HTTP_PORT=${CORE_PORT_PROXY_HTTP}
PROXY_HTTPS_PORT=${CORE_PORT_PROXY_HTTPS}
EOF
chmod 600 "$PAYLOAD/setup.env"
# Reverse proxy config, generated from the same source as everything else.
if [[ "$CORE_PROXY_ENABLED" == "true" ]]; then
mkdir -p "$PAYLOAD/proxy/config"
"${CORE_TOOLS_DIR}/generate-caddyfile.sh" "$PAYLOAD/proxy/config/Caddyfile"
fi
# ---------------------------------------------------------------------------
# 2. Generated env files. EVERY URL BELOW IS DERIVED — see tools/config-export.py.
# No address, port or token is written literally in this script.
# ---------------------------------------------------------------------------
core_log "Generating service env files from CoreSystemConfig.json"
# digest/data, not digest-engine: setup-container-host.sh bind-mounts
# $BASE_DIR/digest/data into the container as /data, which is where IDSconf.json
# has to land. A file written anywhere else would simply never be read.
mkdir -p "$PAYLOAD"/{identity,chores,pantry-vision,digest/data,transit,trash-calendar}
gen_header() {
cat <<EOF
# GENERATED at image build time by tools/build-container-host-iso.sh from
# CoreSystemConfig.json. Editing this file by hand works, but the next image build
# overwrites it — change CoreSystemConfig.json and rebuild instead.
# Pair ID: $(core_pair_id)
EOF
}
if [[ "$CORE_ENABLE_IDENTITY" == "true" ]]; then
{ gen_header
cat <<EOF
IDENTITY_TOKEN=${CORE_IDENTITY_TOKEN}
HA_URL=${CORE_HA_URL}
HA_TOKEN=${CORE_HA_TOKEN}
TRUSTED_ENTITY_PREFIXES=device_tracker.pble_,device_tracker.bletag_
MQTT_BROKER_HOST=mosquitto
MQTT_BROKER_PORT=${CORE_PORT_MQTT}
MQTT_USERNAME=${CORE_MQTT_USERNAME}
MQTT_PASSWORD=${CORE_MQTT_PASSWORD}
FRIGATE_EVENTS_TOPIC=frigate/events
FACE_PRESENCE_WINDOW_SECONDS=600
PRESENCE_POLL_SECONDS=60
DEPARTURE_GRACE_SECONDS=900
VISIT_MAX_OPEN_HOURS=72
NTFY_URL=http://ntfy
NTFY_DEFAULT_TOPIC=household
IDENTITY_PORT=${CORE_PORT_IDENTITY}
IDENTITY_DB_PATH=/data/identity.db
IDENTITY_PHOTO_DIR=/data/photos
IDENTITY_FLOORPLAN_DIR=/data/floorplans
IDENTITY_MAX_IMAGE_MB=15
LOG_LEVEL=INFO
EOF
} > "$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 <<EOF
IDENTITY_URL=http://identity:${CORE_PORT_IDENTITY}
IDENTITY_TOKEN=${CORE_IDENTITY_TOKEN}
WASTE_ICS_URL=
FRIGATE_URL=http://frigate:${CORE_PORT_FRIGATE}
OLLAMA_HOST=${CORE_OLLAMA_HOST}
OLLAMA_VISION_MODEL=${CORE_LLM_VISION_MODEL}
OLLAMA_TEXT_MODEL=
CAMERA_WATCHPOINTS=
CALDAV_URL=
CALDAV_USERNAME=
CALDAV_PASSWORD=
CALDAV_VERIFY_TLS=true
CALDAV_QUIET_KEYWORDS=busy,meeting,call,movie,sleep
NTFY_URL=http://ntfy
NTFY_TOPIC=chores
NEGLECT_THRESHOLD_HOURS=4
CHORE_ASSIGNMENT_STRICT=false
CHORES_DB_PATH=/data/chores.db
LOG_LEVEL=INFO
EOF
} > "$PAYLOAD/chores/chores.env"
chmod 600 "$PAYLOAD/chores/chores.env"
fi
if [[ "$CORE_ENABLE_PANTRY_VISION" == "true" ]]; then
{ gen_header
cat <<EOF
PANTRY_VISION_TOKEN=${CORE_PANTRY_VISION_TOKEN}
PANTRY_VISION_PORT=${CORE_PORT_PANTRY_VISION}
GROCY_URL=http://grocy
GROCY_API_KEY=
OLLAMA_HOST=${CORE_OLLAMA_HOST}
OLLAMA_VISION_MODEL=${CORE_LLM_VISION_MODEL}
LOG_LEVEL=INFO
EOF
} > "$PAYLOAD/pantry-vision/pantry-vision.env"
chmod 600 "$PAYLOAD/pantry-vision/pantry-vision.env"
fi
# IDSconf.json — the OPNsense credentials and tuning for digest-engine's network
# digest. JSON rather than an env file because that is the shape ingest/opnsense_ids.py
# reads (OPNSENSE_IDS_CONF_PATH, default /data/IDSconf.json). Written only when a
# base_url is configured: an empty one means "this household has no OPNsense to ask",
# and a file full of blanks would make the module warn on every run instead of simply
# staying off. digest-engine.env itself is NOT generated — it holds a dozen
# hand-provisioned credentials — so remember to set ENABLE_OPNSENSE_IDS_INGEST=true
# there as well; these credentials do nothing on their own.
if [[ "$CORE_ENABLE_DIGEST_ENGINE" == "true" && -n "$CORE_OPNSENSE_BASE_URL" ]]; then
ids_interfaces="[]"
if [[ -n "$CORE_OPNSENSE_INTERFACES" ]]; then
ids_interfaces="[$(printf '"%s",' $CORE_OPNSENSE_INTERFACES | sed 's/,$//')]"
fi
cat > "$PAYLOAD/digest/data/IDSconf.json" <<EOF
{
"_comment": "GENERATED at image build time by tools/build-container-host-iso.sh from CoreSystemConfig.json's opnsense block and secrets. Editing this file by hand works, but the next image build overwrites it — change CoreSystemConfig.json and rebuild instead. Field documentation: digest-engine/IDSconf.json.example.",
"base_url": "${CORE_OPNSENSE_BASE_URL}",
"api_key": "${CORE_OPNSENSE_API_KEY}",
"api_secret": "${CORE_OPNSENSE_API_SECRET}",
"verify_tls": ${CORE_OPNSENSE_VERIFY_TLS},
"interfaces": ${ids_interfaces},
"max_alerts_scanned": ${CORE_OPNSENSE_MAX_ALERTS_SCANNED},
"top_signatures": ${CORE_OPNSENSE_TOP_SIGNATURES},
"top_hosts": ${CORE_OPNSENSE_TOP_HOSTS},
"packet_capture_reference": "${CORE_OPNSENSE_PACKET_CAPTURE_REFERENCE}"
}
EOF
chmod 600 "$PAYLOAD/digest/data/IDSconf.json"
if [[ -z "$CORE_OPNSENSE_API_KEY" || -z "$CORE_OPNSENSE_API_SECRET" ]]; then
core_warn "opnsense.base_url is set but secrets.opnsense_api_key/secret are empty — the network digest will skip until you fill them in and rebuild."
fi
fi
if [[ "$CORE_ENABLE_TRANSIT" == "true" ]]; then
{ gen_header
cat <<EOF
TRANSIT_TOKEN=${CORE_TRANSIT_TOKEN}
TRANSIT_PORT=${CORE_PORT_TRANSIT}
OTP_URL=${CORE_OTP_URL}
LOG_LEVEL=INFO
EOF
} > "$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" <<EOF
# GENERATED from CoreSystemConfig.json. This address is not arbitrary: every kiosk
# image built from the same config has it baked into its service URLs, so changing it
# here alone would silently orphan them. Change it in CoreSystemConfig.json and
# rebuild everything.
auto lo
iface lo inet loopback
allow-hotplug eth0
iface eth0 inet static
address ${CORE_CONTAINER_HOST_IP}
netmask ${CORE_NETMASK}
gateway ${CORE_GATEWAY}
dns-nameservers ${CORE_DNS_SERVERS}
EOF
echo "${CORE_CONTAINER_HOST_NAME}" > "$INCLUDES/etc/hostname"
cat > "$INCLUDES/etc/hosts" <<EOF
127.0.0.1 localhost
127.0.1.1 ${CORE_CONTAINER_HOST_NAME}
${CORE_CONTAINER_HOST_IP} ${CORE_CONTAINER_HOST_NAME}
# The twin. Present so this host can reach the LLM host by name as well as address,
# and so anyone reading /etc/hosts can see what this machine is paired with.
${CORE_LLM_HOST_IP} ${CORE_LLM_HOST_NAME}
EOF
cat > "$INCLUDES/etc/default/keyboard" <<EOF
XKBMODEL="pc105"
XKBLAYOUT="${CORE_KEYBOARD_LAYOUT}"
XKBVARIANT=""
XKBOPTIONS=""
BACKSPACE="guess"
EOF
core_write_build_stamp "$INCLUDES/etc/smarthome-build" "container-host"
# SSH key, if one was configured.
if [[ -n "$CORE_SSH_AUTHORIZED_KEY" ]]; then
mkdir -p "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh"
echo "$CORE_SSH_AUTHORIZED_KEY" > "$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" <<EOF
# GENERATED from CoreSystemConfig.json by tools/build-container-host-iso.sh
d-i debian-installer/locale string ${CORE_LOCALE}
d-i keyboard-configuration/xkb-keymap select ${CORE_KEYBOARD_LAYOUT}
d-i time/zone string ${CORE_TIMEZONE}
d-i clock-setup/utc boolean true
d-i netcfg/choose_interface select auto
d-i netcfg/disable_autoconfig boolean true
d-i netcfg/get_ipaddress string ${CORE_CONTAINER_HOST_IP}
d-i netcfg/get_netmask string ${CORE_NETMASK}
d-i netcfg/get_gateway string ${CORE_GATEWAY}
d-i netcfg/get_nameservers string ${CORE_DNS_SERVERS}
d-i netcfg/confirm_static boolean true
d-i netcfg/get_hostname string ${CORE_CONTAINER_HOST_NAME}
d-i netcfg/get_domain string local
d-i passwd/root-login boolean false
d-i passwd/user-fullname string ${CORE_CONTAINER_HOST_USER}
d-i passwd/username string ${CORE_CONTAINER_HOST_USER}
$(if [[ -n "$CORE_ADMIN_PASSWORD_HASH" ]]; then
echo "d-i passwd/user-password-crypted password ${CORE_ADMIN_PASSWORD_HASH}"
else
echo "# No admin_password_hash set — the installer will prompt for a password."
echo "# Generate one with: mkpasswd -m sha-512"
fi)
d-i user-setup/allow-password-weak boolean false
d-i user-setup/encrypt-home boolean false
# WHOLE-DISK, AUTOMATIC, NO CONFIRMATION. This erases ${CORE_CONTAINER_HOST_DISK}
# without asking. That is the point of an unattended installer, and it is also why
# you should be certain which disk that is on the machine you're booting this on.
d-i partman-auto/disk string ${CORE_CONTAINER_HOST_DISK}
d-i partman-auto/method string regular
d-i partman-auto/choose_recipe select atomic
d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman/choose_partition select finish
d-i partman/confirm boolean true
d-i partman/confirm_nooverwrite boolean true
d-i pkgsel/include string openssh-server sudo curl ca-certificates python3 git
tasksel tasksel/first multiselect standard, ssh-server
popularity-contest popularity-contest/participate boolean false
d-i grub-installer/only_debian boolean true
d-i grub-installer/bootdev string ${CORE_CONTAINER_HOST_DISK}
d-i finish-install/reboot_in_progress note
EOF
# ---------------------------------------------------------------------------
# 6. Build
# ---------------------------------------------------------------------------
cd "$LIVE_BUILD_DIR"
core_log "Running lb config"
lb clean --purge >/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."