apply-network: final-review fixes (diagnostics, NM off the uplink until handover, preflight, stricter stage 2)

I-1 helper logs diagnostics (nmcli, DHCP routers, networkctl -n 0, ip addr, ip route; each
    under timeout 5) before a stage 1/2 restore, and the port's networkctl list line on success
    (information only, never gating).
I-2 ap/nm-uplink-pending.conf -> /etc/NetworkManager/conf.d/zz-van-wan-pending.conf keeps NM
    off STARLINK_IFACE while the WAN netplan is missing; installed before network-manager,
    removed once the netplan matches, left as is when it differs (port already handed to NM).
    apply_network backs it up as nm-pending.conf; the helper removes it + reloads NM conf right
    before netplan generate/apply, restore() puts it back and reloads after the re-apply.
    --wan-plan prints "uplink NM pending conf: ...".
I-3 nm_wan_state_netplan: "unreadable" (0600 file, non-root); --wan-plan and post-check say so
    without warning.
I-4 apply_preflight_problems (+ hidden --apply-preflight, VANLINK_HOSTNAME test knob ignored as
    root): port absent / conf of another host -> refused without --force. Stage 2 also needs
    van-failover's own "proto static" default route on the port.
M-1 helper restarts van-br0-setup (if the unit exists) after the forward netplan apply.
M-3 nm_pkg_installed (dpkg-query Status) in deploy, --wan-plan and --apply-network.
M-5 header documents VANLINK_CONF; stale test comment fixed.
M-6 cellular_profile_exists ignores VANLINK_NM_DIR as root.
M-7 tests: NM-off files installed before network-manager; read-only sub-commands never apt-get.
M-8 an existing, differing WAN netplan is shown (redact_secrets now masks YAML password:) and
    refused without --force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9
This commit is contained in:
2026-09-23 19:41:02 -04:00
co-authored by Claude Opus 5.5
parent 5c18bcdad6
commit 3fce6a7907
3 changed files with 320 additions and 41 deletions
+9
View File
@@ -0,0 +1,9 @@
# Installed by ./deploy.sh as /etc/NetworkManager/conf.d/zz-van-wan-pending.conf only while the
# WAN netplan (/etc/netplan/50-van-wan.yaml) is missing, i.e. before the uplink port has been
# handed to NetworkManager: until then systemd-networkd owns the port (DHCP lease, default route)
# and NM must leave it alone, or it shows up as "connected (externally)" and van-failover starts
# steering it. ./deploy.sh --apply-network removes this file right before it applies the netplan
# (and a rollback puts it back); a routine deploy removes it once the netplan matches.
# "+=" appends to the unmanaged list of van-ap-unmanaged.conf; the zz- name sorts after it.
[keyfile]
unmanaged-devices+=interface-name:@STARLINK_IFACE@
+156 -30
View File
@@ -14,16 +14,23 @@
# ./deploy.sh --apply-network [--force] # root: a detached helper installs the WAN netplan, applies it, gates on uplink health (2 stages) and rolls back # ./deploy.sh --apply-network [--force] # root: a detached helper installs the WAN netplan, applies it, gates on uplink health (2 stages) and rolls back
# # automatically on failure; status in /var/lib/vanlink/apply-network.status. No-op when already applied, and refused after a # # automatically on failure; status in /var/lib/vanlink/apply-network.status. No-op when already applied, and refused after a
# # running/rollback-failed status, unless --force. # # running/rollback-failed status, unless --force.
# # Refused (unless --force) when the preflight finds a problem, or when an existing
# # /etc/netplan/50-van-wan.yaml differs from the rendered one (the diff is shown, secrets redacted).
# ./deploy.sh --apply-preflight [conf] # problems --apply-network would refuse on, one per line (none: no output, exit 0):
# # STARLINK_IFACE absent in sysfs; /etc/vanlink.conf -> hosts/<name>.conf with <name> != `hostname -s`
# ./deploy.sh --apply-status-check <file> # test seam: yes|no, would this status file (running|rollback-failed) block --apply-network # ./deploy.sh --apply-status-check <file> # test seam: yes|no, would this status file (running|rollback-failed) block --apply-network
# ./deploy.sh --print-apply-helper [conf] # print the detached apply/rollback helper script (read-only, no root; for review and tests) # ./deploy.sh --print-apply-helper [conf] # print the detached apply/rollback helper script (read-only, no root; for review and tests)
# # test-only flags: --backup --netplan-dir --log --status --sysnet --rendered --deadlines "s1 s2 rc" --stamp # # test-only flags: --backup --netplan-dir --log --status --sysnet --rendered --nm-conf-dir --deadlines "s1 s2 rc" --stamp
# ./deploy.sh render <file> # preview a templated file on stdout # ./deploy.sh render <file> # preview a templated file on stdout
# ./deploy.sh --redact # filter stdin, masking secret values (used by --diff; for tests) # ./deploy.sh --redact # filter stdin, masking secret values (used by --diff; for tests)
# VANLINK_SYSNET=<dir> # test override: sysfs net dir used by --post-check (default /sys/class/net) # VANLINK_CONF=<file> # host config path (default /etc/vanlink.conf); sub-commands taking [conf] use that argument instead
# VANLINK_SYSNET=<dir> # test override: sysfs net dir used by --post-check and --apply-preflight (default /sys/class/net)
# VANLINK_HOSTNAME=<name> # test override: host name used by --apply-preflight (default `hostname -s`; ignored when run as root)
# VANLINK_FAILOVER_STATE=<file> # test override: van-failover state file read by --post-check (default /run/van-failover/state.json) # VANLINK_FAILOVER_STATE=<file> # test override: van-failover state file read by --post-check (default /run/van-failover/state.json)
# VANLINK_APPLY_STATUS=<file> # test override: apply-network status file read by --post-check (default /var/lib/vanlink/apply-network.status) # VANLINK_APPLY_STATUS=<file> # test override: apply-network status file read by --post-check (default /var/lib/vanlink/apply-network.status)
# VANLINK_WAN_NETPLAN=<file> # test override: installed WAN netplan compared by nm_wan_state_netplan (default /etc/netplan/50-van-wan.yaml) # VANLINK_WAN_NETPLAN=<file> # test override: installed WAN netplan compared by nm_wan_state_netplan (default /etc/netplan/50-van-wan.yaml)
# VANLINK_NM_DIR=<dir> # test override: where existing NM *.nmconnection files are looked up (default /etc/NetworkManager/system-connections) # VANLINK_NM_DIR=<dir> # test override: where existing NM *.nmconnection files are looked up (default /etc/NetworkManager/system-connections;
# # ignored when run as root)
# Idempotent. Netplan (the NM-managed WANs; eth0 is a LAN port on br0) is never # Idempotent. Netplan (the NM-managed WANs; eth0 is a LAN port on br0) is never
# changed by a routine deploy (applying it flaps the uplinks). ENABLE_NM_WAN=0: # changed by a routine deploy (applying it flaps the uplinks). ENABLE_NM_WAN=0:
# applied manually from the reference copy in ap/50-van-wan.yaml, and checked for # applied manually from the reference copy in ap/50-van-wan.yaml, and checked for
@@ -106,15 +113,37 @@ render_wan_netplan() { # ap/50-van-wan.yaml, plus the wifis stanza when the hos
fi fi
} }
nm_wan_state_netplan() { # missing | differs | matches (the installed WAN netplan vs the rendered one) nm_wan_state_netplan() { # missing | unreadable | differs | matches (the installed WAN netplan vs the rendered one)
local f=${VANLINK_WAN_NETPLAN:-/etc/netplan/50-van-wan.yaml} local f=${VANLINK_WAN_NETPLAN:-/etc/netplan/50-van-wan.yaml}
if [ ! -f "$f" ]; then echo missing if [ ! -f "$f" ]; then echo missing
elif [ ! -r "$f" ]; then echo unreadable # installed 0600 by --apply-network; only root can compare it
elif diff -q <(render_wan_netplan) "$f" >/dev/null 2>&1; then echo matches elif diff -q <(render_wan_netplan) "$f" >/dev/null 2>&1; then echo matches
else echo differs; fi else echo differs; fi
} }
nm_pkg_installed() { # true if the network-manager package is installed (dpkg's Status, not just known to dpkg)
local s
s=$(dpkg-query -W -f='${Status}' network-manager 2>/dev/null) || return 1
[ "$s" = "install ok installed" ]
}
# Keeps NetworkManager off the uplink port until --apply-network hands it over (ap/nm-uplink-pending.conf).
NM_PENDING_CONF=/etc/NetworkManager/conf.d/zz-van-wan-pending.conf
# nm_pending_action <netplan-state> <pending-conf> -> would install | installed | would remove | absent
# The routine deploy installs the pending conf only while the WAN netplan is missing (the uplink was never
# handed to NM) and removes it once it matches. differs/unreadable: the port was already handed to NM by an
# earlier netplan, so the file is left as it is (installing it would take a live uplink away from NM).
nm_pending_action() {
case "$1" in
missing) if [ -e "$2" ]; then echo installed; else echo "would install"; fi ;;
matches) if [ -e "$2" ]; then echo "would remove"; else echo absent; fi ;;
*) if [ -e "$2" ]; then echo installed; else echo absent; fi ;;
esac
}
cellular_profile_exists() { # true if an NM profile with id=$CELLULAR_CONN_NAME exists (any file name, or known to nmcli) cellular_profile_exists() { # true if an NM profile with id=$CELLULAR_CONN_NAME exists (any file name, or known to nmcli)
local d=${VANLINK_NM_DIR:-/etc/NetworkManager/system-connections} f names local d=/etc/NetworkManager/system-connections f names
[ "$(id -u)" = 0 ] || d=${VANLINK_NM_DIR:-$d} # the test knob is ignored when run as root
for f in "$d"/*.nmconnection; do for f in "$d"/*.nmconnection; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
if grep -Fx "id=${CELLULAR_CONN_NAME}" "$f" >/dev/null 2>&1; then return 0; fi if grep -Fx "id=${CELLULAR_CONN_NAME}" "$f" >/dev/null 2>&1; then return 0; fi
@@ -146,7 +175,20 @@ install_rendered() { # install_rendered <src> <dst> [mode]
redact_secrets() { # stdin -> stdout; mask values of secret-looking keys, keep key + diff markers redact_secrets() { # stdin -> stdout; mask values of secret-looking keys, keep key + diff markers
sed -E \ sed -E \
-e 's/((pass(word|phrase)|secret|token)[A-Za-z_]*[[:space:]]*=).*/\1<redacted>/I' \ -e 's/((pass(word|phrase)|secret|token)[A-Za-z_]*[[:space:]]*=).*/\1<redacted>/I' \
-e 's/("[A-Za-z_]*(pass(word|phrase)|secret|token|user)[A-Za-z_]*"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"<redacted>"/I' -e 's/("[A-Za-z_]*(pass(word|phrase)|secret|token|user)[A-Za-z_]*"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"<redacted>"/I' \
-e 's/^([-+ ]?[[:space:]]*(pass(word|phrase)|secret|token)[A-Za-z_]*:[[:space:]]*)[^[:space:]].*/\1<redacted>/I'
}
# apply_preflight_problems <conf> <sysnet-dir> <hostname> — one line per reason --apply-network should not run
# here (nothing when fine): the uplink port does not exist, or the conf is a hosts/<name>.conf of another host
# (checked only when the conf resolves into a hosts/ directory).
apply_preflight_problems() {
local conf=$1 sn=$2 hn=$3 real
[ -e "$sn/$STARLINK_IFACE" ] || echo "uplink port STARLINK_IFACE=$STARLINK_IFACE does not exist ($sn/$STARLINK_IFACE)"
real=$(readlink -f "$conf")
if [ "$(basename "$(dirname "$real")")" = hosts ] && [ "$(basename "$real" .conf)" != "$hn" ]; then
echo "$conf -> $real is not this host's config (hostname $hn)"
fi
} }
template_files() { # every repo file that carries an @TOKEN@ placeholder template_files() { # every repo file that carries an @TOKEN@ placeholder
@@ -336,7 +378,11 @@ PY
else warn "post-deploy: van-failover state $fst missing or unreadable"; fi else warn "post-deploy: van-failover state $fst missing or unreadable"; fi
# k. netplan drift # k. netplan drift
f=$(nm_wan_state_netplan) f=$(nm_wan_state_netplan)
[ "$f" = matches ] && echo " ok WAN netplan matches" || warn "post-deploy: WAN netplan $f — run ./deploy.sh --apply-network" case "$f" in
matches) echo " ok WAN netplan matches" ;;
unreadable) echo " ok WAN netplan: cannot read as non-root (run with sudo to verify)" ;;
*) warn "post-deploy: WAN netplan $f — run ./deploy.sh --apply-network" ;;
esac
# l. last --apply-network # l. last --apply-network
as_f=${VANLINK_APPLY_STATUS:-/var/lib/vanlink/apply-network.status} as_f=${VANLINK_APPLY_STATUS:-/var/lib/vanlink/apply-network.status}
if [ -r "$as_f" ]; then if [ -r "$as_f" ]; then
@@ -353,8 +399,9 @@ PY
# --apply-network time budgets (seconds), in one place; all derived from the AN_* constants. # --apply-network time budgets (seconds), in one place; all derived from the AN_* constants.
# (netplan generate/apply run under timeout -k AN_KILL_T: +AN_KILL_T each in the worst case) # (netplan generate/apply run under timeout -k AN_KILL_T: +AN_KILL_T each in the worst case)
# forward path AN_FWD = generate + apply + 2*kill + stage 1 + restart + stage 2 # forward path AN_FWD = NM conf reload + generate + apply + 2*kill + van-br0-setup (unit check + restart)
# rollback path AN_RB = generate + apply + 2*kill + restart + connectivity recheck # + stage 1 + restart + stage 2 + networkd info line + diagnostics (logged once, on a failed gate)
# rollback path AN_RB = generate + apply + 2*kill + NM conf reload + restart + connectivity recheck
# helper worst case AN_HELPER_MAX = AN_FWD + AN_RB + AN_SLOP (loop iterations overshoot their # helper worst case AN_HELPER_MAX = AN_FWD + AN_RB + AN_SLOP (loop iterations overshoot their
# deadline by a few nmcli/ping timeouts) # deadline by a few nmcli/ping timeouts)
# foreground waits AN_WAIT = AN_HELPER_MAX + 30 # foreground waits AN_WAIT = AN_HELPER_MAX + 30
@@ -364,8 +411,9 @@ PY
# route in its first loop, so a healthy default route shows up within seconds of a restart; the # route in its first loop, so a healthy default route shows up within seconds of a restart; the
# 120 s stage 2 deadline is a wide margin for a slow Starlink/cellular probe. # 120 s stage 2 deadline is a wide margin for a slow Starlink/cellular probe.
AN_KILL_T=10 AN_APPLY_T=90 AN_GEN_T=30 AN_NM_T=5 AN_RESTART_T=30 AN_S1=60 AN_S2=120 AN_RC=60 AN_SLOP=60 AN_KILL_T=10 AN_APPLY_T=90 AN_GEN_T=30 AN_NM_T=5 AN_RESTART_T=30 AN_S1=60 AN_S2=120 AN_RC=60 AN_SLOP=60
AN_FWD=$(( AN_GEN_T + AN_APPLY_T + 2 * AN_KILL_T + AN_S1 + AN_RESTART_T + AN_S2 )) AN_DIAG=$(( 5 * AN_NM_T )) # diag(): five commands, each under timeout AN_NM_T
AN_RB=$(( AN_GEN_T + AN_APPLY_T + 2 * AN_KILL_T + AN_RESTART_T + AN_RC )) AN_FWD=$(( AN_NM_T + AN_GEN_T + AN_APPLY_T + 2 * AN_KILL_T + AN_NM_T + AN_RESTART_T + AN_S1 + AN_RESTART_T + AN_S2 + AN_NM_T + AN_DIAG ))
AN_RB=$(( AN_GEN_T + AN_APPLY_T + 2 * AN_KILL_T + AN_NM_T + AN_RESTART_T + AN_RC ))
AN_HELPER_MAX=$(( AN_FWD + AN_RB + AN_SLOP )) AN_HELPER_MAX=$(( AN_FWD + AN_RB + AN_SLOP ))
AN_WAIT=$(( AN_HELPER_MAX + 30 )) AN_WAIT=$(( AN_HELPER_MAX + 30 ))
AN_RUNMAX=$(( AN_HELPER_MAX + 60 )) AN_RUNMAX=$(( AN_HELPER_MAX + 60 ))
@@ -378,36 +426,61 @@ apply_status_blocks_run() { # apply_status_blocks_run <status-file> -> yes|no:
esac esac
} }
# apply_helper_script <backup-dir> [netplan-dir] [log] [status-file] [sysnet-dir] [rendered-file] [deadlines "s1 s2 rc"] [run-stamp] # apply_helper_script <backup-dir> [netplan-dir] [log] [status-file] [sysnet-dir] [rendered-file] [deadlines "s1 s2 rc"] [run-stamp] [nm-conf-dir]
# The script the detached unit runs. Paths are parameters only so tests can bake # The script the detached unit runs. Paths are parameters only so tests can bake
# sandbox paths into the text; the production helper reads no environment. # sandbox paths into the text; the production helper reads no environment.
apply_helper_script() { apply_helper_script() {
local bak=$1 nd=${2:-/etc/netplan} log=${3:-/var/log/vanlink-apply-network.log} local bak=$1 nd=${2:-/etc/netplan} log=${3:-/var/log/vanlink-apply-network.log}
local st=${4:-/var/lib/vanlink/apply-network.status} sn=${5:-/sys/class/net} local st=${4:-/var/lib/vanlink/apply-network.status} sn=${5:-/sys/class/net}
local rf=${6:-/run/vanlink-50-van-wan.yaml} dl=${7:-"$AN_S1 $AN_S2 $AN_RC"} stamp=${8:-manual} local rf=${6:-/run/vanlink-50-van-wan.yaml} dl=${7:-"$AN_S1 $AN_S2 $AN_RC"} stamp=${8:-manual}
local ncd=${9:-$(dirname "$NM_PENDING_CONF")} pc=${NM_PENDING_CONF##*/}
local d1 d2 d3 local d1 d2 d3
read -r d1 d2 d3 <<< "$dl" read -r d1 d2 d3 <<< "$dl"
# Unquoted heredoc on purpose: values expand now; \$... stays for run time. # Unquoted heredoc on purpose: values expand now; \$... stays for run time.
cat <<HELPER cat <<HELPER
#!/bin/bash #!/bin/bash
# Generated by deploy.sh --apply-network; runs detached. Installs the rendered WAN netplan, # Generated by deploy.sh --apply-network; runs detached. Installs the rendered WAN netplan,
# applies it, verifies the uplink in two stages and restores the pre-run netplan on any failure # removes the NM conf that kept NM off the uplink ($pc), applies, verifies the uplink in two
# (including TERM/INT/HUP, e.g. systemd stopping the unit at its RuntimeMaxSec). # stages and restores the pre-run netplan (and $pc) on any failure (including TERM/INT/HUP,
# e.g. systemd stopping the unit at its RuntimeMaxSec).
# Stage 1 (${d1}s): the apply succeeded and, if the Starlink port exists, NM shows it connected # Stage 1 (${d1}s): the apply succeeded and, if the Starlink port exists, NM shows it connected
# via a netplan-* profile with an IPv4 address whose gateway (IP4.GATEWAY, else the DHCP # via a netplan-* profile with an IPv4 address whose gateway (IP4.GATEWAY, else the DHCP
# routers option: never-default hides IP4.GATEWAY) answers on-link. # routers option: never-default hides IP4.GATEWAY) answers on-link.
# Stage 2 (${d2}s): van-failover restarted; default route and internet ping. # Stage 2 (${d2}s): van-failover restarted; default route and internet ping, and (port present)
# van-failover's own default route on the port (proto static; the NM profile installs none).
# A failed gate logs diagnostics (NM, DHCP routers, networkd, addresses, routes) before restoring.
bak='$bak' bak='$bak'
nd='$nd' nd='$nd'
log='$log' log='$log'
st='$st' st='$st'
sn='$sn' sn='$sn'
rf='$rf' rf='$rf'
ncd='$ncd'
exec >>"\$log" 2>&1 exec >>"\$log" 2>&1
trap 'rm -f "\$0"' EXIT trap 'rm -f "\$0"' EXIT
status() { mkdir -p "\$(dirname "\$st")"; echo "\$1 \$(date -Is)\${2:+ \$2}" > "\$st"; } status() { mkdir -p "\$(dirname "\$st")"; echo "\$1 \$(date -Is)\${2:+ \$2}" > "\$st"; }
echo "== apply-network $stamp \$(date -Is) (backup: \$bak)" echo "== apply-network $stamp \$(date -Is) (backup: \$bak)"
conn_ok() { ip route show default | grep . >/dev/null && ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; } conn_ok() { ip route show default | grep . >/dev/null && ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; }
gate2() {
conn_ok || return 1
[ -e "\$sn/$STARLINK_IFACE" ] || return 0
ip route show default dev '$STARLINK_IFACE' proto static | grep . >/dev/null
}
diag() { # diag <stage>: uplink state for the log; informational, every command bounded and failure-tolerant
echo "--- diagnostics: \$1"
echo "--- nmcli device show $STARLINK_IFACE"
timeout $AN_NM_T nmcli -g GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY device show '$STARLINK_IFACE' 2>&1
echo "--- DHCP4 routers"
timeout $AN_NM_T nmcli -g DHCP4.OPTION device show '$STARLINK_IFACE' 2>&1 | tr '|' '\\n' | grep -E '^ *routers ='
echo "--- networkctl status $STARLINK_IFACE"
timeout $AN_NM_T networkctl --no-pager -n 0 status '$STARLINK_IFACE' 2>&1 | head -8
echo "--- ip -4 addr show $STARLINK_IFACE"
timeout $AN_NM_T ip -4 addr show '$STARLINK_IFACE' 2>&1
echo "--- ip route"
timeout $AN_NM_T ip route 2>&1
echo "--- end diagnostics"
return 0
}
gate1() { gate1() {
[ -e "\$sn/$STARLINK_IFACE" ] || return 0 [ -e "\$sn/$STARLINK_IFACE" ] || return 0
[ "\$(timeout $AN_NM_T nmcli -g GENERAL.STATE device show '$STARLINK_IFACE' 2>/dev/null)" = "100 (connected)" ] || return 1 [ "\$(timeout $AN_NM_T nmcli -g GENERAL.STATE device show '$STARLINK_IFACE' 2>/dev/null)" = "100 (connected)" ] || return 1
@@ -433,6 +506,9 @@ restore() { # restore <reason>: put the pre-run netplan back, re-check connecti
if [ -d "\$bak" ]; then if [ -d "\$bak" ]; then
rm -f "\$nd"/* "\$nd"/.[!.]* rm -f "\$nd"/* "\$nd"/.[!.]*
cp -a "\$bak"/. "\$nd"/ || rb=CP-FAILED cp -a "\$bak"/. "\$nd"/ || rb=CP-FAILED
rm -f "\$nd/nm-pending.conf" # the backed-up NM conf is not a netplan file
# the NM conf that kept NM off the uplink goes back too; NM re-reads it after the re-apply below
[ ! -f "\$bak/nm-pending.conf" ] || cp -a "\$bak/nm-pending.conf" "\$ncd/$pc" || echo "WARNING: could not restore \$ncd/$pc"
else else
# No backup to restore from: at least undo this helper's own two changes. # No backup to restore from: at least undo this helper's own two changes.
rb=BACKUP-MISSING rb=BACKUP-MISSING
@@ -440,6 +516,7 @@ restore() { # restore <reason>: put the pre-run netplan back, re-check connecti
[ ! -f "\$nd/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] || mv "\$nd/10-dhcp-all-interfaces.yaml.vanlink-disabled" "\$nd/10-dhcp-all-interfaces.yaml" [ ! -f "\$nd/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] || mv "\$nd/10-dhcp-all-interfaces.yaml.vanlink-disabled" "\$nd/10-dhcp-all-interfaces.yaml"
fi fi
timeout -k $AN_KILL_T $AN_GEN_T netplan generate && timeout -k $AN_KILL_T $AN_APPLY_T netplan apply || { [ "\$rb" != ok ] || rb=APPLY-FAILED; } timeout -k $AN_KILL_T $AN_GEN_T netplan generate && timeout -k $AN_KILL_T $AN_APPLY_T netplan apply || { [ "\$rb" != ok ] || rb=APPLY-FAILED; }
[ ! -f "\$ncd/$pc" ] || timeout $AN_NM_T nmcli general reload conf || echo "nmcli general reload conf failed"
timeout $AN_RESTART_T systemctl restart van-failover || true timeout $AN_RESTART_T systemctl restart van-failover || true
end=\$((SECONDS+$d3)) end=\$((SECONDS+$d3))
while :; do while :; do
@@ -464,11 +541,18 @@ restore() { # restore <reason>: put the pre-run netplan back, re-check connecti
fi fi
exit 1 exit 1
} }
pending_off() { # hand the uplink to NM: drop the NM conf that kept NM off it, right before the apply
[ -e "\$ncd/$pc" ] || return 0
rm -f "\$ncd/$pc" || return 1
timeout $AN_NM_T nmcli general reload conf || echo "nmcli general reload conf failed (continuing; stage 1 decides)"
return 0
}
status running "\$bak" status running "\$bak"
trap 'restore terminated' TERM INT HUP trap 'restore terminated' TERM INT HUP
applied=0 applied=0
if install -m0600 "\$rf" "\$nd/50-van-wan.yaml" \\ if install -m0600 "\$rf" "\$nd/50-van-wan.yaml" \\
&& { [ ! -f "\$nd/10-dhcp-all-interfaces.yaml" ] || mv "\$nd/10-dhcp-all-interfaces.yaml" "\$nd/10-dhcp-all-interfaces.yaml.vanlink-disabled"; } \\ && { [ ! -f "\$nd/10-dhcp-all-interfaces.yaml" ] || mv "\$nd/10-dhcp-all-interfaces.yaml" "\$nd/10-dhcp-all-interfaces.yaml.vanlink-disabled"; } \\
&& pending_off \\
&& timeout -k $AN_KILL_T $AN_GEN_T netplan generate && timeout -k $AN_KILL_T $AN_APPLY_T netplan apply; then && timeout -k $AN_KILL_T $AN_GEN_T netplan generate && timeout -k $AN_KILL_T $AN_APPLY_T netplan apply; then
applied=1 applied=1
else else
@@ -476,6 +560,10 @@ else
fi fi
s1=0 s1=0
if [ "\$applied" = 1 ]; then if [ "\$applied" = 1 ]; then
# netplan apply restarts networkd, which can drop br0's members: re-run the bridge setup (not gating)
if timeout $AN_NM_T systemctl cat van-br0-setup >/dev/null 2>&1; then
timeout $AN_RESTART_T systemctl restart van-br0-setup || echo "van-br0-setup restart failed (continuing)"
fi
end=\$((SECONDS+$d1)) end=\$((SECONDS+$d1))
while :; do while :; do
gate1 && { s1=1; break; } gate1 && { s1=1; break; }
@@ -483,17 +571,19 @@ if [ "\$applied" = 1 ]; then
sleep 1 sleep 1
done done
fi fi
[ "\$s1" = 1 ] || restore "stage 1: apply or uplink gate failed" [ "\$s1" = 1 ] || { diag "stage 1"; restore "stage 1: apply or uplink gate failed"; }
timeout $AN_RESTART_T systemctl restart van-failover || restore "van-failover restart failed" timeout $AN_RESTART_T systemctl restart van-failover || restore "van-failover restart failed"
s2=0 s2=0
end=\$((SECONDS+$d2)) end=\$((SECONDS+$d2))
while :; do while :; do
conn_ok && { s2=1; break; } gate2 && { s2=1; break; }
[ "\$SECONDS" -ge "\$end" ] && break [ "\$SECONDS" -ge "\$end" ] && break
sleep 1 sleep 1
done done
route_log route_log
[ "\$s2" = 1 ] || restore "stage 2: no default route or internet" [ "\$s2" = 1 ] || { diag "stage 2"; restore "stage 2: no default route, no van-failover route on the port, or no internet"; }
nwl=\$(timeout $AN_NM_T networkctl --no-pager --no-legend list 2>/dev/null | awk -v i='$STARLINK_IFACE' '\$2 == i { print; exit }')
echo "networkd (info, not gating): \${nwl:-$STARLINK_IFACE not listed}"
rm -f "\$rf" rm -f "\$rf"
status ok status ok
trap - TERM INT HUP trap - TERM INT HUP
@@ -507,8 +597,7 @@ apply_network() { # ./deploy.sh --apply-network [--force] — explicit, root; c
[ "${1:-}" = --force ] && force=1 [ "${1:-}" = --force ] && force=1
[ "$(id -u)" = 0 ] || die "--apply-network needs root (sudo)" [ "$(id -u)" = 0 ] || die "--apply-network needs root (sudo)"
[ "$ENABLE_NM_WAN" = 1 ] || die "ENABLE_NM_WAN is 0 in $VANLINK_CONF — nothing to apply" [ "$ENABLE_NM_WAN" = 1 ] || die "ENABLE_NM_WAN is 0 in $VANLINK_CONF — nothing to apply"
dpkg-query -W -f='${Status}' network-manager 2>/dev/null | grep -q 'install ok installed' \ nm_pkg_installed || die "network-manager is not installed — run ./deploy.sh first"
|| die "network-manager is not installed — run ./deploy.sh first"
systemctl is-active --quiet NetworkManager || die "NetworkManager is not running — handing it the uplink port would flap it" systemctl is-active --quiet NetworkManager || die "NetworkManager is not running — handing it the uplink port would flap it"
systemctl cat van-failover >/dev/null 2>&1 || die "van-failover.service does not exist — run ./deploy.sh first (stage 2 of the gate needs it)" systemctl cat van-failover >/dev/null 2>&1 || die "van-failover.service does not exist — run ./deploy.sh first (stage 2 of the gate needs it)"
systemctl is-active --quiet vanlink-apply-network && die "an apply is already running — see /var/log/vanlink-apply-network.log" systemctl is-active --quiet vanlink-apply-network && die "an apply is already running — see /var/log/vanlink-apply-network.log"
@@ -516,10 +605,23 @@ apply_network() { # ./deploy.sh --apply-network [--force] — explicit, root; c
if [ "$force" = 0 ] && [ "$(apply_status_blocks_run "$st")" = yes ]; then if [ "$force" = 0 ] && [ "$(apply_status_blocks_run "$st")" = yes ]; then
die "the last apply did not finish cleanly: $(cat "$st" 2>/dev/null) — inspect $st and $log, check the backup dir named there, then re-run with --force" die "the last apply did not finish cleanly: $(cat "$st" 2>/dev/null) — inspect $st and $log, check the backup dir named there, then re-run with --force"
fi fi
if [ "$force" = 0 ] && [ "$(VANLINK_WAN_NETPLAN='' nm_wan_state_netplan)" = matches ] && [ ! -e /etc/netplan/10-dhcp-all-interfaces.yaml ]; then local np_state problems
np_state=$(VANLINK_WAN_NETPLAN='' nm_wan_state_netplan)
if [ "$force" = 0 ] && [ "$np_state" = matches ] && [ ! -e /etc/netplan/10-dhcp-all-interfaces.yaml ]; then
echo "nothing to apply (WAN netplan already matches; use --force to re-apply)" echo "nothing to apply (WAN netplan already matches; use --force to re-apply)"
return 0 return 0
fi fi
problems=$(apply_preflight_problems "$VANLINK_CONF" /sys/class/net "$(hostname -s)")
if [ -n "$problems" ] && [ "$force" = 0 ]; then
die "refusing to apply:
$problems
(fix it, or re-run with --force)"
fi
if [ "$force" = 0 ] && { [ "$np_state" = differs ] || [ "$np_state" = unreadable ]; }; then
echo "/etc/netplan/50-van-wan.yaml exists and differs from the rendered template (secrets redacted):"
diff -u /etc/netplan/50-van-wan.yaml <(render_wan_netplan) | redact_secrets | sed 's/^/ /' || true
die "refusing to replace an existing, different WAN netplan without --force (review the diff above)"
fi
local stamp helper rendered bak pt local stamp helper rendered bak pt
stamp=$(date +%Y%m%d-%H%M%S)-$$ stamp=$(date +%Y%m%d-%H%M%S)-$$
rendered=/run/vanlink-50-van-wan-$stamp.yaml rendered=/run/vanlink-50-van-wan-$stamp.yaml
@@ -528,6 +630,10 @@ apply_network() { # ./deploy.sh --apply-network [--force] — explicit, root; c
check_rendered ap/50-van-wan.yaml "$rendered" || { rm -f "$rendered"; exit 1; } check_rendered ap/50-van-wan.yaml "$rendered" || { rm -f "$rendered"; exit 1; }
bak=$(mktemp -d "/var/backups/vanlink-netplan-$stamp.XXXX") || { rm -f "$rendered"; die "cannot create backup dir"; } bak=$(mktemp -d "/var/backups/vanlink-netplan-$stamp.XXXX") || { rm -f "$rendered"; die "cannot create backup dir"; }
cp -a /etc/netplan/. "$bak/" || { rm -f "$rendered"; die "backup failed — nothing changed"; } cp -a /etc/netplan/. "$bak/" || { rm -f "$rendered"; die "backup failed — nothing changed"; }
# the NM conf keeping NM off the uplink: the helper removes it right before the apply, a rollback restores it from here
if [ -e "$NM_PENDING_CONF" ]; then
cp -a "$NM_PENDING_CONF" "$bak/nm-pending.conf" || { rm -f "$rendered"; die "backup of $NM_PENDING_CONF failed — nothing changed"; }
fi
if [ ! -e /var/backups/vanlink-netplan-orig ] && [ ! -e /etc/netplan/50-van-wan.yaml ]; then if [ ! -e /var/backups/vanlink-netplan-orig ] && [ ! -e /etc/netplan/50-van-wan.yaml ]; then
pt=/var/backups/vanlink-netplan-orig.tmp.$$ pt=/var/backups/vanlink-netplan-orig.tmp.$$
rm -rf "$pt" rm -rf "$pt"
@@ -537,7 +643,7 @@ apply_network() { # ./deploy.sh --apply-network [--force] — explicit, root; c
fi fi
install -d -m0755 /var/lib/vanlink install -d -m0755 /var/lib/vanlink
rm -f "$st" rm -f "$st"
apply_helper_script "$bak" /etc/netplan "$log" "$st" /sys/class/net "$rendered" "$AN_S1 $AN_S2 $AN_RC" "$stamp" > "$helper" apply_helper_script "$bak" /etc/netplan "$log" "$st" /sys/class/net "$rendered" "$AN_S1 $AN_S2 $AN_RC" "$stamp" "$(dirname "$NM_PENDING_CONF")" > "$helper"
chmod 0700 "$helper" chmod 0700 "$helper"
echo "backup: $bak log: $log" echo "backup: $bak log: $log"
echo "applying from a detached unit (survives an SSH drop); waiting up to $AN_WAIT s ..." echo "applying from a detached unit (survives an SSH drop); waiting up to $AN_WAIT s ..."
@@ -612,11 +718,11 @@ case "${1:-}" in
--apply-status-check) # test seam: --apply-status-check <status-file> prints yes|no (does that status block a new --apply-network run?) --apply-status-check) # test seam: --apply-status-check <status-file> prints yes|no (does that status block a new --apply-network run?)
apply_status_blocks_run "${2:?status file}" apply_status_blocks_run "${2:?status file}"
exit 0 ;; exit 0 ;;
--print-apply-helper) # [conf] the helper script for review; test-only flags: --backup D --netplan-dir D --log L --status S --sysnet N --rendered R --deadlines "s1 s2 rc" --stamp T --print-apply-helper) # [conf] the helper script for review; test-only flags: --backup D --netplan-dir D --log L --status S --sysnet N --rendered R --nm-conf-dir D --deadlines "s1 s2 rc" --stamp T
shift; pc= a_bak=/var/backups/vanlink-netplan-EXAMPLE a_nd= a_log= a_st= a_sn= a_rf= a_dl= a_stamp= shift; pc= a_bak=/var/backups/vanlink-netplan-EXAMPLE a_nd= a_log= a_st= a_sn= a_rf= a_dl= a_stamp= a_ncd=
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--backup|--netplan-dir|--log|--status|--sysnet|--rendered|--deadlines|--stamp) --backup|--netplan-dir|--log|--status|--sysnet|--rendered|--nm-conf-dir|--deadlines|--stamp)
[ $# -ge 2 ] || die "$1 needs a value" ;; [ $# -ge 2 ] || die "$1 needs a value" ;;
esac esac
case "$1" in case "$1" in
@@ -626,22 +732,34 @@ case "${1:-}" in
--status) a_st=$2; shift 2 ;; --status) a_st=$2; shift 2 ;;
--sysnet) a_sn=$2; shift 2 ;; --sysnet) a_sn=$2; shift 2 ;;
--rendered) a_rf=$2; shift 2 ;; --rendered) a_rf=$2; shift 2 ;;
--nm-conf-dir) a_ncd=$2; shift 2 ;;
--deadlines) a_dl=$2; shift 2 ;; --deadlines) a_dl=$2; shift 2 ;;
--stamp) a_stamp=$2; shift 2 ;; --stamp) a_stamp=$2; shift 2 ;;
*) pc=$1; shift ;; *) pc=$1; shift ;;
esac esac
done done
load_conf "${pc:-$VANLINK_CONF}" load_conf "${pc:-$VANLINK_CONF}"
apply_helper_script "$a_bak" "$a_nd" "$a_log" "$a_st" "$a_sn" "$a_rf" "$a_dl" "$a_stamp" apply_helper_script "$a_bak" "$a_nd" "$a_log" "$a_st" "$a_sn" "$a_rf" "$a_dl" "$a_stamp" "$a_ncd"
exit 0 ;; exit 0 ;;
--apply-preflight) # ./deploy.sh --apply-preflight [conf] — the problems --apply-network would refuse on; exit 1 if any
c=${2:-$VANLINK_CONF}
load_conf "$c"
hn=$(hostname -s)
[ "$(id -u)" = 0 ] || hn=${VANLINK_HOSTNAME:-$hn} # test knob, ignored as root
out=$(apply_preflight_problems "$c" "${VANLINK_SYSNET:-/sys/class/net}" "$hn")
[ -z "$out" ] && exit 0
echo "$out"
exit 1 ;;
--render-wan-netplan) load_conf "$VANLINK_CONF"; render_wan_netplan; exit 0 ;; --render-wan-netplan) load_conf "$VANLINK_CONF"; render_wan_netplan; exit 0 ;;
--wan-plan) # ./deploy.sh --wan-plan [conf] — what the NM WAN provisioning would do; changes nothing --wan-plan) # ./deploy.sh --wan-plan [conf] — what the NM WAN provisioning would do; changes nothing
load_conf "${2:-$VANLINK_CONF}" load_conf "${2:-$VANLINK_CONF}"
echo "wan-plan: ENABLE_NM_WAN=$ENABLE_NM_WAN" echo "wan-plan: ENABLE_NM_WAN=$ENABLE_NM_WAN"
if [ "$ENABLE_NM_WAN" = 1 ]; then if [ "$ENABLE_NM_WAN" = 1 ]; then
if [ -n "${WIFI_WAN_IFACE:-}" ]; then echo " wifi WAN: $WIFI_WAN_IFACE"; else echo " wifi WAN: none (wifis stanza omitted)"; fi if [ -n "${WIFI_WAN_IFACE:-}" ]; then echo " wifi WAN: $WIFI_WAN_IFACE"; else echo " wifi WAN: none (wifis stanza omitted)"; fi
if dpkg -s network-manager >/dev/null 2>&1; then echo " packages: network-manager installed"; else echo " packages: network-manager would install"; fi if nm_pkg_installed; then echo " packages: network-manager installed"; else echo " packages: network-manager would install"; fi
echo " netplan: /etc/netplan/50-van-wan.yaml $(nm_wan_state_netplan)" np=$(nm_wan_state_netplan)
if [ "$np" = unreadable ]; then echo " netplan: /etc/netplan/50-van-wan.yaml unreadable (run with sudo)"; else echo " netplan: /etc/netplan/50-van-wan.yaml $np"; fi
echo " uplink NM pending conf: $(nm_pending_action "$np" "$NM_PENDING_CONF")"
if [ -f /etc/netplan/10-dhcp-all-interfaces.yaml ]; then echo " armbian: /etc/netplan/10-dhcp-all-interfaces.yaml present -> would move aside"; else echo " armbian: /etc/netplan/10-dhcp-all-interfaces.yaml absent"; fi if [ -f /etc/netplan/10-dhcp-all-interfaces.yaml ]; then echo " armbian: /etc/netplan/10-dhcp-all-interfaces.yaml present -> would move aside"; else echo " armbian: /etc/netplan/10-dhcp-all-interfaces.yaml absent"; fi
if [ -z "${CELLULAR_APN:-}" ]; then if [ -z "${CELLULAR_APN:-}" ]; then
echo " cellular profile: skipped (no CELLULAR_APN)" echo " cellular profile: skipped (no CELLULAR_APN)"
@@ -975,7 +1093,16 @@ if [ "$ENABLE_NM_WAN" = 1 ]; then
echo "== networkmanager WANs ==" echo "== networkmanager WANs =="
# In place before the package starts NM: no auto-created "Wired connection" profiles. # In place before the package starts NM: no auto-created "Wired connection" profiles.
install -D -m0644 ap/nm-no-auto-default.conf /etc/NetworkManager/conf.d/00-van-no-auto-default.conf install -D -m0644 ap/nm-no-auto-default.conf /etc/NetworkManager/conf.d/00-van-no-auto-default.conf
if ! dpkg -s network-manager >/dev/null 2>&1; then # Also in place before the package starts NM: NM keeps off the uplink port until --apply-network hands it
# over (WAN netplan missing), and the conf goes once the netplan matches; see nm_pending_action. NM picks
# up either change at the `nmcli general reload conf` below.
nm_np_state=$(nm_wan_state_netplan)
if [ "$nm_np_state" = missing ]; then
install_rendered ap/nm-uplink-pending.conf "$NM_PENDING_CONF"
elif [ "$nm_np_state" = matches ]; then
rm -f "$NM_PENDING_CONF"
fi
if ! nm_pkg_installed; then
# Installed only now: the package starts NM, which manages Wi-Fi/gsm devices by # Installed only now: the package starts NM, which manages Wi-Fi/gsm devices by
# default, so van-ap-unmanaged.conf (AP radios, LAN ports) must already be in place. # default, so van-ap-unmanaged.conf (AP radios, LAN ports) must already be in place.
apt-get update -qq || true apt-get update -qq || true
@@ -1003,7 +1130,6 @@ if [ "$ENABLE_NM_WAN" = 1 ]; then
echo " created cellular connection ${CELLULAR_CONN_NAME} (apn ${CELLULAR_APN}) — no modem needed" echo " created cellular connection ${CELLULAR_CONN_NAME} (apn ${CELLULAR_APN}) — no modem needed"
fi fi
fi fi
nm_np_state=$(nm_wan_state_netplan)
if [ "$nm_np_state" != matches ]; then if [ "$nm_np_state" != matches ]; then
warn "WAN netplan is $nm_np_state (not provisioned/up to date): run ./deploy.sh --apply-network (backs up /etc/netplan and rolls back automatically if the uplink does not come back)" warn "WAN netplan is $nm_np_state (not provisioned/up to date): run ./deploy.sh --apply-network (backs up /etc/netplan and rolls back automatically if the uplink does not come back)"
fi fi
+155 -11
View File
@@ -35,8 +35,8 @@ fi
# 2. every file carrying an @TOKEN@ is installed through install_rendered # 2. every file carrying an @TOKEN@ is installed through install_rendered
# (plain `install` would ship the placeholder unrendered — the 2026-09-23 failure) # (plain `install` would ship the placeholder unrendered — the 2026-09-23 failure)
# vanrouter.js: installed by the loop in the cockpit block; 50-van-wan.yaml: reference copy, # vanrouter.js: installed by the loop in the cockpit block; 50-van-wan.yaml: installed only by
# rendered by hand via `deploy.sh render` (deploy.sh only warns when it differs) # the detached helper of `./deploy.sh --apply-network` (a routine deploy only warns when it differs)
# 50-van-wan-wifi.yaml, cellular.nmconnection: installed by dedicated code, not `install_rendered <src> /abs/dst` literal lines # 50-van-wan-wifi.yaml, cellular.nmconnection: installed by dedicated code, not `install_rendered <src> /abs/dst` literal lines
exempt=" cockpit/vanrouter/vanrouter.js ap/50-van-wan.yaml ap/50-van-wan-wifi.yaml ap/cellular.nmconnection " exempt=" cockpit/vanrouter/vanrouter.js ap/50-van-wan.yaml ap/50-van-wan-wifi.yaml ap/cellular.nmconnection "
for f in $(./deploy.sh --templates); do for f in $(./deploy.sh --templates); do
@@ -266,9 +266,16 @@ elif [ -n "$h" ]; then
echo "$h" | grep -q 'enP2p33s0' && ok "apply helper waits on the Starlink port" || bad "apply helper does not reference STARLINK_IFACE" echo "$h" | grep -q 'enP2p33s0' && ok "apply helper waits on the Starlink port" || bad "apply helper does not reference STARLINK_IFACE"
echo "$h" | grep -q 'systemctl restart van-failover' && ok "apply helper restarts van-failover" || bad "apply helper lacks van-failover restart" echo "$h" | grep -q 'systemctl restart van-failover' && ok "apply helper restarts van-failover" || bad "apply helper lacks van-failover restart"
stubs=$(mktemp -d) stubs=$(mktemp -d)
for c in netplan nmcli ip ping systemctl reboot; do for c in netplan nmcli ip ping systemctl reboot networkctl; do
printf '#!/bin/bash\necho "%s $*" >> "$STUB_CALLS"\n' "$c" > "$stubs/$c" printf '#!/bin/bash\necho "%s $*" >> "$STUB_CALLS"\n' "$c" > "$stubs/$c"
done done
cat >> "$stubs/networkctl" <<'STUB'
case " $* " in
*" list "*) echo " 3 enP2p33s0 ether routable configured" ;;
*" status "*) echo "STUB-NETWORKCTL-STATUS" ;;
esac
exit 0
STUB
cat >> "$stubs/netplan" <<'STUB' cat >> "$stubs/netplan" <<'STUB'
if [ "$1" = apply ]; then if [ "$1" = apply ]; then
if [ -e "${STUB_HOLD:-/nonexistent}" ]; then if [ -e "${STUB_HOLD:-/nonexistent}" ]; then
@@ -288,14 +295,25 @@ case "$2" in
IP4.GATEWAY) echo "${NM_GW-}" ;; IP4.GATEWAY) echo "${NM_GW-}" ;;
# never-default: IP4.GATEWAY is empty, the router is only in the DHCP lease options (nmcli -g joins them with " | ") # never-default: IP4.GATEWAY is empty, the router is only in the DHCP lease options (nmcli -g joins them with " | ")
DHCP4.OPTION) echo "${NM_DHCP-requested_routers = 1 | routers = 192.168.100.1 | subnet_mask = 255.255.255.0}" ;; DHCP4.OPTION) echo "${NM_DHCP-requested_routers = 1 | routers = 192.168.100.1 | subnet_mask = 255.255.255.0}" ;;
GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY) echo "STUB-DIAG-NM" ;;
esac esac
exit 0 exit 0
STUB STUB
# STUB_ROUTE: yes | after-rollback | none; STUB_ROUTE_PROTO: static (van-failover's own route, default) | dhcp (a leftover networkd route)
cat >> "$stubs/ip" <<'STUB' cat >> "$stubs/ip" <<'STUB'
if [ "$2" = get ]; then echo "1.1.1.1 via 192.168.100.1 dev x"; exit 0; fi case "$*" in
"route get "*) echo "1.1.1.1 via 192.168.100.1 dev x"; exit 0 ;;
"-4 addr show "*) echo "STUB-ADDR inet 192.168.100.10/24"; exit 0 ;;
esac
r=
case "${STUB_ROUTE:-yes}" in case "${STUB_ROUTE:-yes}" in
yes) echo "default via 192.168.100.1 dev x" ;; yes) r=1 ;;
after-rollback) [ -e "$STUB_ND/50-van-wan.yaml" ] || echo "default via 192.168.100.1 dev x" ;; after-rollback) [ -e "$STUB_ND/50-van-wan.yaml" ] || r=1 ;;
esac
[ -n "$r" ] || exit 0
case "$*" in
*"proto static"*) [ "${STUB_ROUTE_PROTO:-static}" = static ] && echo "default via 192.168.100.1 dev enP2p33s0 proto static metric 200" ;;
*) echo "default via 192.168.100.1 dev enP2p33s0 proto ${STUB_ROUTE_PROTO:-static} metric 200" ;;
esac esac
exit 0 exit 0
STUB STUB
@@ -305,26 +323,32 @@ exit "${STUB_NET_PING:-0}"
STUB STUB
printf '#!/bin/bash\nexec /bin/sleep 0.05\n' > "$stubs/sleep" printf '#!/bin/bash\nexec /bin/sleep 0.05\n' > "$stubs/sleep"
printf '#!/bin/bash\n[ "$1" = -k ] && shift 2\nshift\nexec "$@"\n' > "$stubs/timeout" printf '#!/bin/bash\n[ "$1" = -k ] && shift 2\nshift\nexec "$@"\n' > "$stubs/timeout"
printf '#!/bin/bash\nexit 0\n' >> "$stubs/systemctl" printf '#!/bin/bash\n[ "$1" = cat ] && [ -n "${STUB_NO_BR0:-}" ] && exit 1\nexit 0\n' >> "$stubs/systemctl"
chmod +x "$stubs"/* chmod +x "$stubs"/*
stubs_ok=1 stubs_ok=1
for c in netplan nmcli ip ping systemctl reboot sleep timeout; do [ -x "$stubs/$c" ] || stubs_ok=0; done for c in netplan nmcli ip ping systemctl reboot networkctl sleep timeout; do [ -x "$stubs/$c" ] || stubs_ok=0; done
[ "$stubs_ok" = 1 ] && ok "apply helper sandbox: all system commands stubbed" || bad "apply helper sandbox: stub missing" [ "$stubs_ok" = 1 ] && ok "apply helper sandbox: all system commands stubbed" || bad "apply helper sandbox: stub missing"
an_prep() { # fresh sandbox + generated helper; sets sb an_prep() { # fresh sandbox + generated helper; sets sb
sb=$(mktemp -d); mkdir -p "$sb/netplan" "$sb/sysnet" sb=$(mktemp -d); mkdir -p "$sb/netplan" "$sb/sysnet" "$sb/nmconf"
printf 'armbian\n' > "$sb/netplan/10-dhcp-all-interfaces.yaml"; chmod 0600 "$sb/netplan/10-dhcp-all-interfaces.yaml" printf 'armbian\n' > "$sb/netplan/10-dhcp-all-interfaces.yaml"; chmod 0600 "$sb/netplan/10-dhcp-all-interfaces.yaml"
printf 'old\n' > "$sb/netplan/60-other.yaml" printf 'old\n' > "$sb/netplan/60-other.yaml"
cp -a "$sb/netplan" "$sb/bak" cp -a "$sb/netplan" "$sb/bak"
# the uplink-pending NM conf, and its copy in the backup (as apply_network makes it)
if [ -z "${AN_NOPEND:-}" ]; then
printf '[keyfile]\nunmanaged-devices+=interface-name:enP2p33s0\n' > "$sb/nmconf/zz-van-wan-pending.conf"
cp -a "$sb/nmconf/zz-van-wan-pending.conf" "$sb/bak/nm-pending.conf"
fi
[ -n "${AN_NOPORT:-}" ] || mkdir "$sb/sysnet/enP2p33s0" [ -n "${AN_NOPORT:-}" ] || mkdir "$sb/sysnet/enP2p33s0"
[ -z "${AN_NOBAK:-}" ] || rm -rf "$sb/bak" [ -z "${AN_NOBAK:-}" ] || rm -rf "$sb/bak"
[ -z "${AN_BAKBAD:-}" ] || chmod 000 "$sb/bak/60-other.yaml" [ -z "${AN_BAKBAD:-}" ] || chmod 000 "$sb/bak/60-other.yaml"
printf 'network:\n version: 2\n' > "$sb/rendered.yaml" printf 'network:\n version: 2\n' > "$sb/rendered.yaml"
VANLINK_CONF=hosts/van.conf ./deploy.sh --print-apply-helper --backup "$sb/bak" --netplan-dir "$sb/netplan" \ VANLINK_CONF=hosts/van.conf ./deploy.sh --print-apply-helper --backup "$sb/bak" --netplan-dir "$sb/netplan" \
--log "$sb/log" --status "$sb/status" --sysnet "$sb/sysnet" --rendered "$sb/rendered.yaml" \ --log "$sb/log" --status "$sb/status" --sysnet "$sb/sysnet" --rendered "$sb/rendered.yaml" \
--deadlines "1 1 1" --stamp T > "$sb/helper.sh" --nm-conf-dir "$sb/nmconf" --deadlines "1 1 1" --stamp T > "$sb/helper.sh"
# never run a helper that does not point at the sandbox # never run a helper that does not point at the sandbox
grep -qF "nd='$sb/netplan'" "$sb/helper.sh" && [ "$(id -u)" != 0 ] || { bad "helper does not bake the sandbox netplan dir (or root) - refusing to run"; return 1; } grep -qF "nd='$sb/netplan'" "$sb/helper.sh" && grep -qF "ncd='$sb/nmconf'" "$sb/helper.sh" && [ "$(id -u)" != 0 ] \
|| { bad "helper does not bake the sandbox netplan/NM conf dirs (or root) - refusing to run"; return 1; }
} }
an_run() { # an_run [VAR=val ...] — fresh sandbox, run the helper; sets sb rc an_run() { # an_run [VAR=val ...] — fresh sandbox, run the helper; sets sb rc
an_prep || { rc=99; return 0; } an_prep || { rc=99; return 0; }
@@ -342,6 +366,23 @@ STUB
a_is "success: 50-van-wan.yaml mode" "$(stat -c %a "$sb/netplan/50-van-wan.yaml" 2>/dev/null)" 600 a_is "success: 50-van-wan.yaml mode" "$(stat -c %a "$sb/netplan/50-van-wan.yaml" 2>/dev/null)" 600
[ -e "$sb/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] && [ ! -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && ok "apply helper success: Armbian file moved aside" || bad "apply helper success: Armbian file not moved aside" [ -e "$sb/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] && [ ! -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && ok "apply helper success: Armbian file moved aside" || bad "apply helper success: Armbian file not moved aside"
grep -q '^systemctl restart van-failover' "$sb/calls" && ok "apply helper success: van-failover restarted" || bad "apply helper success: no van-failover restart" grep -q '^systemctl restart van-failover' "$sb/calls" && ok "apply helper success: van-failover restarted" || bad "apply helper success: no van-failover restart"
[ ! -e "$sb/nmconf/zz-van-wan-pending.conf" ] && ok "apply helper success: uplink-pending NM conf removed" || bad "apply helper success: uplink-pending NM conf left in place"
[ ! -e "$sb/netplan/nm-pending.conf" ] && ok "apply helper success: no nm-pending.conf in the netplan dir" || bad "apply helper success: nm-pending.conf leaked into the netplan dir"
# order in the call log: NM conf reload -> netplan generate -> netplan apply -> van-br0-setup restart -> van-failover restart
ln_of() { grep -n -m1 -- "$1" "$sb/calls" 2>/dev/null | cut -d: -f1 || true; }
l_rl=$(ln_of '^nmcli general reload conf$'); l_gen=$(ln_of '^netplan generate'); l_ap=$(ln_of '^netplan apply')
l_br=$(ln_of '^systemctl restart van-br0-setup$'); l_fo=$(ln_of '^systemctl restart van-failover')
[ -n "$l_rl" ] && [ -n "$l_gen" ] && [ "$l_rl" -lt "$l_gen" ] && ok "apply helper success: NM conf reloaded before netplan generate" || bad "apply helper success: NM conf reload missing or after netplan generate (reload '$l_rl', generate '$l_gen')"
[ -n "$l_br" ] && [ -n "$l_ap" ] && [ -n "$l_fo" ] && [ "$l_ap" -lt "$l_br" ] && [ "$l_br" -lt "$l_fo" ] && ok "apply helper success: van-br0-setup restarted after netplan apply, before stage 2" || bad "apply helper success: van-br0-setup restart missing or misplaced (apply '$l_ap', br0 '$l_br', failover '$l_fo')"
[ "$(grep -c '^systemctl restart van-br0-setup$' "$sb/calls")" = 1 ] && ok "apply helper success: van-br0-setup restarted once" || bad "apply helper success: van-br0-setup restart count"
grep -q '^networkd (info, not gating): *3 enP2p33s0 ether routable configured' "$sb/log" && ok "apply helper success: networkd state of the port logged" || bad "apply helper success: networkd info line missing"
grep -q -- '--- diagnostics' "$sb/log" && bad "apply helper success: diagnostics must only be logged on a failed gate" || ok "apply helper success: no failure diagnostics"
rm -rf "$sb"
# (1b) van-br0-setup unit absent: not restarted, still ok; no pending conf: nothing to reload
AN_NOPEND=1 an_run STUB_NO_BR0=1
a_is "no van-br0-setup unit: status" "$(st_word)" ok
grep -q '^systemctl restart van-br0-setup' "$sb/calls" && bad "apply helper: restarted a van-br0-setup that does not exist" || ok "apply helper: van-br0-setup restart skipped when the unit is absent"
grep -q '^nmcli general reload conf$' "$sb/calls" && bad "apply helper: reloaded NM conf with no pending conf" || ok "apply helper: no NM conf reload without a pending conf"
rm -rf "$sb" rm -rf "$sb"
# (2) stage 1 fails: connected (externally) # (2) stage 1 fails: connected (externally)
an_run NM_STATE="100 (connected (externally))" an_run NM_STATE="100 (connected (externally))"
@@ -349,6 +390,22 @@ STUB
grep -q 'ROLLED BACK (restored, connectivity ok)' "$sb/log" && ok "apply helper stage1: log ROLLED BACK" || bad "apply helper stage1: log" grep -q 'ROLLED BACK (restored, connectivity ok)' "$sb/log" && ok "apply helper stage1: log ROLLED BACK" || bad "apply helper stage1: log"
a_is "stage1: Armbian file restored mode" "$(stat -c %a "$sb/netplan/10-dhcp-all-interfaces.yaml" 2>/dev/null)" 600 a_is "stage1: Armbian file restored mode" "$(stat -c %a "$sb/netplan/10-dhcp-all-interfaces.yaml" 2>/dev/null)" 600
[ ! -e "$sb/netplan/50-van-wan.yaml" ] && [ ! -e "$sb/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] && [ -e "$sb/netplan/60-other.yaml" ] && ok "apply helper stage1: netplan dir restored wholesale" || bad "apply helper stage1: netplan dir not restored" [ ! -e "$sb/netplan/50-van-wan.yaml" ] && [ ! -e "$sb/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] && [ -e "$sb/netplan/60-other.yaml" ] && ok "apply helper stage1: netplan dir restored wholesale" || bad "apply helper stage1: netplan dir not restored"
cmp -s "$sb/nmconf/zz-van-wan-pending.conf" "$sb/bak/nm-pending.conf" && ok "apply helper stage1: uplink-pending NM conf restored" || bad "apply helper stage1: uplink-pending NM conf not restored"
[ ! -e "$sb/netplan/nm-pending.conf" ] && ok "apply helper stage1: nm-pending.conf not copied into the netplan dir" || bad "apply helper stage1: nm-pending.conf leaked into the netplan dir"
l_ap2=$(grep -n '^netplan apply' "$sb/calls" | sed -n 2p | cut -d: -f1 || true); l_rl2=$(grep -n '^nmcli general reload conf$' "$sb/calls" | sed -n 2p | cut -d: -f1 || true)
[ -n "$l_ap2" ] && [ -n "$l_rl2" ] && [ "$l_ap2" -lt "$l_rl2" ] && ok "apply helper stage1: NM conf reloaded after the rollback apply" || bad "apply helper stage1: no NM conf reload after the rollback apply (apply '$l_ap2', reload '$l_rl2')"
for m in '--- diagnostics: stage 1' 'STUB-DIAG-NM' 'routers = 192.168.100.1' 'STUB-NETWORKCTL-STATUS' 'STUB-ADDR inet' 'default via 192.168.100.1 dev enP2p33s0'; do
sed -n '/--- diagnostics/,/--- end diagnostics/p' "$sb/log" | grep -qF -- "$m" && ok "apply helper stage1: diagnostics contain '$m'" || bad "apply helper stage1: diagnostics lack '$m'"
done
d_ln=$(grep -n -m1 -- '--- diagnostics' "$sb/log" | cut -d: -f1 || true); r_ln=$(grep -n -m1 'restoring' "$sb/log" | cut -d: -f1 || true)
[ -n "$d_ln" ] && [ -n "$r_ln" ] && [ "$d_ln" -lt "$r_ln" ] && ok "apply helper stage1: diagnostics logged before the restore" || bad "apply helper stage1: diagnostics not before the restore"
grep -q '^networkctl .*status enP2p33s0' "$sb/calls" && ok "apply helper: networkctl status called" || bad "apply helper: networkctl status not called"
grep '^networkctl .*status' "$sb/calls" | grep -qv -- ' -n 0 ' && bad "apply helper: networkctl status must pass -n 0 (no journal)" || ok "apply helper: networkctl status without journal lines"
rm -rf "$sb"
# (2c) stage 1 fails on a host without a pending conf: nothing to restore there
AN_NOPEND=1 an_run NM_STATE=bad
a_is "stage1 without pending conf: status" "$(st_word)" rolled-back
[ ! -e "$sb/nmconf/zz-van-wan-pending.conf" ] && ok "apply helper stage1 without pending conf: none created" || bad "apply helper stage1 without pending conf: one appeared"
rm -rf "$sb" rm -rf "$sb"
# (2b) stage 1 fails: wrong connection name; gateway unpingable # (2b) stage 1 fails: wrong connection name; gateway unpingable
an_run NM_CONN="Wired connection 1"; a_is "stage1 conn name: status" "$(st_word)" rolled-back; rm -rf "$sb" an_run NM_CONN="Wired connection 1"; a_is "stage1 conn name: status" "$(st_word)" rolled-back; rm -rf "$sb"
@@ -383,6 +440,15 @@ STUB
a_is "stage2: exit 1" "$rc" 1; a_is "stage2: status" "$(st_word)" rolled-back a_is "stage2: exit 1" "$rc" 1; a_is "stage2: status" "$(st_word)" rolled-back
[ ! -e "$sb/netplan/50-van-wan.yaml" ] && [ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && ok "apply helper stage2: restored" || bad "apply helper stage2: not restored" [ ! -e "$sb/netplan/50-van-wan.yaml" ] && [ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && ok "apply helper stage2: restored" || bad "apply helper stage2: not restored"
rm -rf "$sb" rm -rf "$sb"
# (5b) stage 2: a default route that is only a leftover networkd/DHCP one (not van-failover's proto static) must not pass
an_run STUB_ROUTE_PROTO=dhcp
a_is "stage2 dhcp-only default route: exit 1" "$rc" 1; a_is "stage2 dhcp-only default route: status" "$(st_word)" rolled-back
grep -q '^ip route show default dev enP2p33s0 proto static$' "$sb/calls" && ok "apply helper stage2: checks van-failover's proto static route on the port" || bad "apply helper stage2: no proto static route check"
grep -qF -- '--- diagnostics: stage 2' "$sb/log" && ok "apply helper stage2: diagnostics logged" || bad "apply helper stage2: diagnostics missing"
[ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && cmp -s "$sb/nmconf/zz-van-wan-pending.conf" "$sb/bak/nm-pending.conf" && ok "apply helper stage2: Armbian file and pending conf restored" || bad "apply helper stage2: Armbian file / pending conf not restored"
rm -rf "$sb"
# (5c) port absent: stage 2 needs no proto static route on it
AN_NOPORT=1 an_run STUB_ROUTE_PROTO=dhcp; a_is "port absent, dhcp route: status" "$(st_word)" ok; rm -rf "$sb"
# (6) clean rollback but no connectivity (uplink unplugged): must NOT reboot # (6) clean rollback but no connectivity (uplink unplugged): must NOT reboot
an_run STUB_ROUTE=none an_run STUB_ROUTE=none
a_is "no connectivity after clean rollback: exit" "$rc" 1 a_is "no connectivity after clean rollback: exit" "$rc" 1
@@ -421,6 +487,10 @@ STUB
[ -n "$so" ] && [ -n "$tr" ] && [ "$so" -lt "$tr" ] && ok "apply helper: status ok is written before the trap is removed" || bad "apply helper: status ok must precede trap removal" [ -n "$so" ] && [ -n "$tr" ] && [ "$so" -lt "$tr" ] && ok "apply helper: status ok is written before the trap is removed" || bad "apply helper: status ok must precede trap removal"
echo "$h" | grep -q "^trap 'rm -f \"\$0\"' EXIT$" && ok "apply helper removes itself on exit" || bad "apply helper lacks the self-remove EXIT trap" echo "$h" | grep -q "^trap 'rm -f \"\$0\"' EXIT$" && ok "apply helper removes itself on exit" || bad "apply helper lacks the self-remove EXIT trap"
echo "$h" | grep -q 'timeout -k 10 30 netplan generate' && echo "$h" | grep -q 'timeout -k 10 90 netplan apply' && ok "apply helper: netplan under timeout -k" || bad "apply helper: netplan lacks timeout -k" echo "$h" | grep -q 'timeout -k 10 30 netplan generate' && echo "$h" | grep -q 'timeout -k 10 90 netplan apply' && ok "apply helper: netplan under timeout -k" || bad "apply helper: netplan lacks timeout -k"
dg=$(echo "$h" | sed -n '/^diag() {/,/^}/p')
[ -n "$dg" ] && ! echo "$dg" | grep -E '(^ *|[;|&(] *)(nmcli|networkctl|ip) ' | grep -q . && [ "$(echo "$dg" | grep -c 'timeout 5 ')" -ge 5 ] \
&& ok "apply helper: every diagnostic command runs under timeout 5" || bad "apply helper: diag() missing or a diagnostic command without timeout 5"
echo "$h" | grep -q journalctl && bad "apply helper must not call journalctl" || ok "apply helper: no journal calls"
fi fi
# 18b. status file gate and STARLINK_IFACE validation # 18b. status file gate and STARLINK_IFACE validation
gs=$(mktemp -d) gs=$(mktemp -d)
@@ -501,6 +571,18 @@ echo "$o" | grep -q -- "-> post-deploy: NetworkManager connection $conn not foun
echo "$o" | grep -q -- '-> post-deploy: WAN netplan differs' && ok "19e: netplan differs -> warn" || bad "19e: netplan warn missing" echo "$o" | grep -q -- '-> post-deploy: WAN netplan differs' && ok "19e: netplan differs -> warn" || bad "19e: netplan warn missing"
printf 'id=%s\n' "$conn" > "$tc/nm/x.nmconnection" printf 'id=%s\n' "$conn" > "$tc/nm/x.nmconnection"
nm | grep -q " ok NetworkManager connection $conn exists" && ok "19e: cellular keyfile scan -> ok" || bad "19e: keyfile scan ok missing" nm | grep -q " ok NetworkManager connection $conn exists" && ok "19e: cellular keyfile scan -> ok" || bad "19e: keyfile scan ok missing"
# 19g. an installed WAN netplan this user cannot read (the helper installs it 0600) is "unreadable", not "differs"
if [ "$(id -u)" = 0 ]; then
ok "19g: unreadable-netplan checks skipped (running as root)"
else
chmod 000 "$tc/np.yaml"
o=$(nm)
echo "$o" | grep -qxF ' ok WAN netplan: cannot read as non-root (run with sudo to verify)' && ok "19g: post-check unreadable netplan -> ok line" || bad "19g: post-check unreadable netplan ok line missing"
echo "$o" | grep -q -- '-> post-deploy: WAN netplan' && bad "19g: post-check must not warn about an unreadable netplan" || ok "19g: post-check does not warn about an unreadable netplan"
p=$(VANLINK_WAN_NETPLAN="$tc/np.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' netplan: /etc/netplan/50-van-wan.yaml unreadable (run with sudo)' && ok "19g: wan-plan unreadable netplan" || bad "19g: wan-plan unreadable netplan line"
chmod 600 "$tc/np.yaml"
fi
# 19f. VANLINK_WAN_NETPLAN is a read-only-path test knob: apply_network must neutralise it, and no apply code reads other VANLINK_* vars # 19f. VANLINK_WAN_NETPLAN is a read-only-path test knob: apply_network must neutralise it, and no apply code reads other VANLINK_* vars
an=$(sed -n '/^apply_network() {/,/^}/p' deploy.sh) an=$(sed -n '/^apply_network() {/,/^}/p' deploy.sh)
echo "$an" | grep -q "VANLINK_WAN_NETPLAN='' nm_wan_state_netplan" && ! echo "$an" | grep -E 'nm_wan_state_netplan' | grep -qv "VANLINK_WAN_NETPLAN=''" \ echo "$an" | grep -q "VANLINK_WAN_NETPLAN='' nm_wan_state_netplan" && ! echo "$an" | grep -E 'nm_wan_state_netplan' | grep -qv "VANLINK_WAN_NETPLAN=''" \
@@ -508,4 +590,66 @@ echo "$an" | grep -q "VANLINK_WAN_NETPLAN='' nm_wan_state_netplan" && ! echo "$a
if { echo "$an"; sed -n '/^apply_helper_script() {/,/^}/p' deploy.sh; } | grep 'VANLINK_' | grep -vE 'VANLINK_CONF|VANLINK_WAN_NETPLAN=' | grep -q .; then bad "19f: apply path reads a VANLINK_* variable"; else ok "19f: apply path reads no VANLINK_* variable besides VANLINK_CONF"; fi if { echo "$an"; sed -n '/^apply_helper_script() {/,/^}/p' deploy.sh; } | grep 'VANLINK_' | grep -vE 'VANLINK_CONF|VANLINK_WAN_NETPLAN=' | grep -q .; then bad "19f: apply path reads a VANLINK_* variable"; else ok "19f: apply path reads no VANLINK_* variable besides VANLINK_CONF"; fi
rm -rf "$tc" rm -rf "$tc"
# 20. final-review fixes
t20=$(mktemp -d)
# 20a. (M-7) the files keeping NM off the AP/LAN ports and the uplink are installed before the package that starts NM
l_pkg=$(grep -n 'apt-get install -y network-manager' deploy.sh | head -1 | cut -d: -f1 || true)
for pat in 'ap/van-ap-unmanaged.conf' 'ap/nm-no-auto-default.conf' 'ap/nm-uplink-pending.conf'; do
l=$(grep -n "^[[:space:]]*install[_ -].*$pat" deploy.sh | head -1 | cut -d: -f1 || true)
[ -n "$l" ] && [ -n "$l_pkg" ] && [ "$l" -lt "$l_pkg" ] && ok "20a: $pat installed (line $l) before network-manager (line $l_pkg)" || bad "20a: $pat install line '$l' must precede the network-manager install line '$l_pkg'"
done
# 20b. (M-7) read-only sub-commands never call apt-get
mkdir -p "$t20/apt"
printf '#!/bin/sh\necho "apt-get $*" >> "$APT_LOG"\nexit 1\n' > "$t20/apt/apt-get"; chmod +x "$t20/apt/apt-get"
for sub in --check --diff --post-check --wan-plan --print-apply-helper; do
PATH="$t20/apt:$PATH" APT_LOG="$t20/apt.log" POSTCHECK_WAIT=0 ./deploy.sh "$sub" hosts/van.conf >/dev/null 2>&1 || true
done
[ ! -s "$t20/apt.log" ] && ok "20b: --check/--diff/--post-check/--wan-plan/--print-apply-helper never call apt-get" || { bad "20b: a read-only sub-command called apt-get"; sed 's/^/ /' "$t20/apt.log"; }
# 20c. (M-3) network-manager is detected with dpkg-query's Status, in one helper
grep -qE 'dpkg -s network-manager' deploy.sh && bad "20c: deploy.sh still uses dpkg -s network-manager" || ok "20c: no dpkg -s network-manager"
[ "$(grep -c 'nm_pkg_installed' deploy.sh)" -ge 4 ] && grep -q "dpkg-query -W -f='\${Status}' network-manager" deploy.sh && ok "20c: nm_pkg_installed (dpkg-query) used by deploy, --wan-plan and --apply-network" || bad "20c: nm_pkg_installed missing or not used in all three places"
# 20d. (I-2) uplink-pending NM conf template and its --wan-plan line
[ "$(grep -v '^[[:space:]]*#' ap/nm-uplink-pending.conf 2>/dev/null | grep -v '^[[:space:]]*$')" = "$(printf '[keyfile]\nunmanaged-devices+=interface-name:@STARLINK_IFACE@')" ] && ok "20d: nm-uplink-pending.conf is [keyfile] + unmanaged-devices+=" || bad "20d: ap/nm-uplink-pending.conf missing or wrong"
expect_line van ap/nm-uplink-pending.conf 'unmanaged-devices+=interface-name:enP2p33s0'
if [ -e /etc/NetworkManager/conf.d/zz-van-wan-pending.conf ]; then
ok "20d: wan-plan pending-conf assertions skipped (this machine has the pending conf)"
else
VANLINK_CONF=hosts/van.conf ./deploy.sh --render-wan-netplan > "$t20/np.yaml"
p=$(VANLINK_WAN_NETPLAN="$t20/none.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' uplink NM pending conf: would install' && ok "20d: netplan missing -> pending conf would install" || bad "20d: netplan missing -> 'would install' expected"
p=$(VANLINK_WAN_NETPLAN="$t20/np.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' uplink NM pending conf: absent' && ok "20d: netplan matches -> pending conf absent" || bad "20d: netplan matches -> 'absent' expected"
echo '# differs' >> "$t20/np.yaml"
p=$(VANLINK_WAN_NETPLAN="$t20/np.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' uplink NM pending conf: absent' && ok "20d: netplan differs (already handed to NM) -> pending conf not installed" || bad "20d: netplan differs -> 'absent' expected"
fi
# 20e. (I-4) apply preflight: port present and conf named after this host
mkdir -p "$t20/sys/enP2p33s0" "$t20/nosys"
pf() { VANLINK_SYSNET="$1" VANLINK_HOSTNAME="$2" ./deploy.sh --apply-preflight "$3" 2>&1; }
o=$(pf "$t20/sys" van hosts/van.conf) && rc=0 || rc=$?
[ -z "$o" ] && [ "$rc" = 0 ] && ok "20e: preflight fine -> no output, exit 0" || bad "20e: preflight fine gave '$o' (exit $rc)"
o=$(pf "$t20/sys" wan hosts/van.conf) && rc=0 || rc=$?
[ "$rc" = 1 ] && [ "$(echo "$o" | grep -c .)" = 1 ] && echo "$o" | grep -q 'van.conf.*hostname wan' && ok "20e: preflight wrong host -> one problem" || bad "20e: preflight wrong host gave '$o' (exit $rc)"
o=$(pf "$t20/nosys" van hosts/van.conf) && rc=0 || rc=$?
[ "$rc" = 1 ] && [ "$(echo "$o" | grep -c .)" = 1 ] && echo "$o" | grep -q 'enP2p33s0' && ok "20e: preflight absent port -> one problem" || bad "20e: preflight absent port gave '$o' (exit $rc)"
cp hosts/van.conf "$t20/other.conf"
o=$(pf "$t20/sys" wan "$t20/other.conf") && rc=0 || rc=$?
[ -z "$o" ] && [ "$rc" = 0 ] && ok "20e: preflight skips the host-name check for a conf outside hosts/" || bad "20e: preflight conf outside hosts/ gave '$o' (exit $rc)"
an=$(sed -n '/^apply_network() {/,/^}/p' deploy.sh)
echo "$an" | grep -q 'apply_preflight_problems "$VANLINK_CONF" /sys/class/net "$(hostname -s)"' && ok "20e: apply_network runs the preflight on the real sysfs/hostname" || bad "20e: apply_network does not call apply_preflight_problems with real paths"
# 20f. (M-8) a differing installed netplan is shown redacted and refused without --force
psk=$(sed -n 's/^ *password: *"\(.*\)"$/\1/p' ap/50-van-wan-wifi.yaml || true)
d=$(diff -u /dev/null <(VANLINK_CONF=hosts/wan.conf ./deploy.sh --render-wan-netplan) | ./deploy.sh --redact) || true
[ -n "$psk" ] && ! echo "$d" | grep -qF -- "$psk" && echo "$d" | grep -qE '^\+ +password: <redacted>$' && ok "20f: --redact masks the netplan Wi-Fi password in a diff" || bad "20f: netplan Wi-Fi password not redacted"
echo "$an" | grep -q 'diff -u /etc/netplan/50-van-wan.yaml <(render_wan_netplan) | redact_secrets' && echo "$an" | grep -q 'without --force' && ok "20f: apply_network prints a redacted diff and refuses a differing netplan without --force" || bad "20f: apply_network differs handling"
# 20g. (M-6) root ignores VANLINK_NM_DIR
cpe=$(sed -n '/^cellular_profile_exists() {/,/^}/p' deploy.sh)
grep -q '"$(id -u)" = 0 ] || d=${VANLINK_NM_DIR' <<<"$cpe" && ok "20g: cellular_profile_exists ignores VANLINK_NM_DIR as root" || bad "20g: cellular_profile_exists must ignore VANLINK_NM_DIR as root"
# 20h. (M-5) documented overrides
hdr=$(sed -n '1,/^set -euo pipefail/{/^#/p}' deploy.sh)
for k in 'VANLINK_CONF=' 'VANLINK_HOSTNAME=' '--apply-preflight'; do
grep -qF -- "$k" <<<"$hdr" && ok "20h: header documents $k" || bad "20h: header lacks $k"
done
rm -rf "$t20"
exit "$fail" exit "$fail"