Files
vanlink/deploy.sh
T
andreasandClaude Sonnet 5 83974f731d deploy: feature flags, --diff, tolerate failed restarts
Li3, NVMe watchdog, Home Assistant/ESPHome, Frigate and Cockpit are
installed, enabled and restarted only when their ENABLE_* flag is 1.
--diff shows what would change in /etc. A unit that fails to restart is
recorded as a warning instead of aborting the deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:34:13 -04:00

497 lines
26 KiB
Bash
Executable File

#!/usr/bin/env bash
# Deploy vanlink configs/scripts (Pi 4 "wan" port) to their system locations.
# Usage: cd ~/vanlink && sudo ./deploy.sh
# sudo ./deploy.sh render <file> # preview a templated file on stdout
# ./deploy.sh --check [conf] # validate conf + render all templates (no root)
# ./deploy.sh --templates # list templated files
# Idempotent. Netplan (wlan0 + Starlink NIC = NM-managed WANs; eth0 is a LAN
# port on br0) is NOT deployed here — reference copy in ap/50-van-wan.yaml,
# applied once manually (apply flaps uplinks). We do check it for drift below.
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
# Host-specific values (interface names, addressing, feature flags) live in
# /etc/vanlink.conf — a symlink to hosts/<hostname>.conf in this repo. Config
# files below carry @TOKEN@ placeholders substituted from those variables via
# render()/install_rendered() — edit hosts/<hostname>.conf, not the configs.
VANLINK_CONF=${VANLINK_CONF:-/etc/vanlink.conf}
# Must be defined (non-empty) in the host file; deploy aborts naming any missing.
REQUIRED_VARS=(
WIFI_5G_IFACE WIFI_2G_IFACE LAN_IFACE STARLINK_IFACE WIFI_WAN_IFACE
GATEWAY_IP GATEWAY_PREFIX GATEWAY_NETWORK DHCP_RANGE_START DHCP_RANGE_END
DNS_RESOLVERS MODEM_USB_VENDOR
ENABLE_COCKPIT ENABLE_LI3 ENABLE_NVME_WATCH ENABLE_HA ENABLE_FRIGATE
)
# May be empty or absent; NVME_* are required only when ENABLE_NVME_WATCH=1.
OPTIONAL_VARS=(
ONBOARD_LAN_IFACE NVME_DEVICE NVME_FS_DEVICE
USB_BACKUP_ROOT_PART USB_BACKUP_MOUNT SD_BACKUP_UUID
)
# Computed in load_conf; usable as @TOKEN@ like the rest.
DERIVED_VARS=(ONBOARD_LAN_UNMANAGED)
TOKEN_VARS=("${REQUIRED_VARS[@]}" "${OPTIONAL_VARS[@]}" "${DERIVED_VARS[@]}")
TOKEN_RE='@[A-Z][A-Z0-9_]*@'
die() { echo "deploy: $*" >&2; exit 1; }
load_conf() { # load_conf <file> — source and validate a host config
local f=$1 v flag missing=()
[ -e "$f" ] || die "$f not found (missing or dangling symlink). Create it for this host with:
sudo ln -sfn \"$PWD/hosts/$(hostname -s).conf\" /etc/vanlink.conf"
for v in "${TOKEN_VARS[@]}"; do unset "$v"; done
# shellcheck disable=SC1090
. "$f"
for v in "${REQUIRED_VARS[@]}"; do
[ -n "${!v:-}" ] || missing+=("$v")
done
[ ${#missing[@]} -eq 0 ] || die "$f is missing required variable(s): ${missing[*]}"
for flag in ENABLE_COCKPIT ENABLE_LI3 ENABLE_NVME_WATCH ENABLE_HA ENABLE_FRIGATE; do
case ${!flag} in
0|1) ;;
*) die "$f: $flag must be 0 or 1 (got '${!flag}')" ;;
esac
done
if [ "$ENABLE_NVME_WATCH" = 1 ]; then
{ [ -n "${NVME_DEVICE:-}" ] && [ -n "${NVME_FS_DEVICE:-}" ]; } \
|| die "$f: ENABLE_NVME_WATCH=1 needs NVME_DEVICE and NVME_FS_DEVICE"
fi
ONBOARD_LAN_UNMANAGED=${ONBOARD_LAN_IFACE:+;interface-name:$ONBOARD_LAN_IFACE}
}
render() { # render <file> -> stdout, with @TOKEN@ placeholders substituted
local args=() v
for v in "${TOKEN_VARS[@]}"; do
args+=(-e "s|@${v}@|${!v-}|g")
done
sed "${args[@]}" "$1"
}
check_rendered() { # check_rendered <src> <rendered-file> — fail on a leftover @TOKEN@
local left
left=$(grep -oE "$TOKEN_RE" "$2" | sort -u | tr '\n' ' ') || true
[ -z "$left" ] && return 0
echo "deploy: $1: unrendered token(s) after render: $left" >&2
return 1
}
install_rendered() { # install_rendered <src> <dst> [mode]
local tmp
tmp=$(mktemp)
render "$1" > "$tmp"
check_rendered "$1" "$tmp" || { rm -f "$tmp"; exit 1; }
install -D -m"${3:-0644}" "$tmp" "$2"
rm -f "$tmp"
}
template_files() { # every repo file that carries an @TOKEN@ placeholder
grep -rlE "$TOKEN_RE" --exclude-dir=.git --exclude-dir=__pycache__ --exclude-dir=.superpowers \
--exclude-dir=docs --exclude-dir=tests --exclude-dir=hosts \
--exclude='*.bak' --exclude='*.md' --exclude=deploy.sh . | sed 's|^\./||' | sort
}
case "${1:-}" in
--templates)
template_files
exit 0 ;;
--check) # ./deploy.sh --check [conf] — validate conf, render every template, install nothing
load_conf "${2:-$VANLINK_CONF}"
rc=0
tmp=$(mktemp)
for f in $(template_files); do
render "$f" > "$tmp"
check_rendered "$f" "$tmp" || rc=1
done
rm -f "$tmp"
[ "$rc" -eq 0 ] && echo "check OK: $(template_files | wc -l) templates render cleanly"
exit "$rc" ;;
--diff) # ./deploy.sh --diff [conf] — what would change in /etc, without installing
load_conf "${2:-$VANLINK_CONF}"
grep -E '^[[:space:]]*install_rendered +[^" ]+ +/' deploy.sh | while read -r _ src dst _; do
if [ ! -e "$dst" ]; then
echo "NEW $dst"
elif ! diff -q <(render "$src") "$dst" >/dev/null; then
echo "CHANGED $dst"
diff -u "$dst" <(render "$src") | sed 's/^/ /' || true
fi
done
exit 0 ;;
render)
[ -n "${2:-}" ] || { echo "Usage: $0 render <file>"; exit 1; }
load_conf "$VANLINK_CONF"
render "$2"
exit 0 ;;
esac
[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }
load_conf "$VANLINK_CONF"
echo "== host config: $(readlink -f "$VANLINK_CONF") =="
# Check for essential package dependencies
echo "== dependency check =="
REQUIRED_PACKAGES=(
hostapd
iw
nftables
dnsmasq
systemd-resolved
python3-gps
python3-bleak
modemmanager
usb-modeswitch
gpsd
gpsd-clients
rsync
mosquitto
python3-paho-mqtt
mobile-broadband-provider-info
zerotier-one
)
if [ "$ENABLE_HA" = 1 ]; then
REQUIRED_PACKAGES+=(podman)
fi
OPTIONAL_PACKAGES=(
)
for pkg in "${REQUIRED_PACKAGES[@]}"; do
dpkg -s "$pkg" >/dev/null 2>&1 || { echo "MISSING REQUIRED: $pkg (apt install $pkg)"; exit 1; }
done
# Note: warn() is defined below; we'll check optional packages after it's defined
# Collected below and persisted to WARNINGS_FILE so the Cockpit vanrouter page
# can surface deploy-time issues (missing deps, unedited example configs, drift)
# without someone having to remember to scroll back through deploy output.
WARNINGS=()
WARNINGS_FILE=/var/lib/vanlink/deploy-warnings.json
warn() { # warn <message...> — prints " -> <message>" (as before) and records it
echo " -> $*"
WARNINGS+=("$*")
}
# Check optional packages now that warn() is defined
for pkg in "${OPTIONAL_PACKAGES[@]}"; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
warn "optional package not installed: $pkg (apt install $pkg)"
fi
done
write_warnings() {
install -d -m0755 "$(dirname "$WARNINGS_FILE")"
{
printf '{\n "deployed": "%s",\n "warnings": [' "$(date -Iseconds)"
local first=1 w e
for w in "${WARNINGS[@]}"; do
[ "$first" = 1 ] || printf ','
first=0
e=${w//\\/\\\\}; e=${e//\"/\\\"}
printf '\n "%s"' "$e"
done
printf '\n ]\n}\n'
} > "$WARNINGS_FILE"
}
# A host file with uncommitted edits means this deploy uses unreviewed values.
conf_real=$(readlink -f "$VANLINK_CONF")
if git -c safe.directory='*' -C "$(dirname "$conf_real")" status --porcelain -- "$conf_real" 2>/dev/null | grep -q .; then
warn "$conf_real has uncommitted changes — commit it so the host config stays reviewable"
fi
echo "== netplan drift check =="
# Netplan is never installed by this script (applying it flaps the uplinks —
# see note below), so it's easy to edit ap/50-van-wan.yaml and forget the
# manual `netplan apply` step. Warn loudly rather than silently drifting.
if [ ! -f /etc/netplan/50-van-wan.yaml ]; then
warn "netplan: /etc/netplan/50-van-wan.yaml is missing — repo config was never deployed. Run: sudo ./deploy.sh render ap/50-van-wan.yaml | sudo tee /etc/netplan/50-van-wan.yaml && sudo netplan generate && sudo netplan apply"
elif ! diff -q <(render ap/50-van-wan.yaml) /etc/netplan/50-van-wan.yaml >/dev/null 2>&1; then
warn "netplan: /etc/netplan/50-van-wan.yaml differs from ap/50-van-wan.yaml (rendered). Deploy manually: sudo ./deploy.sh render ap/50-van-wan.yaml | sudo tee /etc/netplan/50-van-wan.yaml && sudo netplan generate && sudo netplan apply"
diff -u /etc/netplan/50-van-wan.yaml <(render ap/50-van-wan.yaml) || true
fi
echo "== access point =="
install_rendered ap/hostapd.conf /etc/hostapd/hostapd.conf
install -D -m0644 ap/hostapd-restart.conf /etc/systemd/system/hostapd.service.d/restart.conf
install -D -m0644 ap/hostapd-regdomain.conf /etc/systemd/system/hostapd.service.d/regdomain.conf
install -D -m0644 ap/default-hostapd /etc/default/hostapd
install_rendered ap/hostapd-2g.conf /etc/hostapd/hostapd-2g.conf
install_rendered ap/hostapd-2g.service /etc/systemd/system/hostapd-2g.service
install -D -m0644 ap/hostapd-regdomain.conf /etc/systemd/system/hostapd-2g.service.d/regdomain.conf
install_rendered ap/11-van-ap-2g.network /etc/systemd/network/11-van-ap-2g.network
install -D -m0644 ap/van-ap-watchdog-2g.service /etc/systemd/system/van-ap-watchdog-2g.service
install -D -m0755 ap/van-ap-watchdog /usr/local/sbin/van-ap-watchdog
install -D -m0644 ap/van-ap-watchdog.service /etc/systemd/system/van-ap-watchdog.service
install_rendered ap/van-ap-dnsmasq.conf /etc/van-ap/dnsmasq.conf
install -D -m0644 ap/van-ap-dnsmasq.service /etc/systemd/system/van-ap-dnsmasq.service
install_rendered ap/10-van-ap.network /etc/systemd/network/10-van-ap.network
install -D -m0644 ap/20-van-br0.netdev /etc/systemd/network/20-van-br0.netdev
install_rendered ap/21-van-br0.network /etc/systemd/network/21-van-br0.network
# Named 05- (not 22-) so it sorts before Armbian's netplan-generated
# /run/systemd/network/10-netplan-all-eth-interfaces.network, which matches
# every e* port with DHCP and otherwise wins over this file (first match by
# filename), leaving the LAN port un-bridged.
install_rendered ap/22-van-lan.network /etc/systemd/network/05-van-lan.network
rm -f /etc/systemd/network/22-van-lan.network
if [ -n "${ONBOARD_LAN_IFACE:-}" ]; then
install_rendered ap/23-van-lan-eth0.network /etc/systemd/network/23-van-lan-eth0.network
else
rm -f /etc/systemd/network/23-van-lan-eth0.network
fi
install_rendered ap/van-ap-unmanaged.conf /etc/NetworkManager/conf.d/van-ap-unmanaged.conf
# Mask dracut's initramfs-generated catch-all (/run/systemd/network/
# zzzz-dracut-default.network, regenerated every boot): it matches every
# unconfigured link, so networkd co-managed the NM-owned WANs — a second
# DHCPv4 client on wlan0 and IPv6 that worked only by accident. NM owns WAN
# IPv6 now (see ap/50-van-wan.yaml). /etc overrides /run; /dev/null masks.
ln -sfn /dev/null /etc/systemd/network/zzzz-dracut-default.network
install_rendered ap/nftables.conf /etc/nftables.conf
install -D -m0644 ap/regdomain.service /etc/systemd/system/regdomain.service
install_rendered ap/van-br0-setup.service /etc/systemd/system/van-br0-setup.service
install -D -m0644 ap/80-regdomain.rules /etc/udev/rules.d/80-regdomain.rules
install -D -m0644 ap/rtl8188gu-modeswitch.conf /etc/usb-modeswitch.d/0bda:1a2b
install -D -m0644 ap/rtw89.conf /etc/modprobe.d/rtw89.conf
install -D -m0644 ap/99-van-router.conf /etc/sysctl.d/99-van-router.conf
echo "== dns =="
# Disable systemd-resolved DNS listener — dnsmasq handles DNS+DHCP on bridge.
# Even localhost-only listening blocks dnsmasq from binding to port 53.
install -D -m0644 ap/systemd-resolved-disable.conf /etc/systemd/system/systemd-resolved.service.d/disable.conf
# Fixed upstream resolvers (deploy.conf's DNS_RESOLVERS), never a WAN's own
# DHCP/RA-provided DNS — see the files themselves for the full rationale.
# (NM's ipv4/ipv6.ignore-auto-dns can't be set as a config-file connection
# default — NM rejects it there — so 60-van-wan-dns enforces this directly
# against resolved instead, on every WAN connect/lease event.)
# .local (mDNS) is handled separately, scoped to br0 (see 21-van-br0.network
# above). ZeroTier-managed DNS (zt.wrede.pvt) is a separate, additive path.
install_rendered ap/99-van-router-dns.conf /etc/systemd/resolved.conf.d/99-van-router-dns.conf
install -D -m0755 failover/60-van-wan-dns /etc/NetworkManager/dispatcher.d/60-van-wan-dns
# ZeroTier-managed DNS for zt.wrede.pvt (see README's "ZeroTier managed DNS").
# allowDNS on the network + the search-domain drop-in are repo-tracked so a
# reimage doesn't need the manual `zerotier-cli set ... allowDNS=1` step
# remembered by hand; the manager binary itself is a hand-installed .deb
# (not in apt) — see https://github.com/zerotier/zerotier-systemd-manager/releases.
install -D -m0600 dns/zt-network.local.conf /var/lib/zerotier-one/networks.d/d3ecf5726d041b2a.local.conf
install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf
dpkg -s zerotier-systemd-manager >/dev/null 2>&1 \
|| warn "zerotier-systemd-manager not installed — zt.wrede.pvt won't resolve. Install the arm64 .deb from https://github.com/zerotier/zerotier-systemd-manager/releases"
echo "== failover =="
install -D -m0755 failover/van-failover /usr/local/sbin/van-failover
install_rendered failover/config.json /etc/van-failover/config.json
install -D -m0644 failover/van-failover.service /etc/systemd/system/van-failover.service
install -D -m0755 failover/50-disable-eee /etc/NetworkManager/dispatcher.d/50-disable-eee
install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
# Backstop for wlan0's post-boot NM no-secrets wedge (see the script's docstring).
install -D -m0755 failover/van-wlan-watchdog /usr/local/sbin/van-wlan-watchdog
install_rendered failover/van-wlan-watchdog.service /etc/systemd/system/van-wlan-watchdog.service
echo "== cellular modem (GSM/LTE) =="
# NM's gsm.auto-config APN lookup needs this apt-only carrier database; without
# it even the right APN can't be auto-detected, and MVNOs (e.g. Koodo, which
# isn't listed under its own name — only under host network "Telus Mobility")
# often aren't in it anyway, so the modem's NM connection profile may still
# need an explicit gsm.apn set by hand regardless.
dpkg -s mobile-broadband-provider-info >/dev/null 2>&1 \
|| warn "mobile-broadband-provider-info missing (apt install mobile-broadband-provider-info) — GSM APN auto-config will fail"
# Backstop for the modem sometimes never enumerating at boot (see the
# script's docstring) — detection + Pushover alert only. No automated
# recovery: a hub power-cycle was proven not to fix this (only a genuine
# physical unplug/replug does), so the modem is now on the Pi's native USB
# port rather than through the hub. This just pages if it ever recurs.
install -D -m0755 failover/van-modem-watch /usr/local/sbin/van-modem-watch
install_rendered failover/van-modem-watch.service /etc/systemd/system/van-modem-watch.service
# SMS archive + Pushover relay: polls ModemManager for inbound SMS, appends
# each to /var/log/van-sms.jsonl, pages via Pushover, then deletes it from
# the modem's own flash storage (small, fills up silently otherwise). MMS
# can't be fetched here (see script docstring) — notification-only paging.
install -D -m0755 modem/van-sms-watch /usr/local/sbin/van-sms-watch
install -D -m0644 modem/van-sms-watch.service /etc/systemd/system/van-sms-watch.service
# CLI to send an SMS ad hoc (needs sudo — ModemManager's Messaging actions
# are PolicyKit-gated). Not a service, just a tool: sudo van-sms-send <number> <text>
install -D -m0755 modem/van-sms-send /usr/local/sbin/van-sms-send
if [ "$ENABLE_COCKPIT" = 1 ]; then
echo "== cockpit plugin =="
install -d /usr/share/cockpit/vanrouter
for f in cockpit/vanrouter/*; do
b=$(basename "$f")
if [ "$b" = "vanrouter.js" ]; then
install_rendered "$f" "/usr/share/cockpit/vanrouter/$b"
else
install -m0644 "$f" "/usr/share/cockpit/vanrouter/$b"
fi
done
# Bridge fd headroom (Python bridge frees spawn pipes only at GC; 1024 is too tight)
install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpit-session@.service.d/nofile.conf
fi
# The Starlink card queries the dish's gRPC API; grpcurl isn't packaged in apt.
command -v grpcurl >/dev/null 2>&1 \
|| warn "grpcurl missing (Starlink card will say so): install linux_arm64 binary from github.com/fullstorydev/grpcurl/releases"
echo "== router status (MQTT -> Home Assistant, replacing the Cockpit plugin above) =="
# Same status/actions as the cockpit plugin, published via MQTT discovery
# instead of a web UI of its own (Cockpit's login was unreliable and it was a
# resource hog on this Pi). No secrets in config.json (the local Mosquitto
# broker currently allows anonymous connections) so it's rendered + installed
# every deploy, same as failover/config.json.
install -D -m0755 router-status/van-router-mqtt /usr/local/sbin/van-router-mqtt
install_rendered router-status/config.json /etc/van-router-mqtt/config.json
install -D -m0644 router-status/van-router-mqtt.service /etc/systemd/system/van-router-mqtt.service
python3 -c 'import paho.mqtt' 2>/dev/null \
|| warn "python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-router-mqtt won't start"
# MQTT broker must listen on all interfaces (not just localhost) for clients on br0
install -D -m0644 router-status/mosquitto-listener.conf /etc/mosquitto/conf.d/van-listener.conf
# Restart limit drop-ins: prevent infinite restart loops that trigger hardware watchdog
install -D -m0644 router-status/van-gps-owntracks-restart-limit.conf /etc/systemd/system/van-gps-owntracks.service.d/restart-limit.conf
echo "== gps (cellular modem GNSS -> gpsd) =="
if dpkg -s gpsd >/dev/null 2>&1; then
install -D -m0644 gps/77-modem-gps.rules /etc/udev/rules.d/77-modem-gps.rules
install -D -m0644 gps/gpsd.default /etc/default/gpsd
udevadm control --reload
else
warn "gpsd not installed (apt install gpsd gpsd-clients) — skipping GPS setup"
fi
# OwnTracks publisher: gpsd fix -> MQTT (broker + creds live only on the
# system, 0600 — same pattern as pushover.json).
install -D -m0755 gps/van-gps-owntracks /usr/local/sbin/van-gps-owntracks
install -D -m0644 gps/van-gps-owntracks.service /etc/systemd/system/van-gps-owntracks.service
if [ ! -f /etc/van-gps/config.json ]; then
install -D -m0600 gps/config.json.example /etc/van-gps/config.json
warn "seeded /etc/van-gps/config.json (EDIT IT: add MQTT username + password)"
fi
python3 -c 'import gps' 2>/dev/null \
|| warn "python3-gps missing (apt install python3-gps) — van-gps-owntracks won't start"
python3 -c 'import paho.mqtt' 2>/dev/null \
|| warn "python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-gps-owntracks won't start"
if [ "$ENABLE_LI3" = 1 ]; then
echo "== li3 (RV house battery BMS -> MQTT/HA) =="
# NOT the laptop's own AC/battery monitor — that's power/van-battery (different
# hardware, different concern). This is the Lithionics Li3 12V LiFePO4 house
# battery, over its BLE HM-10 UART module (no pairing) -> Home Assistant MQTT
# discovery. Broker + creds live only on the system (0600, seeded from
# li3/config.json.example — same pattern as gps/pushover).
install -D -m0755 li3/van-li3-battery /usr/local/sbin/van-li3-battery
install -D -m0644 li3/van-li3-battery.service /etc/systemd/system/van-li3-battery.service
install -D -m0644 li3/van-li3-battery-restart-limit.conf /etc/systemd/system/van-li3-battery.service.d/restart-limit.conf
if [ ! -f /etc/van-li3/config.json ]; then
install -D -m0600 li3/config.json.example /etc/van-li3/config.json
warn "seeded /etc/van-li3/config.json (EDIT IT: add MQTT username + password)"
fi
python3 -c 'import bleak' 2>/dev/null \
|| warn "python3-bleak missing (apt install python3-bleak) — van-li3-battery won't start"
fi
echo "== thermal monitor =="
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal
install -D -m0644 power/thermal-config.json /etc/van-thermal/config.json
install -D -m0644 power/van-thermal.service /etc/systemd/system/van-thermal.service
# Pushover secrets live only on the system (0600), never in the repo. Path kept
# under /etc/van-battery/ for parity with wayback's van-thermal default.
if [ ! -f /etc/van-battery/pushover.json ]; then
install -D -m0600 power/pushover.json.example /etc/van-battery/pushover.json
warn "seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)"
fi
if [ "$ENABLE_NVME_WATCH" = 1 ]; then
echo "== nvme watchdog =="
install -D -m0755 power/van-nvme-watch /usr/local/sbin/van-nvme-watch
install_rendered power/nvme-watch-config.json /etc/van-nvme-watch/config.json
install -D -m0644 power/van-nvme-watch.service /etc/systemd/system/van-nvme-watch.service
fi
echo "== home assistant =="
# Native HA (Podman Quadlet, replaced the ha_van VM). daemon-reload below
# regenerates homeassistant.service; started (not restarted) at the end so a
# deploy never bounces HA — after editing the .container, restart it manually.
if [ "$ENABLE_HA" = 1 ]; then
install -D -m0644 ha/homeassistant.container /etc/containers/systemd/homeassistant.container
install -d -m0755 /srv/homeassistant
# ESPHome dashboard (sibling container, same rationale as HA above — no
# Supervisor/add-on store here).
install -D -m0644 ha/esphome.container /etc/containers/systemd/esphome.container
install -d -m0755 /srv/esphome
fi
# Frigate NVR (sibling container, same rationale as HA/ESPHome above).
if [ "$ENABLE_FRIGATE" = 1 ]; then
install -D -m0644 ha/frigate.container /etc/containers/systemd/frigate.container
install -d -m0755 /srv/frigate/config /srv/frigate/storage
fi
echo "== hardware watchdog =="
install -D -m0644 power/10-vanlink-watchdog.conf /etc/systemd/system.conf.d/10-vanlink-watchdog.conf
echo "== apply =="
sysctl --system >/dev/null
systemctl daemon-reload
# Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload
# alone does NOT re-arm RuntimeWatchdogSec). Safe online.
systemctl daemon-reexec
# Safe online: picks up 99-van-router-dns.conf immediately. NM's
# ignore-auto-dns only takes effect on a connection's next activation though —
# an already-up WAN keeps its currently-applied DNS until it reconnects (or
# reboot), deliberately not forced here (reconnecting a WAN flaps it).
systemctl restart systemd-resolved
systemctl restart mosquitto
# networkd here owns only the AP radios + bridge + wired LAN port (no real uplink);
# its wait-online would just stall network-online.target. NM-wait-online covers WANs.
systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
systemctl unmask hostapd >/dev/null 2>&1 || true
# Disable system dnsmasq.service to avoid port 53 conflicts — van-ap-dnsmasq owns AP DHCP
systemctl disable dnsmasq.service >/dev/null 2>&1 || true
ENABLE_UNITS=(regdomain.service van-br0-setup.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-modem-watch van-sms-watch van-gps-owntracks van-router-mqtt)
if [ "$ENABLE_NVME_WATCH" = 1 ]; then
ENABLE_UNITS+=(van-nvme-watch)
fi
if [ "$ENABLE_LI3" = 1 ]; then
ENABLE_UNITS+=(van-li3-battery)
fi
systemctl enable "${ENABLE_UNITS[@]}" >/dev/null 2>&1 || true
# bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus
systemctl enable --now bluetooth >/dev/null 2>&1 || true
if [ "$ENABLE_HA" = 1 ]; then
systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)"
systemctl start esphome || warn "esphome failed to start (podman/quadlet — check journalctl -u esphome)"
fi
if [ "$ENABLE_FRIGATE" = 1 ]; then
systemctl start frigate || warn "frigate failed to start (podman/quadlet — check journalctl -u frigate)"
fi
systemctl restart van-thermal
if [ "$ENABLE_NVME_WATCH" = 1 ]; then
systemctl restart van-nvme-watch
fi
systemctl restart van-sms-watch
systemctl restart van-gps-owntracks
if [ "$ENABLE_LI3" = 1 ]; then
systemctl restart van-li3-battery
fi
systemctl restart van-router-mqtt
# Pick up unmanaged-devices changes so NM releases/keeps the right interfaces.
nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true
# restart in dependency order: bridge + members first, then hostapd enslaves the
# radios, then dnsmasq binds br0, then NAT/failover
systemctl restart systemd-networkd
for u in van-ap-dnsmasq nftables van-failover van-wlan-watchdog; do
systemctl restart "$u" || warn "$u failed to restart (journalctl -xeu $u)"
done
# The AP radios live on the USB hub and may be absent; the start then fails but
# Restart=always keeps retrying and claims them the moment they enumerate.
systemctl restart hostapd hostapd-2g \
|| echo " -> hostapd(-2g) waiting for AP radios (USB hub not plugged in)"
# AP watchdogs last, after hostapd is back up (they only ever restart a wedged hostapd)
systemctl restart van-ap-watchdog van-ap-watchdog-2g
networkctl reload 2>/dev/null || true
write_warnings
echo
echo "Deployed. NOTE: until the USB hub (AP radios + LAN/Starlink adapters) is"
echo "plugged in, hostapd/hostapd-2g just retry every 5s — that is by design."
echo "Verify (with hub present):"
echo " iw dev $WIFI_5G_IFACE info | grep -E 'ssid|channel|width'"
echo " iw dev $WIFI_2G_IFACE info | grep -E 'ssid|channel|width'"
echo " cat /run/van-failover/state.json"
echo " cat /run/van-thermal/state.json"
echo " cat /run/van-nvme-watch/state.json"
echo " journalctl -u van-li3-battery -n 20"
echo " journalctl -u van-router-mqtt -n 20 ; mosquitto_sub -h localhost -t 'van/router/#' -v -C 8"
echo " journalctl -u van-sms-watch -n 20 ; tail -f /var/log/van-sms.jsonl"