Files
vanlink/docs/superpowers/plans/2026-09-23-host-config.md
2026-09-23 13:42:57 -04:00

1025 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Host-local Configuration (`/etc/vanlink.conf`) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the tracked, hand-edited `deploy.conf` with one versioned file per host under `hosts/`, reached through an `/etc/vanlink.conf` symlink, so the Pi 5 and the R5C share one git checkout and cannot drift.
**Architecture:** `deploy.sh` sources `/etc/vanlink.conf`, validates it, and renders `@TOKEN@` placeholders in every template (generic loop over a variable list). `install_rendered` refuses output that still contains a token. Optional services are gated by `ENABLE_*` flags. A plain-bash test script (`tests/check-templates.sh`) statically enforces the rules that were broken on 2026-09-23.
**Tech Stack:** bash (`set -euo pipefail`), sed, awk, systemd units, networkd/NetworkManager/hostapd/dnsmasq/nftables configs. No test framework: `tests/check-templates.sh` prints `ok`/`FAIL` lines and exits non-zero on failure.
**Spec:** `docs/superpowers/specs/2026-09-23-host-config-design.md`
## Global Constraints
- Host config path is `/etc/vanlink.conf` (override with env `VANLINK_CONF`); host files are `hosts/<hostname -s>.conf`: `hosts/van.conf` (R5C, hostname `van`) and `hosts/wan.conf` (Pi 5, hostname `wan`).
- Host files contain **no secrets** (they are in git and read by root).
- Required variables: `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`.
- Optional variables: `ONBOARD_LAN_IFACE NVME_DEVICE NVME_FS_DEVICE USB_BACKUP_ROOT_PART USB_BACKUP_MOUNT SD_BACKUP_UUID`. `NVME_DEVICE` and `NVME_FS_DEVICE` are required only when `ENABLE_NVME_WATCH=1`.
- `ENABLE_*` flags are exactly `0` or `1`.
- `LAN_USB_IFACE` is renamed `LAN_IFACE` everywhere; the JSON key `lan_usb_iface` in `router-status/config.json` is **not** renamed (consumer-facing).
- `deploy.sh` never creates or edits `/etc/vanlink.conf` or `hosts/*.conf`.
- `deploy.sh` `--check`, `--templates`, `--diff` and `render` need no root and install nothing.
- LAN bridge file installs as `/etc/systemd/network/05-van-lan.network` (sorts before netplan's `10-` file).
- Commit messages end with `Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>`.
- Do not touch the `.bak` files, `ap/50-van-wan.yaml`'s PSK, or `power/thermal-config.json` (out of scope per spec).
## File Structure
| File | Responsibility |
|---|---|
| `deploy.sh` (modify) | load/validate host conf, render, install, flag gating, `--check/--templates/--diff` |
| `hosts/vanlink.conf.example` (create) | documented reference; defines exactly the variables `deploy.sh` knows |
| `hosts/van.conf`, `hosts/wan.conf` (create) | real values for the two hosts |
| `deploy.conf` (delete) | replaced by the above |
| `tests/check-templates.sh` (create) | static checks, run from anywhere, no root |
| `ap/*`, `failover/*`, `router-status/config.json`, `power/nvme-watch-config.json` (modify) | hardcoded host values become `@TOKEN@`s |
| `failover/sync-usb-backup.sh`, `failover/sync-sd-backup.sh` (modify) | read backup settings from the host conf |
| `README.md`, misc comments (modify) | describe the shared model |
---
### Task 1: Config core, host files, test harness
**Files:**
- Modify: `deploy.sh` (top section, lines 1-47)
- Create: `tests/check-templates.sh`, `hosts/vanlink.conf.example`, `hosts/van.conf`, `hosts/wan.conf`
- Delete: `deploy.conf`
- Modify (rename token): every file containing `LAN_USB_IFACE` except `.bak` and `docs/`
**Interfaces:**
- Produces (used by all later tasks), in `deploy.sh`: arrays `REQUIRED_VARS`, `OPTIONAL_VARS`, `DERIVED_VARS`, `TOKEN_VARS`; string `TOKEN_RE`; functions `die`, `load_conf <file>`, `render <file>`, `check_rendered <src> <rendered>`, `install_rendered <src> <dst> [mode]`, `template_files`; sub-commands `--check [conf]`, `--templates`, `render <file>`. Derived var `ONBOARD_LAN_UNMANAGED` (`;interface-name:<iface>` or empty).
- Produces in `tests/check-templates.sh`: helper functions `ok <msg>` / `bad <msg>` and variable `fail`; later tasks append numbered check sections before the final `exit "$fail"`.
- [ ] **Step 1: Commit the pending `deploy.sh` fix**
The working tree has an uncommitted fix from 2026-09-23 (dnsmasq and br0 rendered; LAN file installed as `05-`).
Run: `git diff --stat` — expected: only `deploy.sh`, ~8 insertions / 3 deletions.
```bash
git add deploy.sh
git commit -m "deploy: render dnsmasq/br0 configs, install LAN file as 05-van-lan
van-ap-dnsmasq.conf and 21-van-br0.network carry @TOKEN@ placeholders but
were installed unrendered. Armbian's netplan-generated 10-netplan-* file
matched the LAN port before 22-van-lan.network, so it was never bridged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
```
- [ ] **Step 2: Write the test script (checks 1, 3, 4) and see it fail**
Create `tests/check-templates.sh` (mode 0755):
```bash
#!/usr/bin/env bash
# Static checks for the host-config templating. No root needed.
# ./tests/check-templates.sh
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")/.."
fail=0
ok() { echo "ok - $*"; }
bad() { echo "FAIL - $*"; fail=1; }
# 1. shell syntax
for f in deploy.sh failover/sync-usb-backup.sh failover/sync-sd-backup.sh tests/check-templates.sh; do
if bash -n "$f"; then ok "bash -n $f"; else bad "bash -n $f"; fi
done
# 3. every host file passes deploy.sh --check (validates vars, renders all templates)
for c in hosts/*.conf; do
if out=$(./deploy.sh --check "$c" 2>&1); then
ok "--check $c"
else
bad "--check $c"; echo "$out" | sed 's/^/ /'
fi
done
# 4. the example defines exactly the variables deploy.sh knows about
known=$(sed -n '/^REQUIRED_VARS=(/,/^)/p;/^OPTIONAL_VARS=(/,/^)/p' deploy.sh \
| grep -vE '^[A-Z_]+=\(|^\)' | tr -s ' \t' '\n' | grep -v '^$' | sort || true)
defined=$(grep -oE '^[A-Z][A-Z0-9_]*=' hosts/vanlink.conf.example 2>/dev/null | tr -d = | sort || true)
if [ -n "$known" ] && [ "$known" = "$defined" ]; then
ok "hosts/vanlink.conf.example defines exactly the variables deploy.sh knows"
else
bad "hosts/vanlink.conf.example differs from deploy.sh's variable lists"
diff <(echo "$known") <(echo "$defined") | sed 's/^/ /' || true
fi
exit "$fail"
```
Run: `chmod +x tests/check-templates.sh && ./tests/check-templates.sh; echo "exit=$?"`
Expected: FAIL lines for `--check hosts/*.conf` (glob matches nothing / no `--check`) and for the example; `exit=1`.
- [ ] **Step 3: Create the host files**
`hosts/vanlink.conf.example`:
```bash
# hosts/vanlink.conf.example — reference for /etc/vanlink.conf.
#
# Each host has hosts/<hostname>.conf (hostname -s) in this repo, and
# /etc/vanlink.conf is a symlink to it:
# sudo ln -sfn "$PWD/hosts/$(hostname -s).conf" /etc/vanlink.conf
# Plain KEY=value (it is sourced by bash). NO SECRETS — this file is in git.
# Validate without root: ./deploy.sh --check hosts/<hostname>.conf
# Find interface names with `ip -br link` (wired) / `iw dev` (wifi); USB IDs with lsusb.
# --- Interfaces -------------------------------------------------------------
WIFI_5G_IFACE=wlp1s0 # 5GHz AP radio (hostapd)
WIFI_2G_IFACE=wlx000000000000 # 2.4GHz AP radio (hostapd-2g)
LAN_IFACE=enp1s0 # wired LAN port, bridged into br0
STARLINK_IFACE=enp2s0 # Starlink uplink (NetworkManager WAN)
WIFI_WAN_IFACE=wlan0 # Wi-Fi uplink (NetworkManager WAN)
ONBOARD_LAN_IFACE= # optional: extra onboard LAN port bridged into br0 (e.g. eth0); empty = none
# --- Addressing ---------------------------------------------------------------
GATEWAY_IP="10.42.9.1"
GATEWAY_PREFIX="24"
GATEWAY_NETWORK="10.42.9.0/24"
DHCP_RANGE_START="10.42.9.10"
DHCP_RANGE_END="10.42.9.254"
DNS_RESOLVERS="1.1.1.1 8.8.8.8"
# --- Hardware -----------------------------------------------------------------
MODEM_USB_VENDOR=2c7c # cellular modem USB vendor id
NVME_DEVICE= # only when ENABLE_NVME_WATCH=1, e.g. nvme0
NVME_FS_DEVICE= # only when ENABLE_NVME_WATCH=1, e.g. /dev/nvme0n1p2
# --- Backup (read by failover/sync-*-backup.sh; leave empty if unused) ----------
USB_BACKUP_ROOT_PART= # sync-usb-backup.sh: partition to mount, e.g. /dev/sda2
USB_BACKUP_MOUNT= # sync-usb-backup.sh: local mount point, or host:/path for NFS
SD_BACKUP_UUID= # sync-sd-backup.sh: filesystem UUID of the backup SD card
# --- Feature flags (0 or 1) ---------------------------------------------------
ENABLE_COCKPIT=0 # Cockpit "Van Router" plugin
ENABLE_LI3=0 # Li3 house-battery BLE -> MQTT service
ENABLE_NVME_WATCH=0 # NVMe timeout watchdog (needs an NVMe drive)
ENABLE_HA=0 # Home Assistant + ESPHome (podman quadlets)
ENABLE_FRIGATE=0 # Frigate NVR (podman quadlet)
```
`hosts/van.conf` (R5C; values from its current, interface-swapped `deploy.conf`, addressing and flags from what runs there today):
```bash
# hosts/van.conf — FriendlyElec NanoPi R5C, hostname "van" (Armbian).
# /etc/vanlink.conf -> this file. See hosts/vanlink.conf.example for the variable list.
WIFI_5G_IFACE=wlp1s0 # MediaTek MT7925E (PCIe); also carries the 2.4GHz AP
WIFI_2G_IFACE=wlx3c3332002066
LAN_IFACE=enP1p17s0
STARLINK_IFACE=enP2p33s0
WIFI_WAN_IFACE=wlan0 # not present on this host (kept for the failover config)
ONBOARD_LAN_IFACE=
GATEWAY_IP="10.42.1.1"
GATEWAY_PREFIX="24"
GATEWAY_NETWORK="10.42.1.0/24"
DHCP_RANGE_START="10.42.1.10"
DHCP_RANGE_END="10.42.1.254"
DNS_RESOLVERS="1.1.1.1 8.8.8.8"
MODEM_USB_VENDOR=2c7c
NVME_DEVICE=
NVME_FS_DEVICE=
USB_BACKUP_ROOT_PART=
USB_BACKUP_MOUNT=
SD_BACKUP_UUID=39118305-4927-47a4-acc7-ba022f37af22
ENABLE_COCKPIT=0
ENABLE_LI3=1
ENABLE_NVME_WATCH=0 # no NVMe on this board
ENABLE_HA=1
ENABLE_FRIGATE=1
```
`hosts/wan.conf` (Pi 5; values from its uncommitted `deploy.conf` and live state):
```bash
# hosts/wan.conf — Raspberry Pi 5, hostname "wan".
# /etc/vanlink.conf -> this file. See hosts/vanlink.conf.example for the variable list.
WIFI_5G_IFACE=wlxc83a35a4ee55 # Realtek RTL8852BU, rtw89
WIFI_2G_IFACE=wlxf4f26d1760a7 # D-Link DWA-171, RTL8821CU
LAN_IFACE=enx00e04c331140
STARLINK_IFACE=enx3c8cf861752b # TrendNet USB dongle
WIFI_WAN_IFACE=wlan0
ONBOARD_LAN_IFACE=eth0
GATEWAY_IP="10.42.0.1"
GATEWAY_PREFIX="24"
GATEWAY_NETWORK="10.42.0.0/24"
DHCP_RANGE_START="10.42.0.10"
DHCP_RANGE_END="10.42.0.254"
DNS_RESOLVERS="1.1.1.1 8.8.8.8"
MODEM_USB_VENDOR=2c7c
NVME_DEVICE=
NVME_FS_DEVICE=
USB_BACKUP_ROOT_PART=/dev/sda2
USB_BACKUP_MOUNT=10.42.0.44:/media/andreas/writable-nvme
SD_BACKUP_UUID=
ENABLE_COCKPIT=0
ENABLE_LI3=0
ENABLE_NVME_WATCH=0 # boots from mmcblk0; no NVMe fitted
ENABLE_HA=1
ENABLE_FRIGATE=0
```
- [ ] **Step 4: Replace the `deploy.conf` block at the top of `deploy.sh`**
In `deploy.sh`, replace everything from the line `# deploy.conf holds hardware-instance identifiers` through the closing `fi` of the `render` sub-command (currently lines 11-45) with:
```bash
# 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=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" ;;
render)
[ -n "${2:-}" ] || { echo "Usage: $0 render <file>"; exit 1; }
load_conf "$VANLINK_CONF"
render "$2"
exit 0 ;;
esac
```
Then, directly after the existing line `[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }`, add:
```bash
load_conf "$VANLINK_CONF"
echo "== host config: $(readlink -f "$VANLINK_CONF") =="
```
- [ ] **Step 5: Rename `LAN_USB_IFACE` to `LAN_IFACE` in the remaining files and delete `deploy.conf`**
```bash
grep -rlI 'LAN_USB_IFACE' --exclude-dir=.git --exclude='*.bak' --exclude-dir=docs . | grep -v '^./deploy.conf$'
```
Expected: `ap/22-van-lan.network`, `ap/van-ap-unmanaged.conf`, `router-status/config.json`, `cockpit/vanrouter/vanrouter.js` (and possibly others). Rename in exactly those files:
```bash
grep -rlI 'LAN_USB_IFACE' --exclude-dir=.git --exclude='*.bak' --exclude-dir=docs . \
| grep -v '^./deploy.conf$' | xargs sed -i 's/LAN_USB_IFACE/LAN_IFACE/g'
git rm -q deploy.conf
grep -rnI 'LAN_USB_IFACE' --exclude-dir=.git --exclude='*.bak' --exclude-dir=docs . || echo "none left"
```
Expected last line: `none left`. (The lowercase JSON key `lan_usb_iface` is untouched by design.)
- [ ] **Step 6: Run the test and fix any leftover-token findings**
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: all `ok`, `exit=0`. If `--check` reports `unrendered token(s)` for a file, that file has an `@…@` string that is not a defined token: either it is a typo (fix it) or legitimate text (rare; then change `template_files`' exclude list and note why in a comment).
Also run: `./deploy.sh --check hosts/van.conf && VANLINK_CONF=hosts/wan.conf ./deploy.sh render ap/van-ap-dnsmasq.conf | grep dhcp-range`
Expected: `check OK: N templates render cleanly` then `dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h`.
- [ ] **Step 7: Commit**
```bash
git add deploy.sh tests hosts
git add -u
git commit -m "deploy: host config from /etc/vanlink.conf (hosts/<hostname>.conf)
deploy.conf is replaced by one versioned file per host plus an example.
deploy.sh validates the config, renders every template with a generic
token loop, refuses to install output with leftover @TOKEN@s and gains
--check/--templates. LAN_USB_IFACE is renamed LAN_IFACE.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
```
---
### Task 2: Template every remaining hardcoded host value
**Files:**
- Modify: `ap/nftables.conf`, `ap/van-br0-setup.service`, `failover/config.json`, `router-status/config.json`, `ap/50-van-wan.yaml`, `failover/van-wlan-watchdog.service`, `ap/van-ap-unmanaged.conf`, `ap/23-van-lan-eth0.network`, `power/nvme-watch-config.json`, `deploy.sh` (install lines)
- Modify: `tests/check-templates.sh` (add checks 2 and 5)
**Interfaces:**
- Consumes (Task 1): `./deploy.sh --templates`, `--check`, `render`, token vars `WIFI_WAN_IFACE`, `ONBOARD_LAN_IFACE`, `ONBOARD_LAN_UNMANAGED`, `NVME_DEVICE`, `NVME_FS_DEVICE`, `GATEWAY_NETWORK`, `LAN_IFACE`, `WIFI_5G_IFACE`.
- Produces: `tests/check-templates.sh` checks 2 (every templated file installed via `install_rendered`) and 5 (rendered host-specific content).
- [ ] **Step 1: Add checks 2 and 5 to the test script**
Insert before the final `exit "$fail"`:
```bash
# 2. every file carrying an @TOKEN@ is installed through install_rendered
# (plain `install` would ship the placeholder unrendered — the 2026-09-23 failure)
exempt=" cockpit/vanrouter/vanrouter.js " # installed by the loop in the cockpit block
for f in $(./deploy.sh --templates); do
case "$exempt" in *" $f "*) continue ;; esac
if grep -qE "^[[:space:]]*install_rendered +$f[[:space:]]" deploy.sh; then
ok "install_rendered $f"
else
bad "$f contains an @TOKEN@ but deploy.sh does not install it with install_rendered"
fi
done
# 5. host-specific values land in the rendered output
r() { VANLINK_CONF="hosts/$1.conf" ./deploy.sh render "$2"; }
expect() { # expect <host> <file> <fixed string that must appear>
if r "$1" "$2" | grep -qF -- "$3"; then ok "$1: $2 has '$3'"; else bad "$1: $2 lacks '$3'"; fi
}
expect van ap/nftables.conf 'ip saddr 10.42.1.0/24'
expect wan ap/nftables.conf 'ip saddr 10.42.0.0/24'
expect van ap/van-br0-setup.service 'brctl addif br0 enP1p17s0'
expect wan ap/van-br0-setup.service 'brctl addif br0 enx00e04c331140'
expect van failover/config.json '"device": "wlan0"'
expect van router-status/config.json '"wifi_wan_iface": "wlan0"'
expect van failover/van-wlan-watchdog.service 'van-wlan-watchdog wlan0'
expect van ap/van-ap-unmanaged.conf 'interface-name:enP1p17s0'
expect wan ap/van-ap-unmanaged.conf 'interface-name:enx00e04c331140;interface-name:eth0'
expect wan ap/23-van-lan-eth0.network 'Name=eth0'
expect van power/nvme-watch-config.json '"nvme_device": ""'
if r van ap/van-ap-unmanaged.conf | grep -q 'eth0'; then
bad "van: unmanaged-devices must not mention eth0 (ONBOARD_LAN_IFACE is empty)"
else
ok "van: unmanaged-devices has no eth0"
fi
```
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: several `FAIL` lines from check 5 (files not yet templated); check 2 may already pass; `exit=1`.
- [ ] **Step 2: Turn the hardcoded values into tokens**
Make these exact edits:
| File | Old | New |
|---|---|---|
| `ap/nftables.conf` | `ip saddr 10.42.1.0/24 oifname != "br0" masquerade;` | `ip saddr @GATEWAY_NETWORK@ oifname != "br0" masquerade;` |
| `ap/van-br0-setup.service` | `brctl addif br0 enP2p33s0 2>/dev/null \|\| true` | `brctl addif br0 @LAN_IFACE@ 2>/dev/null \|\| true` |
| `ap/van-br0-setup.service` | `brctl addif br0 wlp1s0 2>/dev/null \|\| true` | `brctl addif br0 @WIFI_5G_IFACE@ 2>/dev/null \|\| true` |
| `failover/config.json` | `"device": "wlan0",` | `"device": "@WIFI_WAN_IFACE@",` |
| `router-status/config.json` | `"wifi_wan_iface": "wlan0"` | `"wifi_wan_iface": "@WIFI_WAN_IFACE@"` |
| `ap/50-van-wan.yaml` | ` wlan0:` (the line under `wifis:`) | ` @WIFI_WAN_IFACE@:` |
| `failover/van-wlan-watchdog.service` | `ExecStart=/usr/local/sbin/van-wlan-watchdog` | `ExecStart=/usr/local/sbin/van-wlan-watchdog @WIFI_WAN_IFACE@` |
| `ap/van-ap-unmanaged.conf` | `interface-name:@LAN_IFACE@;interface-name:eth0` | `interface-name:@LAN_IFACE@@ONBOARD_LAN_UNMANAGED@` |
| `ap/23-van-lan-eth0.network` | `Name=eth0` | `Name=@ONBOARD_LAN_IFACE@` |
| `power/nvme-watch-config.json` | `"nvme_device": "nvme0",` | `"nvme_device": "@NVME_DEVICE@",` |
| `power/nvme-watch-config.json` | `"fs_device": "/dev/nvme0n1p2",` | `"fs_device": "@NVME_FS_DEVICE@",` |
Note: `failover/config.json` has `"device": "wlan0", "metric": 100` — keep the column spacing; only replace the `wlan0` text. The `van-wlan-watchdog` script already takes the device as `argv[1]` (`DEVICE = sys.argv[1] if len(sys.argv) > 1 else "wlan0"`), so no script change is needed.
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: check 2 now FAILs for the files still installed with plain `install`; check 5 passes.
- [ ] **Step 3: Install them through `install_rendered` in `deploy.sh`**
Replace these lines (spacing preserved as in the file):
```bash
install -D -m0644 ap/nftables.conf /etc/nftables.conf
```
→
```bash
install_rendered ap/nftables.conf /etc/nftables.conf
```
```bash
install -D -m0644 ap/van-br0-setup.service /etc/systemd/system/van-br0-setup.service
```
→
```bash
install_rendered ap/van-br0-setup.service /etc/systemd/system/van-br0-setup.service
```
```bash
install -D -m0644 failover/van-wlan-watchdog.service /etc/systemd/system/van-wlan-watchdog.service
```
→
```bash
install_rendered failover/van-wlan-watchdog.service /etc/systemd/system/van-wlan-watchdog.service
```
```bash
install -D -m0644 power/nvme-watch-config.json /etc/van-nvme-watch/config.json
```
→
```bash
install_rendered power/nvme-watch-config.json /etc/van-nvme-watch/config.json
```
and the `23-van-lan-eth0.network` line:
```bash
install -D -m0644 ap/23-van-lan-eth0.network /etc/systemd/network/23-van-lan-eth0.network
```
→
```bash
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
```
- [ ] **Step 4: Run tests**
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: every line `ok`, `exit=0`.
- [ ] **Step 5: Commit**
```bash
git add -A ap failover router-status power deploy.sh tests
git commit -m "Template remaining host-specific values (WAN wifi, LAN/eth0, NVMe, nftables)
nftables.conf hardcoded the R5C network, van-br0-setup.service the R5C
interfaces and several configs wlan0/eth0. All now come from the host
config; the onboard LAN port is skipped when ONBOARD_LAN_IFACE is empty.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
```
---
### Task 3: Feature flags, safer apply step, `--diff`
**Files:**
- Modify: `deploy.sh` (package list, cockpit/li3/nvme/HA/frigate blocks, enable/start/restart lines, apply step, new `--diff`, dirty-config warning)
- Modify: `tests/check-templates.sh` (add checks 6 and 8)
**Interfaces:**
- Consumes (Task 1): `load_conf`, `render`, `warn` (defined later in `deploy.sh`, so the dirty-config warning goes right after its definition), flags `ENABLE_COCKPIT|LI3|NVME_WATCH|HA|FRIGATE`.
- Produces: `./deploy.sh --diff [conf]` (prints `NEW <dst>` / `CHANGED <dst>` + unified diff for every `install_rendered <src> /abs/dst` line); all flagged units are guarded by a column-0 `if [ "$ENABLE_X" = 1 ]; then … fi` block with 4-space-indented contents (the awk check in check 6 depends on that formatting).
- [ ] **Step 1: Add checks 6 and 8 and see them fail**
Insert before the final `exit "$fail"`:
```bash
# 6. flagged units/paths only appear inside their ENABLE_* guard in deploy.sh.
# Convention: guards are column-0 `if [ "$ENABLE_X" = 1 ]; then` ... `fi`,
# contents indented (so a nested `fi` never sits in column 0).
if awk '
BEGIN {
need["van-li3-battery"]="ENABLE_LI3"
need["van-nvme-watch"]="ENABLE_NVME_WATCH"
need["nvme-watch-config"]="ENABLE_NVME_WATCH"
need["homeassistant"]="ENABLE_HA"
need["esphome"]="ENABLE_HA"
need["frigate"]="ENABLE_FRIGATE"
need["cockpit"]="ENABLE_COCKPIT"
}
/^if \[ "\$ENABLE_[A-Z0-9_]+" = 1 \]/ { match($0, /ENABLE_[A-Z0-9_]+/); guard=substr($0, RSTART, RLENGTH); next }
/^fi([[:space:]]|$)/ { guard=""; next }
/^[[:space:]]*(#|echo)/ { next }
{ for (u in need) if (index($0, u) && guard != need[u]) {
printf " deploy.sh:%d mentions %s outside an %s guard: %s\n", NR, u, need[u], $0; bad=1 } }
END { exit bad }
' deploy.sh; then ok "flagged units are only touched inside their ENABLE_* guard"; else bad "flag gating"; fi
# 8. ./deploy.sh --diff runs (no root) and lists rendered files
if out=$(./deploy.sh --diff hosts/van.conf 2>&1) && echo "$out" | grep -qE '^(NEW|CHANGED) '; then
ok "deploy.sh --diff hosts/van.conf"
else
bad "deploy.sh --diff hosts/van.conf"; echo "$out" | sed 's/^/ /'
fi
```
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: check 6 lists many unguarded mentions, check 8 FAILs (`--diff` unknown), `exit=1`.
- [ ] **Step 2: Add `--diff` and the dirty-config warning**
In the `case "${1:-}" in` block (added in Task 1), add before `render)`:
```bash
--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 ;;
```
Directly after the `write_warnings() { … }` function definition in `deploy.sh`, add:
```bash
# 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
```
- [ ] **Step 3: Gate the optional sections**
Edit `deploy.sh` (old text is exact; new text indents block contents by 4 spaces):
**Packages** — remove `podman` from the array and add the conditional. Replace
```bash
zerotier-one
podman
)
```
with
```bash
zerotier-one
)
if [ "$ENABLE_HA" = 1 ]; then
REQUIRED_PACKAGES+=(podman)
fi
```
**Cockpit** — replace the first line `if [ "$SKIP_COCKPIT" -ne "1" ]; then` with `if [ "$ENABLE_COCKPIT" = 1 ]; then` (the block is already indented; also fix its stray trailing spaces after the closing `fi`).
**Li3** — replace the whole `echo "== li3 …"` section (from `echo "== li3 (RV house battery BMS -> MQTT/HA) =="` through the `python3 -c 'import bleak' …|| warn "python3-bleak missing …"` line) with:
```bash
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
```
and delete the now-duplicated line
`install -D -m0644 li3/van-li3-battery-restart-limit.conf /etc/systemd/system/van-li3-battery.service.d/restart-limit.conf`
from the router-status section.
**NVMe** — replace the three-line nvme section (after `echo "== nvme watchdog =="`) with:
```bash
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
```
**Home Assistant / ESPHome / Frigate** — replace the section from `echo "== home assistant =="` through the `install -d -m0755 /srv/frigate/config /srv/frigate/storage` line with:
```bash
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
```
**Apply step** — replace the long `systemctl enable regdomain.service … van-router-mqtt >/dev/null 2>&1 || true` line with:
```bash
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
```
Replace the three `systemctl start homeassistant/esphome/frigate … || warn …` lines with:
```bash
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
```
Replace the restart lines `systemctl restart van-thermal` … `systemctl restart van-router-mqtt` (thermal, nvme-watch, sms-watch, gps-owntracks, li3-battery, router-mqtt) with:
```bash
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
```
Replace `systemctl restart van-ap-dnsmasq nftables van-failover van-wlan-watchdog` with (a failing unit is now recorded as a warning instead of aborting the script mid-apply):
```bash
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
```
- [ ] **Step 4: Run tests**
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: all `ok`, `exit=0`. If check 6 still names a line, that line mentions a flagged unit outside a column-0 guard: move it inside a guard (or, if it is a comment/echo, it is already skipped).
Also: `bash -n deploy.sh && ./deploy.sh --diff hosts/wan.conf | head -5` — expected: `NEW /etc/...` lines (this laptop has none of the files).
- [ ] **Step 5: Commit**
```bash
git add deploy.sh tests
git commit -m "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>"
```
---
### Task 4: Backup scripts read the host config
**Files:**
- Modify: `failover/sync-usb-backup.sh`, `failover/sync-sd-backup.sh`
- Modify: `tests/check-templates.sh` (add check 7)
**Interfaces:**
- Consumes (Task 1): host variables `USB_BACKUP_ROOT_PART`, `USB_BACKUP_MOUNT`, `SD_BACKUP_UUID`; env override `VANLINK_CONF` (default `/etc/vanlink.conf`).
- Produces: both scripts exit non-zero with a message naming the missing variable, **before** the root check, when their setting is absent.
- [ ] **Step 1: Add check 7 and see it fail**
Insert before the final `exit "$fail"`:
```bash
# 7. backup scripts take their settings from the host config and say what is missing
empty=$(mktemp); trap 'rm -f "$empty"' EXIT
for pair in "failover/sync-usb-backup.sh:USB_BACKUP_ROOT_PART" "failover/sync-sd-backup.sh:SD_BACKUP_UUID"; do
s=${pair%%:*}; v=${pair##*:}
if out=$(VANLINK_CONF="$empty" "./$s" 2>&1); then
bad "$s should fail when $v is not set"
elif echo "$out" | grep -q "$v"; then
ok "$s names missing $v"
else
bad "$s failed but did not mention $v"; echo "$out" | sed 's/^/ /'
fi
done
if grep -nE '/dev/sda2|10\.42\.[01]\.44|39118305-4927' failover/sync-*-backup.sh; then
bad "hardcoded host values remain in the backup scripts"
else
ok "no hardcoded devices/UUIDs/addresses in the backup scripts"
fi
```
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: check 7 FAILs (scripts still hardcode values and demand root first), `exit=1`.
- [ ] **Step 2: Edit `failover/sync-usb-backup.sh`**
Directly after `set -euo pipefail`, insert:
```bash
# Backup settings come from the host config (see hosts/vanlink.conf.example).
# shellcheck disable=SC1090
. "${VANLINK_CONF:-/etc/vanlink.conf}"
: "${USB_BACKUP_ROOT_PART:?set USB_BACKUP_ROOT_PART in /etc/vanlink.conf}"
: "${USB_BACKUP_MOUNT:?set USB_BACKUP_MOUNT in /etc/vanlink.conf}"
```
Replace this block:
```bash
USB_ROOT_PART=/dev/sda2
MOUNT_POINT=/mnt/usb-backup-root
MOUNT_POINT=10.42.1.44:/media/andreas/writable-nvme
[[ "$MOUNT_POINT" == *":"* ]]
LOCAL_MOUNT=$?
```
with:
```bash
USB_ROOT_PART=$USB_BACKUP_ROOT_PART
MOUNT_POINT=$USB_BACKUP_MOUNT
# host:/path means an already-exported NFS target: nothing to mount locally.
if [[ "$MOUNT_POINT" == *":"* ]]; then LOCAL_MOUNT=0; else LOCAL_MOUNT=1; fi
```
(This also fixes a latent bug: the old `[[ … ]]` line returned 1 for a local mount point and `set -e` aborted the script before `LOCAL_MOUNT=$?` ran.)
- [ ] **Step 3: Edit `failover/sync-sd-backup.sh`**
Directly after `set -euo pipefail`, insert:
```bash
# Backup settings come from the host config (see hosts/vanlink.conf.example).
# shellcheck disable=SC1090
. "${VANLINK_CONF:-/etc/vanlink.conf}"
: "${SD_BACKUP_UUID:?set SD_BACKUP_UUID in /etc/vanlink.conf}"
```
Replace `SD_UUID=39118305-4927-47a4-acc7-ba022f37af22` with `SD_UUID=$SD_BACKUP_UUID`.
- [ ] **Step 4: Run tests**
Run: `./tests/check-templates.sh; echo "exit=$?"`
Expected: all `ok`, `exit=0`.
- [ ] **Step 5: Commit**
```bash
git add failover tests
git commit -m "Backup scripts read device/UUID/target from the host config
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
```
---
### Task 5: Documentation and stale references
**Files:**
- Modify: `README.md` (header), `deploy.sh` (header comment), and comments that mention `deploy.conf`: `failover/van-modem-watch`, `failover/60-van-wan-dns`, `ap/50-van-wan.yaml`, `ap/hostapd-2g.service`, `ap/99-van-router-dns.conf`, `cockpit/vanrouter/vanrouter.js`
- [ ] **Step 1: Find the stale references**
Run: `grep -rnI 'deploy\.conf' --exclude-dir=.git --exclude='*.bak' --exclude-dir=docs .`
Expected: comment-only hits in the files listed above (and none that are code).
- [ ] **Step 2: Update them**
For each hit, replace `deploy.conf` with `/etc/vanlink.conf` (comment text only), e.g.:
```bash
grep -rlI 'deploy\.conf' --exclude-dir=.git --exclude='*.bak' --exclude-dir=docs . \
| xargs sed -i "s#deploy\.conf's#/etc/vanlink.conf's#g; s#deploy\.conf#/etc/vanlink.conf#g"
```
Replace the first lines of `deploy.sh`:
```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
```
with:
```bash
# Deploy vanlink configs/scripts to their system locations. One checkout is
# shared by every host; host-specific values come from /etc/vanlink.conf
# (symlink to hosts/<hostname>.conf — see hosts/vanlink.conf.example).
# Usage: cd ~/vanlink && sudo ./deploy.sh
# ./deploy.sh --check [conf] # validate conf + render all templates, no install
# ./deploy.sh --diff [conf] # what would change in /etc
# ./deploy.sh render <file> # preview a templated file on stdout
```
Replace the note block at the top of `README.md` (the `> **This clone = the Pi 4 port …` blockquote, 5 lines) with:
```markdown
> **One checkout, several hosts.** The Pi 5 (`wan`, 10.42.0.1) and the FriendlyElec R5C
> (`van`, 10.42.1.1) run from this same repo and branch. Everything host-specific
> (interface names, addressing, which optional services run) lives in
> `hosts/<hostname>.conf`; `/etc/vanlink.conf` is a symlink to it:
> `sudo ln -sfn "$PWD/hosts/$(hostname -s).conf" /etc/vanlink.conf`.
> Edit that file (and commit it), not the individual configs. Before a deploy:
> `./deploy.sh --check` and `./deploy.sh --diff`. Static checks: `tests/check-templates.sh`.
```
- [ ] **Step 3: Test and commit**
Run: `./tests/check-templates.sh; echo "exit=$?"` — expected `exit=0`.
```bash
git add -A README.md deploy.sh failover ap cockpit
git commit -m "Docs: shared checkout model, /etc/vanlink.conf references
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
git push origin wan
```
---
### Task 6: Roll out to the R5C (`van`) — needs the user's go-ahead
**Gate — ask the user first.** Applying changes `enP1p17s0` (today the port carrying the management address `192.168.10.211`) into the LAN bridge port. Confirm: (a) the cabling is final (Starlink/upstream cable on `enP2p33s0`, LAN devices on `enP1p17s0`), and (b) the user has console access or another way in (e.g. the van Wi-Fi AP at `10.42.1.1`) in case SSH over `192.168.10.211` drops.
**Files:** none in the repo; changes on the host `192.168.10.211` (`root@`).
- [ ] **Step 1: Put the checkout on the host**
`git` is not installed on the R5C. Install it and use the repo remote; if that key/network is unavailable, rsync the laptop checkout including `.git`:
```bash
ssh root@192.168.10.211 'apt-get install -y git'
# preferred:
ssh root@192.168.10.211 'cd /home/andreas && git clone -b wan git@git.wrede.ca:andreas/vanlink.git vanlink.new'
# fallback (laptop checkout is up to date after Task 5's push):
rsync -a --exclude __pycache__ /home/andreas/vanlink/ root@192.168.10.211:/home/andreas/vanlink.new/
ssh root@192.168.10.211 'chown -R andreas:andreas /home/andreas/vanlink.new'
```
- [ ] **Step 2: Swap the directory (keep the old one) and link the host config**
```bash
ssh root@192.168.10.211 'cd /home/andreas && mv vanlink vanlink.old && mv vanlink.new vanlink &&
ln -sfn /home/andreas/vanlink/hosts/van.conf /etc/vanlink.conf && ls -l /etc/vanlink.conf'
```
Expected: `/etc/vanlink.conf -> /home/andreas/vanlink/hosts/van.conf`.
- [ ] **Step 3: Validate and preview**
```bash
ssh root@192.168.10.211 'cd /home/andreas/vanlink && ./deploy.sh --check && ./deploy.sh --diff'
```
Expected: `check OK: N templates render cleanly`, then `NEW`/`CHANGED` entries. Read every `CHANGED` diff: the expected ones are `/etc/van-ap/dnsmasq.conf`, `/etc/systemd/network/21-van-br0.network`, `/etc/nftables.conf` (previously unrendered), `05-van-lan.network` (NEW), and `/etc/systemd/system/van-br0-setup.service`. Anything else needs an explanation before continuing.
- [ ] **Step 4: Deploy and verify**
```bash
ssh root@192.168.10.211 'cd /home/andreas/vanlink && ./deploy.sh 2>&1 | tail -25'
```
Then (from a machine that can still reach it; use `10.42.1.1` if `192.168.10.211` is gone):
```bash
ssh root@<host> 'systemctl is-active van-ap-dnsmasq nftables hostapd; ip -br addr show br0; networkctl status enP1p17s0 | grep "Network File"; nft list ruleset | grep masquerade'
```
Expected: `active` ×3; `br0 … 10.42.1.1/24`; `Network File: /etc/systemd/network/05-van-lan.network`; a masquerade rule for `10.42.1.0/24`. Report anything else to the user (do not paper over failures; `journalctl -xeu <unit>`).
- [ ] **Step 5: Record the outcome in memory** (`multihost-config-plan`): R5C migrated, old copy at `/home/andreas/vanlink.old`.
---
### Task 7: Roll out to the Pi 5 (`wan`) — needs the user's go-ahead
**Gate — ask the user first.** A deploy restarts `systemd-networkd`/hostapd on a live router (brief Wi-Fi/LAN outage) — agree a moment. Also decide with the user: `ENABLE_NVME_WATCH=0` in `hosts/wan.conf` means deploy no longer manages `van-nvme-watch`; the Pi has no NVMe yet the service is currently running. Ask whether to `systemctl disable --now van-nvme-watch` there (deploy never stops services).
**Files:** none in the repo; changes on `root@10.42.0.1`. Run git there as `git -c safe.directory=/home/andreas/vanlink` (repo owned by `andreas`, commands run as root).
- [ ] **Step 1: Back up the local edits, then drop them**
```bash
ssh root@10.42.0.1 'cd /home/andreas/vanlink && G="git -c safe.directory=/home/andreas/vanlink" &&
mkdir -p /home/andreas/vanlink-pre-migration && cp -a deploy.conf deploy.sh failover/sync-usb-backup.sh li3/van-li3-battery /home/andreas/vanlink-pre-migration/ &&
$G diff > /home/andreas/vanlink-pre-migration/local-edits.diff && $G status --short'
```
Expected: the four modified files (`deploy.conf`, `deploy.sh`, `failover/sync-usb-backup.sh`, `li3/van-li3-battery`) and nothing else. Their content is superseded: `li3` and `sync-usb-backup.sh` changes are already in `wan` (`565e8f3`); the host values are now in `hosts/wan.conf`; the `SKIP_LI3` logic became `ENABLE_LI3`. Confirm with the user, then:
```bash
ssh root@10.42.0.1 'cd /home/andreas/vanlink && G="git -c safe.directory=/home/andreas/vanlink" &&
$G checkout -- . && $G fetch origin && $G merge --ff-only origin/wan && $G log --oneline -1'
```
Expected: fast-forward to the commit pushed in Task 5.
- [ ] **Step 2: Link the host config, validate, and compare with what is live**
```bash
ssh root@10.42.0.1 'cd /home/andreas/vanlink && ln -sfn "$PWD/hosts/wan.conf" /etc/vanlink.conf &&
./deploy.sh --check && ./deploy.sh --diff'
```
Expected: `check OK`. Read every `CHANGED` entry; changes that alter behaviour on the Pi 5 (addresses, interface names, DNS, unit contents) must be explained or `hosts/wan.conf` corrected **before** deploying. Fix by editing `hosts/wan.conf` on the laptop, commit, push, `git merge --ff-only` again.
- [ ] **Step 3: Deploy in the agreed window and verify**
```bash
ssh root@10.42.0.1 'cd /home/andreas/vanlink && ./deploy.sh 2>&1 | tail -25'
ssh root@10.42.0.1 'systemctl is-active van-ap-dnsmasq nftables hostapd hostapd-2g van-failover; ip -br addr show br0; cat /run/van-failover/state.json | head -20'
```
Expected: all `active`, `br0 … 10.42.0.1/24`, failover state healthy. Any `warn` lines from deploy go to the user.
- [ ] **Step 4: Update memory** (`multihost-config-plan`): both hosts migrated; `/etc/vanlink.conf` symlinks; backup of the old Pi edits at `/home/andreas/vanlink-pre-migration`. Delete `vanlink.old` on the R5C only when the user says so.
---
## Self-Review (done)
**Spec coverage:** host file + symlink (Task 1, 6, 7); variable table incl. new vars (Task 1 arrays/example, Task 2 tokens); repo changes / `deploy.conf` removal / hosts files / new tokens (Tasks 1-2); README (Task 5); `deploy.sh` behaviour: missing/dangling symlink message + required-var abort + resolved path + dirty warning (Tasks 1, 3), `install_rendered` leftover guard (Task 1), `05-van-lan` and eth0 handling (pending-fix commit, Task 2), feature flags for install/enable/restart (Task 3), `--check` (Task 1), `render` (Task 1), restart-failure warnings (Task 3); backup scripts (Task 4); testing items 1-4 (checks 1-4) plus content checks; migration (Tasks 6-7) incl. pre-deploy diff (`--diff`, Task 3) and Pi 5 backup; out-of-scope items untouched.
**Placeholders:** none; every code step has its code. Task 6/7 are operational and hold explicit gates.
**Type/name consistency:** `LAN_IFACE`, `WIFI_WAN_IFACE`, `ONBOARD_LAN_IFACE`/`ONBOARD_LAN_UNMANAGED`, `NVME_DEVICE`/`NVME_FS_DEVICE`, `USB_BACKUP_ROOT_PART`/`USB_BACKUP_MOUNT`, `SD_BACKUP_UUID`, `ENABLE_*` names identical across the spec, `REQUIRED_VARS`/`OPTIONAL_VARS`, host files, templates and tests. Check numbers in the test script are 1, 2, 3, 4, 5, 6, 7, 8 (a section is added in the task that introduces its behaviour; numbering follows the spec's order where it exists and is not required to be contiguous while tasks are in progress).