395 lines
15 KiB
Bash
Executable File
395 lines
15 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"
|
|
mkdir -p "$PAYLOAD"/{identity,chores,pantry-vision,digest-engine,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
|
|
|
|
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."
|