Commit Graph

158 Commits

Author SHA1 Message Date
Michal
5ed0e4888a labsim: both matrices green end to end, numbers reproduced
Some checks failed
CI/CD / typecheck (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / lint (push) Failing after 24s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
Final confirming run with GRACE=300 and every fix installed:

  --all   ALL PASS  (T0 T3 T5 T8 T11 T12)
  --hard  ALL PASS  (replace / deny / disable, each policy verified)

Second independent measurement of the hard failover, which is what makes the
figures trustworthy rather than anecdotal:

                run 1   run 2
  replace        26s     26s
  deny          148s    141s
  disable        21s     20s

`deny` sits at ~141-148s across both, so it is the AC's dead-peer behaviour
and not a one-off; GRACE=300 keeps roughly 2x margin.

Corrected an overclaim in PPPOE-HA.md while confirming it: the invariant row
read "AC never showed two simdsl sessions", and under session-control=disable
it did -- one live, one orphaned from the destroyed router. Only ever one LIVE
router dialled, which is the invariant that matters. Said so plainly rather
than leaving a table that reads better than the evidence.
2026-09-06 00:42:01 +01:00
Michal
97efb5abb2 labsim: record the failover numbers and how they were nearly wrong
Some checks failed
CI/CD / lint (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / typecheck (push) Failing after 25s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
The three T4 measurements (replace 26s, deny 148s, disable 21s), why deny is
the only one that sizes GRACE, and why 148s is a floor rather than a worst
case -- idle VMs, and an AC that shares an OVS bridge so `virsh destroy` lets
it see the peer vanish in a way a real BRAS never would.

Also writes down the broken setter that made the previous numbers fiction, so
the next person to touch the matrix knows to check the policy actually landed.
2026-09-06 00:27:38 +01:00
Michal
6987b324f1 vrrp-wan: size GRACE from the measured hostile failover, not the theory
Some checks failed
CI/CD / typecheck (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / lint (push) Failing after 24s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
With the matrix actually setting session-control, T4 timed a destroyed
master's takeover at:

    replace    26s
    deny      148s   <-- sizing case
    disable    21s

`deny` is the case GRACE exists for: the AC refuses the survivor until its own
dead-peer timer frees the dead session. The session poller caught it happening
-- the destroyed router's session stayed in the table while the survivor's
dials appeared and were rejected, twice, before one took at 148s.

148s is well past the lcp-echo-interval(30) x failure(3) = 90s budget that 180
was sized against, leaving 32s of margin. GRACE=300 is ~2x the worst observed.
Treat 148s as a floor, not a worst case: these are idle 2-vCPU VMs, and the AC
shares an OVS bridge with the routers, so `virsh destroy` removes the port and
accel-ppp sees the peer vanish. A real BRAS over DSL never learns our router
died and waits out longer timers of its own.

The cost is stated in the conf: GRACE is also how long an alive-but-unroutable
master holds every VIP before yielding. bond0.53 covers most of that -- a DHCP
lease satisfies the check in seconds -- so GRACE only dominates when PPPoE is
the last path. Kept the health check's fallback in step, since keepalived runs
it with no environment and that number decides mastership if the conf is ever
missing.

check_invariant no longer fails blind on the AC's session count. That count is
only a proxy for "two of our routers dialled", and only while the AC enforces
single-session; under `disable` it does not, so a destroyed router's session
lingers and the count reads 2 with exactly one live router dialled. The real
invariant -- at most one router holds pppoe0 -- is now the failing one, and the
stale session is reported as a WARN rather than silenced, because it still
occupies the slot at a real ISP and is precisely what made `deny` take 148s.
2026-09-06 00:25:00 +01:00
Michal
9221c71ff0 labsim: the session-control matrix was never setting session-control
Some checks failed
CI/CD / lint (push) Failing after 10s
CI/CD / test (push) Failing after 9s
CI/CD / typecheck (push) Failing after 24s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
T4 prints "--- session-control=deny ---" and then measures whatever policy the
AC already had. The setter was

  isp "vbash -c 'source script-template; configure; set ...; commit; save'"

and that form does not start a config session at all -- commit dies with
"Invalid command: [commit]" on stderr, which isp() discards. `show
configuration commands | grep session-control` on the ISP VM returned nothing
after a full matrix run: all three iterations had run against the accel-ppp
default. The labels were fiction, and a harness that reports coverage it does
not have is worse than one that reports a failure.

Driving it from a real script FILE works. isp_session_control() does that,
reads the value back, and fails the iteration if it disagrees rather than
measuring the wrong policy. `session-control` is a valid node here (checked
the template dir on VyOS 2026.08.12-0831-rolling), so this was purely the
invocation.

Staged the two Pulumi overrides in migration/ rather than adding them to
kubernetes-deployment: another agent runs `pulumi up` on that repo, so merging
`remove: pppoe0 disable` before the gate exists on vyos002 would let it dial
and take the single Vodafone session off the live master. Ordering is written
at the top of the file.
2026-09-06 00:15:45 +01:00
Michal
4d47b609a2 PPPOE-HA: record the two failure modes found by running the thing
Some checks failed
CI/CD / lint (push) Failing after 8s
CI/CD / test (push) Failing after 8s
CI/CD / typecheck (push) Failing after 25s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
Both were invisible to inspection and only appeared under the hard matrix:
the flap damper tearing down an established session via the lease it stopped
renewing, and a missing peers file being completely silent. The second has a
production edge worth spelling out -- an unsaved commit reverts on reboot and
takes pppoe0 with it, leaving a standby that can never take over while
looking perfectly healthy.
2026-09-06 00:11:32 +01:00
Michal
b659e0d47e vrrp-wan: a flap holdoff must not tear down a live WAN session
Some checks failed
CI/CD / typecheck (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / lint (push) Failing after 25s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
ppp_dial() checked the flap holdoff and returned BEFORE renewing
/run/vrrp-wan/may-dial. That lease is what vrrp-wan-guard expires after
LEASE_TTL, so tripping the damper stopped the renew and the guard hung up
pppoe0 on the MASTER ~80s later. A damper meant to suppress repeated DIALS
was tearing down a working WAN instead.

Observed in labsim, end to end:

  DIAL FLAP: >=6 attempts in 600s -- holding off 900s
  GUARD: lease stale (81s > 75s) -- hanging up pppoe0

An established session now outranks every check below it: ppp_active renews
the lease and returns first. Everything after it only decides whether to
start a NEW session.

Two supporting fixes for how that storm started. The dial attempts were all
no-ops because /etc/ppp/peers/pppoe0 was missing, and nothing said so --
systemd logs "skipped because of an unmet condition check" exactly once and
the gate looks identical to a healthy backup. ppp_dial() now reports it, and
distinguishes "configured but not rendered" (re-commit the subtree) from "no
pppoe0 in config at all", which is what a reboot leaves behind when a commit
was never saved. That is precisely how the sim secondary lost its WAN.

Also `cat | wc -l` rather than `wc -l < file`: redirections are applied left
to right, so the missing-file error escapes the 2>/dev/null on every
first-ever dial.

Harness: T11 copied-then-removed instead of mv, and verifies the restore --
losing that file strands a router permanently, which cost a debugging
session. preflight now refuses to run if either router lacks the peers file
or the pppoe0 config, since every failover result would otherwise be a false
negative blamed on the ISP. New T12 forges a 900s holdoff against a live
session and asserts it survives.
2026-09-06 00:04:41 +01:00
Michal
d626750010 labsim: hard failover and reboot safety hold; runbook for the production apply
Some checks failed
CI/CD / lint (push) Failing after 9s
CI/CD / typecheck (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
Hard failover proven by destroying the master outright (virsh destroy -- no PADT,
the case a graceful stop cannot cover): the survivor took the VIPs, dialled, and
the access concentrator showed exactly ONE simdsl session from its MAC for the
whole seven minutes. When the destroyed router came back it did NOT dial --
ConditionResult=no, ActiveState=inactive, NRestarts=0, pppoe0 absent, may_dial=no
-- and the AC session count stayed at 1. That is the gate doing the one job it
exists for, on the path that previously had no protection at all.

Two more harness bugs of the same family as the last three, both of which
reported a working system as broken:

  - ppp_on() returned an EMPTY string for an unreachable router, and `[ "" = 0 ]`
    is false, so the hard-failover wait sat for its full timeout waiting for a
    DESTROYED box to report zero -- long after the survivor had taken over
    correctly. Absent now means 0.
  - a VM restart recreates its taps under new names and the OVS bond keeps the
    old ones: lacp dies, VLAN 1 goes with it, and the box returns reachable on
    some VLANs and not others. That looked exactly like a failed failover. It is
    the same stale-membership fault ovs_bond_router already detects, but nothing
    ran it after a restart; the harness now does.

migration/PPPOE-HA.md is the runbook: what the design is, why `disable` cannot
work, the measured numbers, the deploy order (vyos002 first, on its own commit,
verified on the wire with tcpdump rather than from state), and the one-line
rollback.

It also records a model hazard found while writing it. The imported baseline
captures the RUNNING state, not the safe one -- vyos001 has no `vif 53 disable`
because it happens to be master, vyos002 does. An apply performed while vyos002
held the VIP would therefore enable vyos001's WAN too, putting the cloned MAC on
both boxes. An override must assert `vif 53 disable` on BOTH, accepting that an
apply then briefly disables the current master's 10 gig until the reconciler
restores it.

Still not applied to production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-05 19:21:13 +01:00
Michal
93fed7826b labsim: PPPoE HA passes the matrix, and the health check had a real flap bug
Some checks failed
CI/CD / typecheck (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / lint (push) Failing after 23s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
Full run green: baseline (one AC session, held by the VIP holder), clean
failover (pppoe0 moves in 26s, old master releases), 10 gig down (route falls to
pppoe0 and the LAN is back online in 5s), lease expiry (the guard hangs up), and
a missing peers file (NRestarts=0, no loop). Evidence in
labsim/wan-failover-evidence/.

The 10 gig test found a genuine bug in vrrp-wan-health, not in the sim. GRACE
was measured from PROMOTION, so an established master had no grace at all --
after hours of uptime `now - since` far exceeds any window. The first moment
bond0.53 went down while pppoe0 was mid-redial, the master failed its own check,
shed every VIP, and the peer inherited the same WAN outage and did the same. A
brief WAN blip would have flapped the production pair. The stamp is now
refreshed on every healthy tick, so grace measures time since the box last
demonstrably HAD a WAN -- survivable wherever the gap happens, not only just
after a promotion.

Three harness bugs, all the same shape, all of which produced a confident wrong
answer before being caught:

  - waiting for "exactly one pppoe0 holder" returns INSTANTLY during a handover,
    because it was already true. The useful question is who holds it.
  - judging connectivity on a single ping 20s after a link drop reported an
    outage that had already healed. Poll, do not sample.
  - `-o PreferredAuthentications=password` suits the routers but not the Alpine
    LAN VMs, whose sshd offers keyboard-interactive: ssh exited 255 before
    running anything and the test read that as "the LAN lost the internet". A
    tcpdump on the router showed the pings leaving pppoe0 NATed to
    198.51.100.117 and the replies coming back the whole time. An exit code that
    can mean "the network is broken" or "I could not log in" is not a
    connectivity test, so the check now asserts on what the guest reported.

That last one is why the harness asks the routers and the access concentrator
rather than a client, and why it refuses to run at all while either router still
has a default route via eth2 -- the libvirt-NAT scaffold answers connectivity
checks the WAN under test would have failed.

Still to run: hard failover (destroy the master), and the session-control
replace/deny/disable axis that brackets Vodafone's unknown behaviour and sets
the final GRACE. Nothing applied to production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-05 19:11:07 +01:00
Michal
4efd70c987 vyos: move PPPoE off the config plane onto a gated systemd unit
Some checks failed
CI/CD / lint (push) Failing after 25s
CI/CD / typecheck (push) Failing after 23s
CI/CD / test (push) Failing after 23s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
PPPoE HA could not work as written, and the reason is structural rather than a
bug: `set interfaces pppoe pppoe0 disable` and `delete` are handled identically
by interfaces_pppoe.py -- both UNLINK /etc/ppp/peers/pppoe0. That path is pppd's
own options file, so the resting state destroyed exactly what the promotion path
needed, and `ppp@pppoe0` restart-looped against it (observed: 47 restarts, zero
sessions at the access concentrator). It also made op-mode `connect interface
pppoe0` unusable, and put every failover behind a priority-322 commit where one
unrelated invalid node fails the whole thing -- which has already taken the
10 gig down once.

pppoe0 is now configured identically and ENABLED on both routers, so the peers
file always exists, and dialling is gated by a drop-in on the unit:

    ConditionPathExists=/run/vrrp-wan/may-dial
    ConditionPathExists=/etc/ppp/peers/pppoe0

/run is tmpfs, so the gate is shut at boot and neither box can dial before VRRP
has decided. That matters more than it first appears: with the node enabled,
interfaces_pppoe.py restarts ppp on EVERY commit touching the pppoe subtree when
the daemon is not running -- so the backup actively tries to dial whenever
anything commits. The gate is the only thing making that a no-op, which is why
vrrp-wan-reconcile now refuses to bless a box whose drop-in is missing: /etc is
per-image, and a VyOS upgrade would otherwise silently remove the protection.

may-dial is a LEASE, not a flag. ConditionPathExists is evaluated at start only
-- it can prevent a dial, never revoke one -- so a reconciler that stops running
while its box is demoted would keep the one ISP session for ever. The reconciler
renews the lease; a new 5s vrrp-wan-guard revokes it, and only ever revokes. It
fired correctly first time: "GUARD: lease stale (81s > 75s)".

Also: remove-then-stop on release (the file's absence blocks a NEW start that a
concurrent commit would trigger); a flap damper, because two routers that both
believe they hold the VIP will both dial and each dial kills the other's session
-- against a real ISP that is how an account gets rate-limited; and a guard on
`cfg` returning empty under commit-lock contention, which had already produced
one spurious "releasing" on a box that needed nothing.

GRACE 90 -> 180. accel-ppp's dead-peer budget is lcp-echo-interval(30) x
failure(3) = 90s, so the old value sat exactly on the boundary: a hard failover
into an AC that does not replace the stale session would fail its own check,
shed the VIPs, and leave both routers in FAULT.

The sim could not have tested any of this. Both routers now get the identical
WAN -- the secondary had none "because two PPPoE clients sharing one credential
is a different failure mode than anything production has", which is backwards:
that IS production. It also left the pair incomparable, ten NAT rules against
none. Safety now comes from resting state, not asymmetry.

Three more things the sim was hiding:
  - the drift check's secondary regex omitted interfaces pppoe/bonding, nat
    source and protocols failover, so it reported "in sync" for a box with no
    WAN at all;
  - the VRRP health-check and transition-script hooks existed on both live VMs
    and in NEITHER generator -- the mechanism under test was pure undetected
    drift;
  - labsim-vyos's only default route was the libvirt-NAT scaffold, so every
    "the LAN still has internet" verdict on it was answered by eth2 rather than
    the WAN. --drop-scaffold applied; the earlier DHCP-failover proof is being
    re-run because of it.

vrrp-wan-install ends the other half of that: the sim's previous proof came from
scripts hand-`sed`-ed in place, so the tested behaviour was not the committed
behaviour. `--check` now makes that a hard failure.

First green run: master holds both WANs, backup released, and the AC reports
exactly ONE session. sim-net-apply.sh check: all four in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-05 18:50:16 +01:00
Michal
2e828b8af2 vyos: a failover you can actually trigger, and four bugs found triggering it
Some checks failed
CI/CD / lint (push) Failing after 8s
CI/CD / typecheck (push) Failing after 8s
CI/CD / test (push) Failing after 8s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
A planned failover had no reliable lever. VyOS offers only `restart vrrp`, and
neither that nor `systemctl restart keepalived` is dependable: with advert_int 1
the peer declares the master dead after ~3.6s and a restart usually finishes
inside that window. Measured -- the same command moved mastership on one run and
not on the next three. A fail-back step you cannot trigger on purpose is not a
procedure, and the recovery card depended on one.

The lever is now `touch /run/vrrp-wan/force-fault`: the health check fails, the
sync group sheds every VIP, and the peer takes over. It exercises the same path
a real WAN loss takes rather than a special case, and it lives in /run so a
reboot cannot leave a router permanently ineligible.

Proven end to end in labsim: lever -> mastership moves -> WAN follows -> the old
master releases -> a LAN VM has internet -> the faulted router returns to BACKUP
and is eligible again.

Getting there exposed four real bugs, two of which would have broken a GENUINE
failover, not just the drill:

  - The grace stamp was written only by the 30s reconciler, so a freshly
    promoted master reached the 5s health check with no stamp, scored grace = 0,
    failed instantly and went FAULT. With the peer already faulted that left
    BOTH routers in FAULT and the LAN with no gateway at all -- worse than the
    outage the check exists to prevent. The check now stamps on promotion.
  - And it inherited STALE stamps from an earlier mastership, failing ~5s after
    passing. The stamp is now cleared on the way down, by the health check
    itself, not only by the reconciler.
  - The lock fd leaked into VyOS's config session: `exec 9>` is inherited by the
    long-lived unionfs-fuse the session spawns, which never closes it. From the
    first config change on, every later reconciler run lost the flock and exited
    0 having done nothing -- healthy-looking journal, silently stopped
    reconciling. That is how a demoted router kept the WAN. Children now get 9>&-.
  - vrrp-wan-apply touched pppoe0 unconditionally. On a box where pppoe0 has no
    source-interface VyOS rejects the whole commit ("Physical source-interface
    required"), taking the bond0.53 change down with it -- and the script still
    returned 0, so the reconciler logged a release that never happened. pppoe0 is
    now guarded on existence and the commit's verdict is propagated.

Still NOT applied to production. The pair is single-homed on WAN until it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 23:27:17 +01:00
Michal
7bf3f42e19 vyos: WAN follows VRRP mastership, rehearsed in labsim
Some checks failed
CI/CD / typecheck (push) Failing after 10s
CI/CD / test (push) Failing after 9s
CI/CD / lint (push) Failing after 36s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
The ISP is consumer with one static IP, so "both routers hold WAN" is not
available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a
single credential. The WAN therefore has to move with mastership.

Proven end to end in the sim: the secondary was promoted, took the WAN, got a
lease, installed a default route, and a LAN VM reached the internet through it
(3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master
converged correctly too: it came back BACKUP with the WAN disabled while the
peer kept it.

The rehearsal earned its keep four times over, and none of these were visible
from reading the docs:

  - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it
    through keepalived-fifo.py, and on one promotion that helper logged NOTHING
    while Keepalived_vrrp logged all six instances entering MASTER and the
    built-in notify_master for conntrack-sync ran normally. The result was a
    router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by
    the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer:
    the scripts give speed, the timer gives correctness.

  - The health check must ask REALITY, not a marker. Keying "am I master" on a
    /run file written by the transition script meant that when the script did
    not run, the router believed it was backup, passed the check, and kept the
    VIPs it could not serve. It now asks whether the VIP is actually on the box.

  - The old address-based check DEADLOCKED this design: may-I-be-master required
    already having WAN, and only the master gets WAN. That is why vyos002 sat in
    FAULT for ever -- the safety check had silently removed the redundancy it
    existed to protect.

  - script-template must be the FIRST thing a script does. Sourced after an if,
    an exec and a mkdir it terminated the script inside the source, rc=0, no
    output: the reconciler reported success having done nothing. Hence the split
    into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses.

Two hazards found and handled rather than discovered in production:

  - A `configure` session whose process dies leaks a unionfs mount under
    /opt/vyatta/config/tmp, and one of those holds the commit lock -- after
    which every commit fails, including the manual one you try to fix it with.
    A 30s job that can leak one per failure wedges the box on its own, so the
    reconciler reaps dead sessions before it starts. It cleared 8 on the sim.

  - Any `save` while a box is master persists the enabled WAN into config.boot,
    so a reboot would claim the shared MAC regardless of VRRP state. Observed:
    an ordinary console-apply did exactly this. config.boot must keep `disable`
    on BOTH routers; the model asserts it and vyos:verify reports it as drift.

NOT yet applied to production, and it should not be until the remaining item is
settled: a clean, deliberately-triggered failover has been seen via reboot, but
`restart vrrp` twice failed to move mastership at all, so the trigger for a
planned failover is still unproven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
Michal
09ede73b67 migration: the watcher that caught vyos002, and what it found
vyos002-catch.sh is armed before power-on and strips the eth2 address the moment
SSH answers -- 6 seconds, where a human watching a console loses that race more
often than not. It commits eth2 on its own before doing anything else, because
every extra command in that commit is extra exposure.

Caught at 15:35:22 on the LoT leg. eth2 ended with no address and the sync-group
health-check installed; it exits 1 (WAN disabled on this box), so all six VRRP
groups sit in FAULT and it holds no VIPs at all. That is the protection that was
missing on 2026-09-02, working as intended rather than as a theory.

The bounded-risk note in the header is the part worth keeping: it comes up BACKUP
behind a healthy vyos001, so it never holds 192.168.8.1 and the GATEWAY cannot be
poisoned during the window. The unbounded case is it becoming MASTER with eth2
present, which the health-check now makes impossible.

It also surfaced that vyos002 had booted from a saved config predating the day's
work -- stale reservations including the two that hand the routers' own eth2 NICs
192.168.8.143/.144. Synced to vyos001 and imported; see kubernetes-deployment
7f92974.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 15:51:47 +01:00
Michal
a973b51b9c migration: vyos001 converted in production, and the config vyos002 needs first
UniFi does offer Native VLAN = None (7.5.10), and its dropdown lists Management
as VLAN 1 -- the API reports vlan:null, which is what made this look like a
blocker. The UI was right.

Applied to USW Aggregation ports 1+2, then the router over ssh vyos@10.0.1.252.
Confirmed by kea's own log: "the interface bond0 has no usable IPv4 addresses
configured" -- it opens no socket on the parent, so there is no wrong pool left
to answer from. VRRP held mastership with no transition; the VIPs never moved.

Proven end to end by the thing that was broken: vyos002's JetKVM console, which
had been sitting on a VLAN 3 port holding Management lease 192.168.1.28, was
restarted and took 192.168.3.14 -- one lease, right pool, right reservation.
That console is what unblocks vyos002.

commit-confirm cannot be driven non-interactively: `vbash -s` hangs on its
prompt. It failed safely (candidate discarded, nothing committed) but the
recovery card's commit-confirm advice only works typed by hand.

vyos002-return.conf carries the two defects that must not survive its next boot.
eth2 is US24 port 16, native VLAN 2 -- disable that port before powering the box
on and the ARP hazard is gone before it can happen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 15:26:21 +01:00
Michal
d727a50ca0 migration: offline recovery card, and stop the leak test trusting a silent router
The change itself takes Management down, so the session that performs it has no
internet and no Claude. RECOVERY-CARD-vlan1-move.md is what is actually needed at
that point, on one page: the recovery path, the order, the commands, and what to
do when locked out.

The recovery path is `ssh vyos@10.0.1.252`, and the reason it is trustworthy is
that it is not routed -- the workstation is 10.0.0.210/23 and the router's LoT
leg is 10.0.1.252/23, so `ip route get` returns dev lanbr0 with no `via`. It
therefore survives Management, VRRP, the VIPs, DNS and the switch trunk all being
wrong at once. bond0.10 is untouched by the change and stays in the LAN group.

Order is switch-first, which is the opposite of what seems natural. The UniFi
controller is 192.168.1.5, on Management, reached from LoT *through vyos001*: do
the router first and you lose the controller you still need for the switch.

Two corrections to the leak test, both because it reported a router fault that
was its own:

  - it counted `pgrep -f 'tcpdump -i bond0'`, so a stray tcpdump from an earlier
    run satisfied the >=2 guard with none of this run's captures alive. A capture
    that records nothing reads as "the router sent no reply at all".
  - it believed a single silent run. Kea can be is-active and answering nothing
    for tens of seconds after a restart, so the first VLANs of a loop failed and
    the last passed. That produced two OPPOSITE and equally wrong conclusions
    about `listen-interface` before a retry showed the pattern.

On `listen-interface`: it is a real second branch in kea-dhcp4.conf.j2 that keeps
dhcp-socket-type raw, unlike listen-address which forces udp and took DHCP down
when it was tried live. But naming the sub-interfaces explicitly left Management
DHCP dead in the sim, reproducibly, against a clean control with interfaces:["*"].
Not adopted, and not needed -- the address move is the proven fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 14:30:35 +01:00
Michal
0481c38e09 labsim: prove the tagged-Management fix for kea's wrong-pool offers
Kea #1117: with dhcp-socket-type raw, a frame tagged for a sub-interface is
also delivered to the parent's AF_PACKET socket, and if the parent serves a
subnet kea answers from it too. Management being the native VLAN on bond0 is
what gives the parent that subnet. One DISCOVER on VLAN 3 produced two OFFERs,
and in the captures here the WRONG one arrives first as often as not -- which
is why this looked device-dependent rather than like a server bug.

labsim-vlan-leak-test.sh reproduces it and scores the SERVER's offers, not the
client's choice; a client picking correctly is how this hid. Fails on the old
shape, passes on the new one across all six LAN VLANs.

Three things the rehearsal caught that reasoning had not:

  - kea keeps its old raw socket. VyOS does not restart it for an interface
    address change, so the first post-fix test failed and looked exactly like
    the fix not working.
  - interface-group LAN names the bare bond0. Moving the address without
    moving the group drops every management session under default-deny.
  - there is no make-before-break. A port always egresses its native VLAN
    untagged, so while VLAN 1 is native the router can send tagged VLAN 1 but
    never receive it -- verified, the ARP landed on bond0 untagged.

What makes the cutover safe anyway is that tagged and untagged Management
coexist, so the firewalls convert one at a time: 0s of VIP downtime, versus
5m30s if both routers go before the switch does. In that state the healthy
BACKUP does NOT take over -- the sync group holds native BACKUP because the
other VLANs still hear the master.

Also fixes two ways the sim was lying. ovs_bond_router compared only the trunk
VLAN list on re-runs, so a VM restart left the bond holding taps that no longer
existed while the real ones sat in the bridge unbonded -- labsim-vyos2 had no
LACP at all. And the tap count included the primary's libvirt-NAT scaffold NIC,
so the primary's bond was skipped outright.

Runbook: migration/MANAGEMENT-VLAN-TAGGED.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 14:03:44 +01:00
Michal
23783b8486 vyos: VRRP health check so the WAN and the gateway VIP cannot separate
Outage of 2026-09-02: vyos002 held every floating gateway IP while vyos001 held
the only working WAN. The LAN had a gateway that could not reach the internet,
and it stayed that way until vyos002 was powered off by hand.

Three causes, none of them bad luck. VRRP had no health check of any kind, so
mastership was decided purely on whether the peer was still advertising and
never on whether this router could route. vyos002 structurally cannot route --
bond0.53 is `disable`d because the 10 gig lease is bound to a cloned MAC that
only one box may hold, and pppoe0 is not up. And `no-preempt` is set on every
group, so once vyos002 took master it kept it even with a healthy priority-200
peer sitting next to it.

Reproduced exactly in labsim: stopping keepalived on the primary moved every VIP
to the WAN-less secondary and LAN internet went from 9ms to 100% loss; restarting
the healthy primary left it BACKUP and the outage in place. Applying this check
self-healed it -- secondary to FAULT, primary to MASTER, internet back.

The check asks "do I have an address on a WAN interface", deliberately not "can
I reach the internet" and not "do I have a default route". During a real ISP
outage the default route disappears on BOTH routers; keying on that would put
both in FAULT, nobody would hold the VIPs, and an internet outage would become a
total one.

Config goes on the SYNC GROUP, not per group: VyOS refuses a per-group check
while the group is in a sync group ("Only sync group health check will be
used"), and sync-group scope is what we want anyway so all VIPs move together.

Known cost, measured: with the primary genuinely dead the secondary stays FAULT
and NOTHING holds the gateway, so inter-VLAN routing stops too. That is the
honest consequence of a backup that cannot route. The fix for it is to make the
WAN follow mastership so the backup CAN route -- next, and rehearsed separately,
since it is the one change that can lose the DHCP lease.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 11:39:44 +01:00
Michal
11344eab92 labsim: the IPAM switch needs no pod recycle when the CIDR sources agree
Re-ran the 3-node rehearsal, this time from a state that matches production
rather than one I had accidentally skewed.

The earlier "two nodes swapped CIDRs" result was an artefact of my own setup:
labsim had been running cluster-pool with per-node CIDRs that differed from
node.spec, I flipped it to ipam=kubernetes (which resyncs CiliumNode from
node.spec), and flipping back therefore looked like a renumber. Production has
only ever run ipam=kubernetes, and all five nodes were checked: CiliumNode and
node.spec agree everywhere.

From that matching state the switch is close to a non-event: per-node CIDRs
unchanged, every pod still inside its node's range, nothing stranded, no recycle
required. The only disruption is the cilium DaemonSet restarting itself -- one
agent sat in Init:0/6 and one node briefly took the agent-not-ready taint, both
of which cleared on their own. Cross-node connectivity verified after.

So the recycle is CONDITIONAL, not a fixed step, and `verify` is what decides.
Documented both cases in the script header, because the dangerous one is silent:
stranded pods report Running and Ready while being unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-24 23:20:16 +01:00
Michal
e8679f45b5 labsim: rehearse the IPAM switch on 3 nodes, and catch the trap in it
Rehearsed kubernetes -> cluster-pool on the 3-node labsim cluster, which is the
transition production faces. The switch itself is undramatic: agents stayed up,
the operator adopted the pool, and the agent-not-ready taint deadlock did NOT
occur. That deadlock is specific to ADDING IPv6 -- the agent blocks on an IPv6
pod CIDR that does not exist yet. A v4-only mode switch does not hit it.

The real hazard is quieter. The operator does not preserve which node held which
/24: two nodes swapped CIDRs. Their existing pods kept their old addresses,
which now fall outside the node's range, so every other node routes that prefix
to the wrong node. Cross-node ping to those pods dropped 100% while every pod
stayed Running and every node stayed Ready. Nothing in `kubectl get pods` shows
it.

So "pods kept the same address" is the FAILURE signal here, not the reassurance
it looks like. cilium-ipam-switch.sh verify now flags pods sitting outside their
node's CIDR, which is the check that decides whether a recycle is optional
(it is not) or mandatory (it is).

Recycling every deploy/ds/sts restored it: all pods back inside their node CIDR,
cross-node ping 0% loss. Sequence proven end to end:
  preflight -> apply -> restart operator then agents -> unstick if needed ->
  recycle all workloads -> verify

Also fixed the recycle hint the script printed: `kubectl rollout restart
deploy,ds,sts -A` is not valid (`unknown shorthand flag: 'A'`), so anyone
following it under pressure would have got an error instead of a recycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-24 23:13:20 +01:00
Michal
527e0798ae bastion/k3s: bootstrap new clusters with cluster-pool IPAM
Every cluster this bastion builds was pinned to ipam=kubernetes, which makes it
permanently single-stack: Cilium reads node.spec.podCIDRs, the controller-manager
writes that once at node join and never revises it, so adding IPv6 later fails
with `required IPv6 PodCIDR not available` and needs a rebuild.

cluster-pool puts allocation in the CiliumNode CRD, where the operator can add
an address family to a running node. Proven in labsim (842408c). It is also
Cilium's own default -- ipam=kubernetes was the deviation.

Pool matches the k3s cluster-cidr (10.42.0.0/16, /24 per node), so a node gets
the same CIDR shape it would have had. IPv4-only for now: the IPv6 pool wants an
apiserver carrying an IPv6 service CIDR, which is a separate change.

Mirrors @michal/cilium-values ciliumBaseValues (cilium-values@11ee509), which
these paths track but do not import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-24 22:59:55 +01:00
Michal
842408c0d9 labsim: prove k3s CAN be converted to dual-stack in place
k3s documents that dual-stack "cannot be enabled on an existing cluster". Rather
than accept that for a 145-day-old production cluster, build both shapes and
diff them. dualstack-lab.sh builds a single-node IPv4 cluster and a native
dual-stack one, takes a reflink copy of the IPv4 disk so a failed conversion
costs 90 seconds to undo, converts in place, and diffs the results.

Result: the conversion works. etcd data is never touched.

The documented blocker is real but narrower than stated. Cilium reports it
exactly -- `required IPv6 PodCIDR not available` -- because node.spec.podCIDRs
is assigned at join and is immutable, and the Kubernetes IPAM controller will
not add a second family later. That objection only holds while Cilium runs
ipam=kubernetes and therefore reads that field. Switching to cluster-pool IPAM
moves pod CIDR allocation into the CiliumNode CRD, where the operator hands out
both families on a cluster that was born IPv4-only.

Sequence that works, in order:
  1. k3s unit gains --cluster-cidr/--service-cidr/--node-ip with both families.
     k3s validates the two CIDRs together and refuses to start on a mismatch
     ("must share the same IP version"), so a partial edit crash-loops rather
     than coming up half-configured. That is the safe failure mode.
  2. The ServiceCIDR object picks up the IPv6 range on restart -- this is
     upstream's supported "single-to-dual-stack preserving the primary
     ServiceCIDR" path, and existing Services keep their IPv4 addresses.
  3. Cilium to ipam=cluster-pool with an IPv6 pool, then DELETE the CiliumNode
     so the operator reallocates; it will not add a family to an existing one.
  4. Expect a deadlock here: the agent will not go ready without a pod CIDR, so
     the node keeps the node.cilium.io/agent-not-ready taint, so the new
     operator that would assign the CIDR cannot schedule. Remove the taint by
     hand once to break it.

Verified on the converted cluster: pod with 10.42.0.125 AND fd00:42::4843, and
a PreferDualStack Service holding 10.43.122.115 AND fd00:43::72a3.

The only field that still differs from a native build is node.spec.podCIDRs,
which stays IPv4-only -- immutable, and unused once Cilium owns IPAM. CiliumNode
podCIDRs and ServiceCIDR are identical to the native cluster.

Not yet answered: this is one node. Whether a 3-server etcd cluster converts as
cleanly, and what a rejoining agent does, is the next experiment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-24 22:46:27 +01:00
Michal
ad6eb7a9a6 labsim: default-deny firewall policy, proven in the sim
Some checks failed
CI/CD / lint (push) Failing after 10s
CI/CD / test (push) Failing after 10s
CI/CD / typecheck (push) Failing after 24s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
Policy: internal VLANs reach each other and the internet; the internet
initiates nothing inward. That was already the effect of the IPv4 ruleset, but
built as a blacklist -- default-action accept plus explicit drops per WAN
interface. Identical behaviour right up until a WAN is added, at which point it
is open and nothing looks wrong. This expresses it as a whitelist.

Two findings from the sim, both of which would have been outages in production:

`set` on a rule number is ADDITIVE. The sim already had a rule 10 carrying
inbound/outbound interface constraints; `set ... rule 10 state established`
ANDed onto it, producing a stateful-accept that applied to one interface pair
only. Return traffic from the internet then matched no rule and hit the default
drop, so LAN hosts could reach nothing outbound. The generator now deletes each
filter before rebuilding it, so the code owns the subtree. It is one commit, so
nftables is rebuilt atomically -- there is no window without a firewall.

DHCP lease renewal is unicast UDP to port 68 and conntrack does not reliably
cover it. Without an explicit rule the WAN keeps working until the lease
expires and then dies -- a delayed failure that looks nothing like a firewall
change. Also added a loopback accept for both families, absent from the v6
policy since it went default-deny.

Verified in labsim: inter-VLAN ok, LAN-to-internet ok, internet-to-router
dropped, and internet-to-LAN dropped with the drop counter incrementing by
exactly the packets sent, after routing the test through the router rather than
around it via the hypervisor.

Also extends the drift check to the firewall subtree, which it did not cover --
so it had been reporting "in sync" while that subtree was uncaptured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-22 22:25:55 +01:00
Michal
7f551081ad labsim: capture BGP, dual WAN and both ISP VMs as code
The sim's routing config existed only as running state on the VMs. It was
applied by hand over SSH, so rebuilding a VM lost the rehearsal and nothing
recorded why any of it was shaped the way it was. The two ISP VMs were not
referenced anywhere in the repo at all.

sim-net-config.py generates all four roles; sim-net-apply.sh applies them over
the serial console, or diffs them against the running VMs. Verified reproducing
live state exactly before committing: primary 40/40 commands, secondary 16/16,
isp-dhcp 19/19, isp-pppoe 21/21.

Carries the reasoning that was previously nowhere: RFC 8212 needing policy in
both directions or the session carries zero prefixes; probe targets that must
not double as system name-servers; default-route-distance 210 rather than
no-default-route, which blanks new_routers and hands the default route to the
backup line; and the WI-8 bootstrap bug that pinned /32s fix.

Dropped a stale `pppoe-server interface eth0` on isp-pppoe (a NIC that does not
exist there) so a green drift check stays meaningful.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-22 16:26:36 +01:00
Michal
f41ffdd039 feat(vyos): reconciler that keeps the HE 6in4 tunnel on the live WAN
Some checks failed
CI/CD / lint (push) Failing after 9s
CI/CD / test (push) Failing after 13s
CI/CD / typecheck (push) Failing after 25s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
Kernel-level (`ip tunnel change`), not VyOS config: no commit churn on a
flapping line, no drift against the Pulumi model, and a reboot restores
config.boot — which pins the 10 gig — so a wrong source cannot survive a
restart.

Sends `myip` explicitly, because mid-failover the update request may egress
either line and letting HE infer the address would point the tunnel at the WAN
we just left. Requires two consecutive agreeing runs before acting, since HE
rate-limits updates and a flapping WAN would hammer the API precisely when it
matters.

MTU moves with the WAN: 1480 on the 10 gig (1500-20), 1472 on PPPoE (1492-20).
Fixed at 1480, the backup path gives the signature people lose a day to — small
packets fine, large transfers hang.

Inert without /config/he-secrets, and a no-op when already in sync.
2026-08-21 02:04:43 +01:00
Michal
a187703a3a feat(vyos): pin a known-good config and restore it with one command
Some checks failed
CI/CD / lint (push) Failing after 9s
CI/CD / test (push) Failing after 10s
CI/CD / typecheck (push) Failing after 24s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
VyOS already has rollback, but `rollback 1` returns you to the *previous*
revision, which may itself be broken — you can end up walking backwards through
several bad commits hunting for the one that worked, at exactly the moment you
have no network to look things up with. This pins a state a human has actually
used and found working, so recovery is one step and needs no memory of how many
changes ago things were fine.

    /config/vyos-known-good save      pin the running config
    /config/vyos-known-good status    when it was taken, how running differs
    /config/vyos-known-good diff      what a restore would change
    /config/vyos-known-good restore   go back to it

Deliberately not automatic. A config is only known-good once someone has used
the network; a snapshot taken after every commit would faithfully preserve the
broken one.

The restore is itself commit-confirmed, so even the recovery path is protected:
if the snapshot is somehow wrong, or access is still broken and nothing can be
confirmed, the router undoes the restore rather than leaving you worse off.
Silence reverts.

`save` refuses when there are uncommitted changes — a snapshot that did not
match what is actually running would look like a safety net without being one.

Two things found while building it, both of which made the script silently
useless rather than fail loudly:

  - Sourcing `script-template` **resets the positional parameters**, so `$1` was
    empty by the time the case statement ran and every invocation fell through
    to the usage message. Arguments are captured before the source.
  - `0600` made the snapshot unreadable to the `vyos` user, so `status` and
    `diff` — the two commands you run while deciding whether to restore — showed
    nothing. Now 0660 root:vyattacfg, matching /config/config.boot.

Installed on both routers with the current, verified-working config pinned
(vyos001 1029 lines, vyos002 1019).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-21 01:46:43 +01:00
Michal
86c2a36f00 feat(labsim): a real Kubernetes cluster for rehearsing Cilium <-> VyOS BGP
Some checks failed
CI/CD / lint (push) Failing after 9s
CI/CD / typecheck (push) Failing after 8s
CI/CD / test (push) Failing after 9s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
BGP is about to be added to a network that currently works, where a bad
advertisement blackholes the house. That needs somewhere to fail first.

Three Debian nodes (4 GB / 2 vCPU) on the OVS `vlan2` access ports running k3s
with flannel disabled, so Cilium is the CNI under test. Three rather than two
because ECMP is only meaningfully tested if a node can be drained and more than
one path survives.

VMs rather than k3d: the thing under test is eBGP between Cilium and VyOS across
the switch fabric, nodes peering with the router's bond0.2 leg, directly
connected. k3d would put the nodes on a container bridge -- a different L2 path,
proving something else. (It also wants Docker; this host has podman.) The
existing micro VMs are Alpine with 256 MB and 1 vCPU, which is not close to
enough.

Debian rather than the sim's Alpine base: glibc, a stock kernel that Cilium's
eBPF probes are tested against, and cloud-init that actually applies
network-config -- the Alpine base notably does not.

One trap worth recording. An earlier draft called `selected_vlans "$K8S_VLAN"`
before `ovs_up`, and since `ovs_up` re-defines the libvirt network from
SELECTED, that silently deleted the portgroups for every other VLAN. Running
VMs kept working -- their taps were already attached -- so nothing complained
until the ISP VMs needed vlan51 and vlan53 and could not be attached. It now
selects every VLAN.

The generated kubeconfig is gitignored: it carries cluster-admin credentials and
is one `git add -A` away from being committed. Regenerate with
`k8s-up.sh --kubeconfig`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-19 13:15:45 +01:00
Michal
27a343bc75 Merge branch 'feat/unifi-export-and-vyos-dhcp': USG to VyOS migration
Some checks failed
CI/CD / typecheck (push) Failing after 12s
CI/CD / test (push) Failing after 10s
CI/CD / lint (push) Failing after 25s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
Carries the UniFi export tooling, the generated VyOS DHCP/DNS config, the
reversible cutover switch, the health-checked WAN failover, and the labctl
side of applying a Pulumi-rendered bundle at install time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 12:19:48 +01:00
Michal
672b89ce38 feat(labctl): install VyOS from a Pulumi-rendered bundle, and enable its API
Two halves of the same problem: a router should come up running the config
that is declared for it, and should be manageable the moment it does.

--vyos-bundle applies a bundle rendered by kubernetes-deployment verbatim,
replacing the derived --vyos-bond/--vlan/... path rather than merging with it.
Deriving a second opinion alongside a bundle is exactly the drift the bundle
exists to prevent: Pulumi and labctl would each believe they knew the
router's config and the box would end up with whichever ran last. Passing
both is rejected rather than silently resolved.

Secret-valued nodes arrive as @secret: sentinels and are dropped, with a
warning naming each one. Writing the sentinel text into config.boot would
look configured while being wrong, which is worse than being absent -- the
router comes up without its PPPoE credential and the first `pulumi up`
supplies it. A bundle committed to git has to stay safe to read.

The hostname is forced to the one the install was asked for. A bundle is
exported from one router and reused for its peer, and taking the hostname
from it would put two vyos001s on the network.

--vyos-api-key enables the HTTP API at install, on both the bundle and the
derived path, so every VyOS this bastion provisions is manageable from first
boot. vyos001 and vyos002 predate this and had to be enabled by hand on a
live firewall after their cutover -- which is the gap this closes. It is
deliberately not part of the Pulumi model: a provider able to rewrite its own
transport can revoke its own access.

listen-address is always set, and the API is NOT enabled when no address is
known -- under DHCP there is none at build time, and binding to every
interface would publish a config-write endpoint on the WAN. It warns and
leaves the router SSH-only instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 12:18:53 +01:00
Michal
f4984e3962 fix(vyos): health-check the 10 gig primary so failover actually fires
The 10 gig line was primary by route distance alone, which only fails over
when bond0.53 loses carrier or its DHCP lease. An ISP that keeps the link up
while dropping traffic -- the common failure -- would black-hole everything,
because a DHCP-installed route has nothing to withdraw it.

`protocols failover` now owns the live default route and pings two targets
bound to the interface, so the backup can never be validated through the
primary's path. Rehearsed on the labsim router: failover and failback both
inside 5s with the router's own interface still UP.

The vif keeps default-route-distance rather than no-default-route, demoted
below Vodafone. vyos-failover resolves a dhcp-interface gateway by reading
new_routers out of /run/dhclient/dhclient_<if>.lease, and no-default-route
leaves that field EMPTY -- the daemon then finds no next hop and installs
nothing. Observed on vyos001: the default route fell through to Vodafone.
Preference is now failover's kernel route (distance 0) > pppoe (10) >
DHCP (210), so the demoted route can never re-create the black hole it
exists to avoid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 12:18:41 +01:00
Michal
7b5331ddcd fix(migration): the backup has no WAN by design; stop failing it for that
The cutover succeeded on vyos001 -- gateway live, bond0.53 holding
87.192.101.48 with the cloned MAC, kea serving, clients routing out through NAT.
vyos002 then ran the same script and was judged unhealthy, because the mandatory
checks are "default route exists / internet reachable / DNS resolves" and the
backup deliberately holds its WAN interfaces DOWN. Its config was correct; the
check did not apply to it. Confirmed by hand before the timer could revert a
good config.

This is the third instance of one mistake: asserting a condition that is not
true of the box being checked. First requiring every WAN when one suffices, now
requiring a WAN on the box that is configured not to have one.

A delta containing `interfaces ... disable` for the WAN now identifies the
backup, and the WAN-dependent checks are skipped with a note. kea and the DNS
forwarder remain mandatory on both -- those are what the backup must actually
be able to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 01:48:48 +01:00
Michal
ce6911c196 fix(migration): require a working WAN, not every WAN
This reverted a cutover that had actually succeeded.

The evidence, from the revert tearing it down:

  dhclient: DHCPRELEASE of 87.192.101.48 on bond0.53 to 185.232.119.244
  vtysh:    "no ip route 0.0.0.0/0 87.192.96.1 bond0.53 tag 210 1"
  netlinkd: RTM_NEWLINK -> bond0.53, mac=f0:9f:c2:12:9b:4f

bond0.53 came up with the cloned MAC and was handed 87.192.101.48 -- the exact
public address the USG holds -- with a default route via the real ISP gateway.
kea was serving live LAN clients at the same moment (10.0.0.12, 10.0.0.13,
192.168.8.28). The gateway was working.

The only failure was pppoe0: ppp@pppoe0.service exited 5/NOTINSTALLED. That is
the Vodafone FAILOVER line, and the health check listed "pppoe0 has an address"
as mandatory, so a working gateway was torn down because its backup WAN was
down. The check encoded "every WAN must work" when the requirement is "the box
must reach the internet".

Now: default route, reachability and DNS are mandatory; each WAN interface is
reported individually but fatal on neither. A failover line being down is worth
seeing, not worth reverting for.

This also incidentally settles the last genuine unknown in the migration, which
could not be tested any other way: the ISP does hand the same lease to the
cloned MAC. That was the one thing I had said was unknowable until the USG let
go of it.

Note the earlier polling fix (54b21fa) addressed a real weakness but not this
failure -- no amount of waiting would have satisfied a check that required a
line which was never going to come up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 01:37:14 +01:00
Michal
54b21fa9ff fix(migration): poll for WAN health instead of sampling once at 25s
A real cutover attempt reported failure and reverted a configuration that may
well have been fine. The health check waited a fixed 25 seconds and then judged:

  [switch] committed. Waiting 25s for PPPoE and services to settle...
  [switch]   FAIL  pppoe0 has an address

25s is far too short for a WAN. PPPoE alone is PADI/PADO/PADR/PADS followed by
LCP, authentication and IPCP -- routinely 15-30s on its own. Both lines had also
just been released by the USG seconds earlier, and ISPs commonly hold the
previous session and MAC binding for minutes before leasing to the "same" CPE
again, which is exactly what a cloned MAC looks like from their side. The one
thing the design could not tolerate was being impatient, and it was.

Now polls every 15s up to HEALTH_BUDGET (default 180s), reporting progress, and
stops early the moment everything is healthy. The budget deliberately finishes
long before commit-confirm fires -- 180s against a 10 minute timer leaves 420s
of margin -- so the decision to confirm or revert stays ours rather than being
made by the timer.

Also recorded while chasing this: the earlier claim that VLANs 51/53 are not
trunked to the firewalls was WRONG, and the UniFi port settings disprove it --
those LAG ports are Native VLAN Management (1) with Tagged VLAN Management set
to Allow All. My evidence never supported the claim: a passive RX count cannot
distinguish an absent VLAN from a quiet one, because switches do not flood
unicast, and the active DHCP probe used a random MAC that an ISP binding to its
registered CPE would ignore regardless. Both observations fit a perfectly
healthy trunk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 01:10:50 +01:00
Michal
ff86a421f4 fix(labsim): console-apply must handle both VyOS prompts, not just $
Two failures from one strict expect, both hit while building the sim ISPs.

A run that dies mid-config leaves the console parked in configuration mode. The
next run then waits for the operational `$ ` prompt against a perfectly healthy
VM and hangs until timeout, with nothing in the output to say why -- the box was
sitting at `vyos@isp-dhcp#` the whole time. Login now accepts `# ` as well and
discards the stale candidate rather than committing something nobody has seen.

The same mistake at the exit step: insisting on `$ ` after `save` hung, AND left
the console in config mode, which is what created the first failure for the
following run. Now accepts either prompt.

Known-bad, not fixed: the tool reports "committed and saved" when the set
commands have not applied. Verified against the clone -- prompt showed the
host-name change had landed while `grep -c dhcp-server` returned 0. The failure
detection only inspects c.before for a few strings and evidently misses the real
failure mode, so success is being reported without evidence. That needs fixing
before this tool is trusted for anything; it is currently only safe to use with
an independent check afterwards, which is how the gap was found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 01:00:19 +01:00
Michal
ee070371a8 feat(labsim): add WAN transport VLANs so the sim can host fake ISPs
A cutover attempt failed on the WAN and nothing had tested it. The reason the
sim could not have caught it: labsim modelled every LAN VLAN faithfully and
omitted the WAN entirely -- vlans.conf had 1, 2, 3, 9, 10 and 200, never 51 or
53. Worse, the switch script's WAN health checks are conditional on the delta
configuring PPPoE, so in the sim they printed "this delta configures no WAN --
skipping all WAN health checks" and passed. The sim proved the delta commits; it
never proved the WAN works, and could not have.

Adds VLANs 51 (Vodafone/PPPoE) and 53 (10gig/DHCP) to the fabric so a fake ISP
can live on each and those checks actually execute. VyOS has service
pppoe-server (accel-ppp) natively -- authentication local-users, client-ip-pool,
gateway-address -- so a VyOS VM can play the concentrator, and dhcp-server can
play the other ISP.

vlans.conf gains host_octet 0, meaning "no host leg". A host address on a WAN
transport VLAN would misrepresent the segment: the point is that VyOS reaches an
ISP, not the host.

Also fixes a real gap in ovs_bond_router: it returned early when the bond
already existed, so adding a VLAN to vlans.conf never reached an existing bond.
Re-runs now reconcile the trunk and say so. That gap is the same SHAPE as the
production failure -- interface present, VLAN missing from the trunk, frames
silently dropped -- which is precisely the class of bug the sim needs to be able
to reproduce rather than embody.

Both bonds updated: [2,3,9,10,200] -> [2,3,9,10,51,53,200].

Note on the production diagnosis, which is NOT settled: a passive RX test showed
zero frames on 51/53 at the firewall, and an active DHCP DISCOVER (verified to
have transmitted, tx +2) drew no reply. That is consistent with the VLANs not
being trunked, but equally with the ISP only answering its registered CPE MAC --
which is exactly why the delta clones f0:9f:c2:12:9b:4f, and why it cannot be
settled from production while the USG holds that MAC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 00:44:11 +01:00
Michal
41b5448f56 feat(pulumi-vyos): prototype VyOS subtrees as Pulumi resources with commit-confirm
Goal: change VyOS and Kubernetes in one codebase and one plan -- so a BGP change
touches both sides in a single `pulumi preview`.

First, the worry about per-command pushes turned out to be unfounded for the
community providers. Read foltik/vyos and its client library: a
`vyos_config_block_tree` flattens the whole subtree into a single payload array
and sends ONE POST to /configure, so one resource is one commit. Good.

What they do not do is send `confirm_time`. Their payload is only
op/path/value, so every change is an unprotected commit -- on a router you reach
through the router, that is the difference between a mistake and an outage. The
VyOS API itself supports commit-confirm; the providers simply do not use it.

So this is a ~180-line Pulumi dynamic provider that does. Verified end to end on
labsim: create and update each land in ~6s as one commit-confirmed transaction,
update reports [diff: ~commands], destroy removes the subtree, and an
unconfirmed commit was observed reverting the router on its own.

Three API details found the hard way, all now encoded and commented:

  - confirm_time is ONLY read when the body parses as ConfigureListModel, i.e.
    {"commands": [...], "confirm_time": N}. A bare array is accepted and
    committed with NO timer armed, and the response looks like success. This
    silently discards the entire safety net, so the resource now checks the
    response actually says "commit-confirm" and refuses to proceed otherwise.
  - There is no /confirm endpoint; confirm is an op on /configure.
  - Confirm requires a `path` field even though it ignores it -- the Union
    resolves to ConfigureModel, which mandates path. Without it: "missing 'path'
    field", and the timer keeps running.

Apply is `delete <path>` followed by the sets, in one request, so the result is
the declared state rather than a merge -- otherwise `pulumi up` accumulates
instead of converging.

Known gaps, in the README rather than hidden: no read/refresh so out-of-band
drift is not detected, and the API runs with a self-signed certificate and
verification disabled. Both need addressing before production. The cutover
itself should still use vyos-unifi-switch, which the API cannot replace.

Sim left as found: test resource destroyed, dns forwarding restored to 15 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 01:09:05 +01:00
Michal
63061e6e7e feat(migration): peer link cabled and verified; conntrack-sync enabled in deltas
eth3 <-> eth3 direct cable is in. Both ends negotiated 2500Mb full duplex --
these are 2.5 GbE ports, not the 1G I had assumed.

Carrier alone proves nothing, so the link was tested end to end with temporary
kernel-level addresses (never committed to VyOS config, removed afterwards):
3/3 packets, 0% loss, 0.371ms average. A cable can show carrier and still not
pass traffic; now it is known to.

Deltas regenerated with --conntrack-link and installed on both boxes:

  vyos001  nat=21 fw=58 conntrack=9  eth3=10.255.255.1/30  disable=0
  vyos002  nat=21 fw=58 conntrack=9  eth3=10.255.255.2/30  disable=2

Identical apart from the peer /30, the VRRP/DHCP-HA roles, and the two disable
lines holding vyos002's WAN down. Both still report mode=unifi and nothing about
their behaviour has changed -- eth3 carries no address in the running config,
and conntrack-sync appears only in the delta, which is applied at cutover.

Not verified: multicast on the peer link. `ping -I eth3 224.0.0.1` drew no
responders, but that is the all-hosts group which VyOS need not answer, so it
proves nothing either way. conntrack-sync's own multicast (225.0.0.50) was
proven working in labsim over bond0.10, and this is a point-to-point link, so
the risk is low -- but it is untested on this specific cable and worth watching
at cutover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:37:16 +01:00
Michal
ccdd1e7e49 fix(migration): both boxes carry WAN and NAT; the backup just holds it down
"No NAT? How are we supposed to get internet?" -- a fair question that exposed a
worse design than I had admitted. Internet did work, but only via vyos001: NAT
and the entire WAN were gated behind --with-wan, so vyos002 would have held the
LAN VIPs and routed between VLANs with no path to the outside at all. Failover
would have preserved addressing and lost the internet.

The fix rests on a checked fact rather than an assumption: VyOS WARNS but still
commits when a NAT rule names an interface that does not exist
("Interface bond0.53 for source NAT rule 900 does not exist!"). Verified on a
real VyOS before relying on it.

So both boxes now get the identical WAN, NAT, port-forward and firewall config,
and the backup's two WAN interfaces are simply set `disable`. The cloned WAN MAC
is therefore never live on two boxes at once, while everything needed to route
and masquerade is already in place. The two deltas are now byte-identical apart
from VRRP priority, own/peer addresses, DHCP HA role, the conntrack /30 -- and
the two disable lines.

Taking over the internet path becomes deleting two lines rather than
reconstructing NAT under pressure:

    delete interfaces bonding bond0 vif 53 disable
    delete interfaces pppoe pppoe0 disable

Both boxes now: 21 NAT rules, 58 firewall rules, full PPPoE. Backup delta
validated against a real VyOS config with the disable lines present -- commits
clean. Runbook updated with the takeover procedure and the warning that it must
only be done when vyos001 is genuinely down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:30:23 +01:00
Michal
64e748ea94 test(labsim): conntrack-sync verified, and it exposed a delta defect
conntrack-sync proven working on the sim pair -- bidirectional replication with
zero errors:

  MASTER  internal 34  external(from peer) 52   62 pkts sent / 109 recv  0 err
  BACKUP  internal 76  external(from peer) 36  142 pkts sent /  73 recv  0 err

Getting there required learning something that changes the production config:
**VyOS only engages conntrack when a firewall or NAT is configured.** With
neither present, both routers reported zero conntrack entries and conntrack-sync
had nothing to replicate. Adding a single state-matching forward rule turned
tracking on and replication began immediately.

That is a defect in the delta, not just a test artifact. NAT and the firewall
were both gated behind --with-wan, so the BACKUP would have had neither -- it
would not have tracked connections at all, and replicated entries are useless to
a box whose conntrack is not engaged. Exactly the failure that only shows up
during a failover, when it is too late to notice.

Fixed: a stateful forward rule (accept established/related, default-action
accept) is now emitted on BOTH boxes, outside the WAN gate. Only NAT and the
WAN-scoped rules remain master-only. Verified: vyos002 now carries stateful
tracking and conntrack-sync but zero NAT lines. Master delta re-validated
against a real VyOS config -- no errors.

Also incidentally confirmed no-preempt: router1 rebooted and came back as
BACKUP rather than seizing the VIP, which is the opposite of what the production
pair did this afternoon (still on default preempt until cutover).

Two traps recorded while doing this:

  - The detached `setsid nohup` config-apply pattern can strand a VyOS config
    session. An orphaned session (dirs under /opt/vyatta/config/tmp/, PID long
    dead) blocked every subsequent `set` on that box with a bare "Set failed",
    and the dirs are overlay mounts so they cannot simply be deleted. Rebooting
    cleared it. This pattern is used to survive losing SSH mid-change, so it is
    worth knowing it has a failure mode of its own.
  - Only VLAN 10 passes traffic between the two sim routers; every other VLAN
    fails ARP despite identical vlan_mode/tag/trunks on both OVS bonds and
    distinct MACs. VRRP forms on all six groups regardless. The sync link had to
    be bond0.10 as a result. OVS-specific, absent in production, but it means
    the sim proves mechanism rather than topology.

Production deltas regenerated with --conntrack-link: eth3 at 10.255.255.1/30 and
.2/30 awaiting the cable, which is not yet plugged (carrier=0 on both).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:17:21 +01:00
Michal
bb654d83f8 test(labsim): second VyOS router proves DHCP active-passive HA
Answers the question a single router could not, and that would otherwise only
have been discovered at cutover: with kea high-availability active-passive, does
exactly ONE box answer a DHCP request?

Yes. Probing sim VLAN 10 with broadcast-dhcp-discover returns offers from a
single distinct Server Identifier -- 172.31.10.252, the primary. The secondary
runs kea but stays silent. Without this the delta would have put 6 subnets and
84 static-mappings on both boxes with nothing to arbitrate them, and two kea
instances would have raced on every broadcast domain.

Worth noting the raw response count is misleading: nmap reports "Response 1 of
2" because it sends several discovers, and both replies carry the same server
identifier. Counting responses says "2 servers"; counting distinct server
identifiers says "1". The second number is the true one.

labsim now runs a real pair, mirroring production:

    router1  172.31.<v>.252  priority 200  DHCP HA primary
    router2  172.31.<v>.253  priority 100  DHCP HA secondary
    VIP      172.31.<v>.1    floating, held by the master

That required converting router1, which held .1 directly, to .252 plus a
floating VIP -- otherwise it is two routers, not a pair. All six VRRP groups
show MASTER on router1 and BACKUP on router2.

New tooling:

  - sim-ha-config.py generates each role's config, reusing unifi-to-vyos.py
    --mode sim for the DHCP half so what is proven here and what production
    gets share a code path. VLAN 10 correctly carries /23.
  - console-apply.py applies config over the serial console, which is necessary
    because a freshly installed VyOS holds the same addresses as its peer and
    cannot safely be reached over the network at all until reconfigured.

Known sim-only quirk, deliberately not chased: router1 cannot ARP router2 on
the untagged VLAN 1 while every tagged VLAN works, and VRRP forms correctly on
all six groups regardless. Both OVS bonds carry identical vlan_mode/tag/trunks
and the bond MACs differ, so this is OVS bond behaviour on the native VLAN with
two bonds on one bridge -- not a VyOS config problem, and not present in
production, which uses a real switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 23:29:12 +01:00
Michal
952f5c66e3 feat(migration): complete the VyOS HA stack per the official docs
Prompted by "I thought we tested HA on libvirt" -- checking rather than
recalling showed the sim has ONE VyOS router with zero high-availability
config. VRRP was configured and running on the real pair, but it is only one of
four parts of what VyOS considers an HA pair.

Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA
walkthrough), three gaps are now closed in the delta:

  - VRRP was multicast-only with default preemption. Added unicast
    hello-source-address/peer-address per group, as the walkthrough does, plus
    no-preempt. Without no-preempt a recovered box reclaims the VIP before
    conntrack state has synced and drops every established connection; the docs
    are explicit that preempt-delay must otherwise be >= purge-timeout.
    The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while
    every other VLAN is .252/.253.
  - DHCP high-availability, which fixes a real defect rather than adding a
    feature. Both boxes carried the full 6 subnets and 84 static-mappings, so
    after cutover two kea instances would have raced on the same broadcast
    domains. Now active-passive with primary/secondary and swapped
    source/remote, syncing over TCP 647 on the LoT addresses. Each subnet
    already carries the unique subnet-id kea HA requires, and the peer name
    deliberately differs from both host-names.
  - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind
    --conntrack-link because it needs a cable that is not plugged in yet. This
    is what the peer cable is actually for -- VRRP does not want one, since its
    hellos must travel on the segment they protect.

VRRP failover exercised on the production pair, which is free to break today
because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs
moved to vyos002 within 12s, and returned on restart (preemption still default
on the live boxes). Both boxes clean afterwards, no config drift.

Master delta validated against vyos001's real running config on the sim router
before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA
primary/secondary respectively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
Michal
f81c94af43 feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN
Two corrections from reading the live USG instead of trusting UniFi's fields,
which report wan_type=dhcp for both WANs and are simply wrong:

  - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP
    with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 --
    and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51,
    the failover. The delta had PPPoE as the only WAN, which would have left the
    primary line unconfigured.
  - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC
    (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather
    than negotiating a new one -- or getting none, if the ISP allows one per
    line. Distances: 10 gig at 1, Vodafone at 10.

Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT
and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234,
zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it
would have meant a duplicate MAC on VLAN 53 and a flapping switch table.

Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was
deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP
10.8.0.254 on both boxes, and the delta now targets 10.8.0.1.

Creating that network first required breaking a deadlock in UniFi: every LAN
write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was
set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and
the validator treated that as a subnet overlapping everything. Verified
server-side, not a UI bug -- the API rejected it identically. Setting
wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom
overlap and incidentally PROVED the Vodafone credentials and line work, which
had been listed as untestable before cutover.

dhcp-options no-default-route-dns does not exist; the valid set is client-id,
default-route-distance, host-name, mtu, no-default-route, reject, user-class,
vendor-class-id. Caught by validating the delta against vyos001's real config on
the labsim router before installing.

After adding the network, the gateway's dhcpd.conf was checked with
`dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a
force-provision -- controller state is not device state.

Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines,
carrying the new VLAN 9), no config drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
Michal
febe4b72bc chore(migration): all DNS through VyOS to Google, NAS out of the path
The NAS is legacy for ad.itaz.eu and those records now live in Cloudflare, so
the zone resolves publicly -- verified: nas001.ad.itaz.eu and
kvm-macstudio1.ad.itaz.eu both answer from 8.8.8.8. That removes the reason for
a conditional forward and lets the NAS leave the DNS path entirely.

Two changes:

  - `service dns forwarding name-server` is now 8.8.8.8 and 8.8.4.4, the same
    pair the USG used on its WAN, instead of 10.0.0.194.
  - Every VLAN is handed the gateway as its resolver. UniFi set an explicit
    resolver on LoT only (the NAS); carrying that over would have kept the NAS
    in the path for one VLAN and not the other five, which is the sort of
    asymmetry nobody remembers a year later.

The NAS is still referenced 9 times, all legitimate and checked: 4 NAT
destination rules, the 4 matching firewall accepts for those port forwards, and
its own DHCP reservation. No DNS references remain.

Validated by loading vyos001's real running config on the labsim router and
applying the full delta -- all 318 commands accepted, no errors. Installed on
both boxes and verified in place: priority 200/100, upstream 8.8.8.8 + 8.8.4.4,
six client resolvers, 377 lines each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 17:16:17 +01:00
Michal
3768657b91 chore(migration): firewalls resolve via 8.8.8.8/8.8.4.4
Matches the DNS the USG used on its WAN (wan_dns1/wan_dns2), replacing the
10.0.0.194 I had set earlier. Applied to both boxes and saved; VRRP unchanged
(MASTER/BACKUP), NTP still synced, no config drift.

/config/modes/unifi.boot was RE-CAPTURED on both afterwards. It had been taken
before this change, so the escape hatch would have quietly reverted the
resolver on any rollback -- a snapshot is only an escape hatch for the state it
was taken from.

Two things recorded in the runbook:

  - The boxes' name resolution now depends on the internet, so between
    unplugging the USG and PPPoE establishing they have no DNS. Harmless:
    nothing in the switch resolves a name, and the health checks use DNS
    precisely to prove the WAN came up.
  - Internal ad.itaz.eu names still resolve via Google, because that zone is
    published publicly with private addresses in it (nas001 -> 10.0.0.194,
    kvm-macstudio1 -> 192.168.3.8). So no conditional forward was needed --
    though it is worth knowing the internal topology is public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 16:49:27 +01:00
Michal
2a8fcb3bd3 fix(migration): the runbook pointed at addresses that die with the USG
The access table led with 192.168.8.143/.144 and offered the LoT addresses as a
fallback ("if unreachable, try"). That is backwards and would have stranded the
operator at the worst moment: the workstation sits on LoT, and reaching
192.168.8.x routes *through the USG*, so those addresses are guaranteed dead the
instant it is unplugged. Measured:

  ip route get 192.168.8.143  ->  via 10.0.0.1   (the USG)
  ip route get 10.0.1.252     ->  dev lanbr0     (same L2, no gateway)

10.0.1.252 and .253 are on the LoT VLAN, same broadcast domain as the
workstation, and both answer SSH. They are now the only addresses the runbook
gives, with the k8s ones struck through.

Also recorded: the switch cannot be run before unplugging the USG (two devices
on every gateway address; the guard refuses), so the order is forced. And
during the gap between unplugging and completing the switch there is no
inter-VLAN routing at all -- which means the JetKVMs (Management and kvm) and
Tailscale are NOT fallbacks in that window. LoT SSH is the only remote path;
below it is physical console. Added a step 0: open both SSH sessions and leave
them open before touching anything.

Both boxes are now installed and pass the pre-flight gate: mode unifi,
unifi.boot 231 lines including the reload action, delta at the right priority
(200/100), wan-secrets 0600, script executable, no config drift, VRRP still
MASTER/BACKUP. `vyos-unifi-switch vyos` refuses on both -- all six gateway
addresses detected answering ARP -- and neither box has gained dhcp-server, dns
or nat, so nothing about their behaviour has changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 16:25:04 +01:00
Michal
fc31013ceb fix(migration): apply the reviewed reservation plan, not a recomputed one
This caused a real outage. unifi-reserve-all.py recomputed its plan at --apply
time by re-reading stat/sta, so a client that renewed between the dry run and
the apply was pinned to whatever transient address it happened to hold at that
instant. worker1-k8s0 was reviewed at 192.168.8.13 and written as
192.168.8.242. On its next reboot it could not get an address at all, taking a
k8s node down.

A plan that gets reviewed and a plan that gets applied must be the same object.
The dry run now WRITES the plan to a file and --apply READS it and applies
exactly that, reporting any client whose current address has since drifted
rather than silently preferring the new value.

1 of 51 diverged; the rest were verified against the reviewed list and were
correct. worker1 has been restored to .13 and confirmed: DHCPOFFER for its own
MAC returns 192.168.8.13, and the node is up with a full lease and working
internet.

The second half of the outage was drift between controller and device: the USG
was still running config from ~16h before these changes, so the controller
looked perfectly correct while the gateway handed out something else. Writing
the controller is only half the job, so the script now says so explicitly and
gives the force-provision and DHCP-probe commands to verify with. `nmap
--script broadcast-dhcp-discover --script-args broadcast-dhcp-discover.mac=...`
is the way to prove a specific reservation is live without disturbing the
client -- it elicits an OFFER without ever sending a REQUEST.

_unifi.py gains post() for device commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 14:31:30 +01:00
Michal
b37cd79432 fix(migration): refuse an unprobeable delta instead of warning past it
The ARP guard against two devices holding the same gateway address is the one
check that prevents this script's worst outcome. It reads the addresses to
probe out of the delta -- so a delta with no VIP lines made the guard inert,
and it previously warned and carried on. That was a testing convenience (the
lab delta has no VIPs) weakening a production safety check, which is backwards.

It now refuses by default. ALLOW_NO_VIP_DELTA=1 is the explicit lab override.

The guard's probing path had never actually executed before this: every sim
run took the no-VIPs branch. Verified against the live USG from vyos001:
arping is present on VyOS, the regex extracts all six gateway addresses from
the real delta (192.168.1.1, 192.168.8.1, 192.168.3.1, 10.0.9.0, 10.0.0.1,
192.168.2.1), and every one of them answers ARP right now -- so on the real
boxes, with the USG connected, the guard fires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 00:13:19 +01:00
Michal
7e464a2828 docs(migration): record what the rehearsal proved and what it could not
Ran vyos001's real running config plus the real production delta on the labsim
router -- same VyOS version, isolated OVS bridge with no physical NIC, so the
sim briefly holding vyos001's actual addresses could not reach the real LAN.

Result: all 317 commands accepted and the whole delta commits (COMMIT OK), the
revert is byte-exact, and auto-revert fires without rebooting (uptime and
boot-id unchanged across it).

Also written down are the two things this did NOT establish, because a runbook
that overstates its own coverage is worse than one that admits the gap: PPPoE
cannot be tried while the USG holds the single available session, and the
rehearsal ran with vyos001's eth2/eth3 stanzas stripped because the sim VM has
two NICs rather than four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 00:08:13 +01:00
Michal
01a923352f fix(migration): do not emit a NAT translation port for a port list
`set nat destination rule N translation port '16881,6881'` is rejected --
"16881,6881 is not a valid service name" -- because mapping a list of ports
onto a list is ambiguous. `destination port` accepts the same list happily,
which is why this only shows up on the translation side.

All four UniFi port forwards map a port to itself, so translation port was
redundant anyway: omitting it makes VyOS preserve the original port, which is
exactly the intent. It is now emitted only when the forwarded port genuinely
differs, and generation fails loudly rather than producing a config that will
not commit if a differing port LIST ever appears.

Found by loading vyos001's real running config onto the labsim router and
applying the full delta to the candidate config without committing. Worth
noting the delta had already passed a read-through: this one only surfaced by
running it against a real VyOS of the same version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 00:02:21 +01:00
Michal
7f5d3517a3 fix(migration): create the WAN vif before PPPoE references it
`set interfaces pppoe pppoe0 source-interface bond0.51` refers to an interface
that must already exist, and neither firewall has vif 51 -- only 2, 3, 9, 10
and 200 are configured. The commit would have failed, and since the whole delta
commits as one unit, that failure would have taken the entire switch with it at
the worst possible moment.

No address on the vif: PPPoE rides the VLAN and needs no L3 of its own.

Found by checking the running config against the generated delta rather than by
running it. The prod delta has still never been applied to any VyOS, which is
the remaining gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 23:51:13 +01:00
Michal
d56bbf6db0 feat(migration): reversible USG->VyOS switch, proven on the sim
The cutover is a switch, not a migration: unplug the USG, run one command, and
if anything is wrong run the other one and plug it back in. The operator will
have no internet during this and therefore no assistant, so the machinery has
to live on the boxes and the failure paths have to be proven in advance.

vyos-mode-delta.py generates the delta that turns the passive pair into the
gateway. Only one artifact is authored: gateway mode is always derived from
`load unifi.boot` + delta, so there is no inverse to maintain and no drift
between two hand-kept configs. It reuses unifi-to-vyos.py rather than
duplicating it, so what labsim proved and what production gets are one code
path. The PPPoE password is never written into the delta -- it carries a
placeholder the switch substitutes at apply time from /config/wan-secrets --
and generation fails if the real password appears in the output.

Two things the delta covers that the plan had underweighted:

  - VyOS defaults to ACCEPT while the USG has an implicit WAN drop. Migrating
    the port forwards alone would have left the router's own services and the
    whole LAN reachable from the WAN. Added a stateful baseline scoped to the
    WAN interface rather than a global default-action drop, so a mistake there
    cannot lock anyone out over the LAN -- the only way back during a cutover.
  - The old VIPs are NOT at network+254 on the /23 networks; they are
    192.168.9.254, 10.0.9.254 and 10.0.1.254, in the upper half. A delete
    naming a computed address fails quietly and leaves the group holding two
    VIPs. The delta deletes the whole address node instead of guessing.

vyos-unifi-switch runs on the box from /config, which survives image upgrades,
so it works from a local terminal or the JetKVM with no workstation.

Proven on labsim, not assumed:

  - unifi mode restores the previous config BYTE-EXACT (138 lines, diff clean).
  - Auto-revert fires when the commit is not confirmed: 85 static-mappings ->
    0, kea stopped, hostname restored, and uptime plus boot-id UNCHANGED, so
    it reloaded rather than rebooted. That distinction is the whole reason
    `commit-confirm action reload` is a prerequisite.
  - Health-check failure triggers an immediate revert_soft rather than waiting
    out the timer.

Four bugs found while doing it, each of which produced a wrong answer rather
than an error:

  - commit-confirm is TWO steps. `config-mgmt commit_confirm` only arms the
    revert timer; a normal `commit` still has to follow. Arming alone committed
    nothing while reporting success.
  - `sudo sg vyattacfg "config-mgmt ..."` loses the config-session environment,
    so it reported "No configuration changes to commit" against a candidate
    that plainly had 446 added lines.
  - `... | grep -q` under `set -o pipefail` reports FAILURE on a match: grep
    exits early, the producer takes SIGPIPE. Whether it triggers depends on
    output size, so `status` misreported the mode intermittently.
  - `show configuration commands` quotes values, so a fixed-string match for
    `action reload` never matched `action 'reload'`.

CUTOVER.md is the printable runbook: both reachable addresses per box, the
escape hatch first, and the note that PPPoE is the one thing that could not be
tested beforehand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 23:30:28 +01:00
Michal
6c4318d3ae feat(migration): reserve every active client at its current address
kea does not inherit UniFi's lease database. At cutover it starts with an empty
view of who holds what, so it can hand an address that is currently in use to a
different device. Reservations are what carry "this device has this address"
across the switch, because they live in config rather than in lease state.

unifi-reserve-all.py creates one per active client, dry run by default. 51
written, 51/51 verified live by reading the records back; the controller now
holds 85 reservations and the generator emits all 85 with unique, valid
hostnames and no duplicate addresses. Active clients with no reservation went
from 48 to 4.

Three guards, each of which caught something real in the dry run:

  - VRRP virtual addresses are excluded. UniFi reports them as ordinary client
    addresses because the firewalls' bond MACs answer for them, and their
    apparent IP flips between the real interface address and the VIP. Without
    this, 192.168.1.254 -- the gateway VIP itself -- would have been given a
    DHCP reservation.
  - The firewalls' own interface MACs are excluded; those are statically
    configured routers, not DHCP clients.
  - Any address claimed by more than one MAC is dropped rather than guessed
    at. This is how the VIPs surfaced in the first place.

Also skipped: addresses already reserved to another MAC, network gateways, and
anything on a network that does not serve DHCP (which excludes the WAN transit
VLANs automatically).

labsim-dhcp-test.sh gained a lease-database flush, and it is not tidiness. Two
findings, both of which first appeared as a PASSING test:

  - Re-running against stale leases, kea gave dynamic addresses to three
    devices that have reservations. The reservations were present and correct
    in kea's own config throughout. Kea saw the reserved address as leased to
    "another client" -- same MAC, different client-id from the earlier boot --
    and allocated elsewhere. Cutover starts with an empty lease database so
    this is a testing artifact, but a reservation is evidently not
    unconditional once leases exist.
  - Removing only dhcp4-leases.csv does nothing: kea's memfile backend keeps
    lease-file-cleanup rotations (.csv.2) and restores from them on start.

The verdict logic no longer takes the first matching lease row. Doing so
reported an hours-old lease as the current answer and scored three failures as
passes, including one where the device had plainly been given a dynamic
address. A MAC with more than one lease is now an explicit failure rather than
a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 23:07:07 +01:00