Files
vanlink/docs/superpowers/specs/2026-09-23-host-config-design.md

9.8 KiB

Host-local configuration for multi-host vanlink

Date: 2026-09-23. Status: approved by user; revised 2026-09-23 (LAN_IFACE rename, symlinked host config).

Problem

Two hosts run vanlink from this repo: the Pi 5 (10.42.0.1) and the FriendlyElec R5C (10.42.1.1). Host-specific values (interface names, addressing, storage devices, which optional services to run) live in deploy.conf, which is tracked in git and was hand-edited per host. The hosts drifted: each has its own uncommitted deploy.conf, and the R5C runs a non-git copy of the repo. On 2026-09-23 a deploy on the R5C failed because:

  1. van-ap-dnsmasq.conf and 21-van-br0.network contain @TOKEN@ placeholders but deploy.sh installed them with plain install, so they reached /etc unrendered (dnsmasq failed to parse, br0 got no address);
  2. nftables.conf on the host was an unrendered copy for the same reason;
  3. Armbian's netplan-generated 10-netplan-all-eth-interfaces.network sorts before 22-van-lan.network, so networkd never applied the bridge config to the LAN port.

Other host-specific values are hardcoded outside deploy.conf: van-br0-setup.service (enP2p33s0, wlp1s0), failover/config.json and router-status/config.json (wlan0), ap/50-van-wan.yaml (wlan0), ap/van-ap-unmanaged.conf (eth0), ap/23-van-lan-eth0.network, power/nvme-watch-config.json (NVMe device), failover/sync-usb-backup.sh (/dev/sda2, NFS target), and failover/sync-sd-backup.sh (SD UUID).

Decisions already made

  • One git repo, cloned on every host, same branch; no per-host copies or branches.
  • All host-specific values live in one file per host under hosts/ in the repo, and /etc/vanlink.conf on each host is a symlink to that host's file. (Revised from "outside the repo": one versioned source of truth per host, no drift, git pull carries config changes.)
  • Per-host optional services are controlled by explicit feature flags in that file.
  • deploy.sh keeps rendering @TOKEN@ placeholders at deploy time (approach A); services do not read /etc/vanlink.conf at runtime, except the two backup shell scripts.

Design

Each host has hosts/<hostname>.conf (named by hostname -s, so hosts/van.conf for the R5C and hosts/wan.conf for the Pi 5; a hardware swap does not rename a host). /etc/vanlink.conf is an absolute symlink to it: sudo ln -sfn "$PWD/hosts/$(hostname -s).conf" /etc/vanlink.conf. Everything reads /etc/vanlink.conf, so there is one fixed path. Consequences, accepted:

  • Editing a host's values means committing to hosts/<hostname>.conf (on the host or the laptop) and pulling; there is no invisible per-host drift, but a dirty checkout on a host is still possible and deploy.sh reports it (see 3).
  • Root sources a file writable by the checkout's owner. Fine on these single-user boxes (that user has sudo anyway); it is also why the file must never hold secrets.
  • If the checkout is moved the symlink dangles; deploy.sh says so and prints the ln command.

Plain KEY=value lines, comments allowed, no secrets (it is in git and read by root). It is valid shell, so deploy.sh and the backup scripts source it. Every variable below is required unless marked optional: deploy.sh aborts naming any missing one, so a variable added in a later release fails loudly on hosts that have not adopted it instead of silently defaulting.

Group Variable Meaning / used by
Interfaces WIFI_5G_IFACE, WIFI_2G_IFACE AP radios (hostapd, networkd, br0-setup)
LAN_IFACE wired LAN port bridged into br0 (renamed from LAN_USB_IFACE; the R5C's is PCIe)
STARLINK_IFACE Starlink uplink (netplan, failover, status)
WIFI_WAN_IFACE (new) Wi-Fi uplink, currently hardcoded wlan0
ONBOARD_LAN_IFACE (new, optional) extra onboard LAN port bridged into br0 (Pi: eth0); empty = none
Addressing GATEWAY_IP, GATEWAY_PREFIX, GATEWAY_NETWORK, DHCP_RANGE_START, DHCP_RANGE_END br0, dnsmasq, nftables
DNS_RESOLVERS fixed upstream resolvers
Hardware MODEM_USB_VENDOR modem watch / udev
NVME_DEVICE, NVME_FS_DEVICE (new) NVMe watchdog; only read when ENABLE_NVME_WATCH=1
Backup USB_BACKUP_ROOT_PART, USB_BACKUP_MOUNT (new) sync-usb-backup.sh
SD_BACKUP_UUID (new) sync-sd-backup.sh
Features ENABLE_COCKPIT (replaces SKIP_COCKPIT) Cockpit plugin
ENABLE_LI3 (replaces the Pi's uncommitted SKIP_LI3) Li3 battery service
ENABLE_NVME_WATCH NVMe watchdog
ENABLE_HA podman quadlets: Home Assistant + ESPHome; adds podman to required packages
ENABLE_FRIGATE Frigate quadlet

Backup variables are only validated by the script that uses them, not by deploy.sh, because a host uses one backup method or neither.

2. Repo changes

  • deploy.conf is deleted.
  • hosts/vanlink.conf.example: every variable with a comment (the reference).
  • hosts/van.conf (R5C), hosts/wan.conf (Pi 5): the real, versioned values for the two hosts, also the test input for --check. deploy.sh never creates the symlink or edits these files.
  • New tokens: @WIFI_WAN_IFACE@, @ONBOARD_LAN_IFACE@, @NVME_DEVICE@, @NVME_FS_DEVICE@. van-br0-setup.service, failover/config.json, router-status/config.json, ap/50-van-wan.yaml, ap/van-ap-unmanaged.conf, power/nvme-watch-config.json and ap/nftables.conf become templates (nftables currently hardcodes 10.42.1.0/24, which is wrong on the Pi 5).
  • README.md header ("Pi 4 port") updated to describe the shared model.

3. deploy.sh behaviour

  • Config path is ${VANLINK_CONF:-/etc/vanlink.conf}. Missing or dangling symlink: abort with the ln command for this host. Missing/empty required variable: abort naming it. It prints the resolved file it is using, and warns if that file has uncommitted changes (git status) so a deploy from unreviewed values is visible.
  • install_rendered refuses to install any output that still matches @[A-Z][A-Z0-9_]*@ and aborts before anything is restarted. Every file containing a token must be installed through it (enforced by the test below).
  • ap/22-van-lan.network is installed as 05-van-lan.network (sorts before the netplan-generated 10- file) and any old 22-van-lan.network is removed. Same fix for 23-van-lan-eth0.network when ONBOARD_LAN_IFACE is set; when empty it is neither installed nor left behind.
  • Feature-flagged sections: install, enable and restart are all skipped when the flag is 0 (today systemctl enable/restart names van-li3-battery unconditionally).
  • ./deploy.sh --check [conf]: no root needed, no installs. Loads the conf (default /etc/vanlink.conf), validates variables, renders every token-bearing file to a temp dir, and reports leftover tokens. Exit non-zero on any problem.
  • ./deploy.sh render <file> keeps working.
  • Failure while restarting nftables or van-ap-dnsmasq is reported as a warning in the summary (it is today printed and lost).

4. Backup scripts

sync-usb-backup.sh and sync-sd-backup.sh source /etc/vanlink.conf and use ${VAR:?set in /etc/vanlink.conf}. Hardcoded devices, UUIDs and NFS targets are removed. The failover/dormant-boot.scr logic of sync-sd-backup.sh is unchanged.

Testing

tests/check-templates.sh (plain bash, run from the repo root, no root needed):

  1. bash -n deploy.sh and the backup scripts.
  2. Every repo file containing @TOKEN@ is installed by deploy.sh via install_rendered (fails on the class of bug from 2026-09-23).
  3. For each hosts/*.conf: ./deploy.sh --check hosts/x.conf passes, and rendering all templates leaves no @…@.
  4. hosts/vanlink.conf.example defines exactly the variables deploy.sh requires (no drift between docs and code).

On the hosts: --check first, then a real deploy. The R5C deploy is done at a console or with the cabling settled (see Migration); a deploy restarts systemd-networkd.

Migration

  1. Implement and merge on wan, run the test script, push.
  2. R5C: create hosts/van.conf from its current deploy.conf (interfaces as currently swapped) in the repo, commit and push; on the R5C git clone next to the stale copy, keep the old directory as vanlink.old, make the symlink, run --check, then deploy.
  3. Pi 5: create hosts/wan.conf from its deploy.conf (its real interfaces, 10.42.0.x, ENABLE_LI3=0, ENABLE_COCKPIT=0, ENABLE_HA etc. as it is today); copy its 4 modified files to a backup directory; git checkout -- . and fast-forward to the shared wan; make the symlink; --check; deploy. Its existing behaviour must not change: diff the rendered files against what is currently in /etc before restarting anything.
  4. main is brought up to date with wan afterwards (separate decision; hosts follow wan).

Rollback per host: restore the old directory and, if needed, the previous files from /etc (deploy.sh does not currently back them up; the pre-deploy diff in step 3 is the safety net for the Pi 5).

Out of scope / follow-ups

  • ap/50-van-wan.yaml contains the Wi-Fi PSK hash for "Wapana", committed to git. Should move to the host (and the value be considered exposed); separate change.
  • power/thermal-config.json hardcodes Pi sensor names (rp1_adc, nvme); the R5C needs its own sensor list. A per-host mechanism for it is deferred until wanted.
  • Netplan (ap/50-van-wan.yaml) remains a manual step; the drift check stays as is.
  • The .bak files in ap/ and failover/ are unrelated clutter.

Open questions for review

None open. Resolved: LAN_USB_IFACE becomes LAN_IFACE (the lan_usb_iface key in router-status/config.json is a consumer-facing name and stays); host files live in the repo and are reached through the /etc/vanlink.conf symlink.