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

46 KiB
Raw Blame History

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.

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):

#!/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:

# 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):

# 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):

# 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:

# 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:

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
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:

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
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":

# 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):

install -D -m0644 ap/nftables.conf          /etc/nftables.conf

→

install_rendered ap/nftables.conf           /etc/nftables.conf
install -D -m0644 ap/van-br0-setup.service  /etc/systemd/system/van-br0-setup.service

→

install_rendered ap/van-br0-setup.service   /etc/systemd/system/van-br0-setup.service
install -D -m0644 failover/van-wlan-watchdog.service /etc/systemd/system/van-wlan-watchdog.service

→

install_rendered failover/van-wlan-watchdog.service /etc/systemd/system/van-wlan-watchdog.service
install -D -m0644 power/nvme-watch-config.json /etc/van-nvme-watch/config.json

→

install_rendered power/nvme-watch-config.json /etc/van-nvme-watch/config.json

and the 23-van-lan-eth0.network line:

install -D -m0644 ap/23-van-lan-eth0.network /etc/systemd/network/23-van-lan-eth0.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
  • Step 4: Run tests

Run: ./tests/check-templates.sh; echo "exit=$?" Expected: every line ok, exit=0.

  • Step 5: Commit
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":

# 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):

    --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:

# 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

    zerotier-one
    podman
)

with

    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:

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:

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:

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:

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:

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:

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):

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
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":

# 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:

# 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:

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:

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:

# 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
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.:

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:

# 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:

# 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:

> **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.

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:

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
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
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
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):

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
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:

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
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
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).