diff --git a/docs/README.md b/docs/README.md
index 0c89b1b..28bb446 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -65,10 +65,10 @@ portcullis (bare-metal edge box for Nikhef — staged, not yet in service)
## Site: home
Redundant routers, VM host, storage, IoT containers and the workstation — see
-[`sites/home/README.md`](sites/home/README.md). The hand-configured switch fabric (jim/dave/brian)
-and the Digiweb WAN path are documented in [`sites/home/switches.md`](sites/home/switches.md); the
-5G modem being evaluated as a replacement for `stream`'s WAN is in
-[`sites/home/wwan.md`](sites/home/wwan.md).
+[`sites/home/README.md`](sites/home/README.md). The hand-configured switch fabric
+(`jim`/`dave`/`brian`/`shytzel`) and WAN paths are documented in
+[`sites/home/switches.md`](sites/home/switches.md); the 5G modem being evaluated as a replacement
+for `stream`'s WAN is in [`sites/home/wwan.md`](sites/home/wwan.md).
```
h.nul.ie
diff --git a/docs/networking.md b/docs/networking.md
index 8c6f182..ee83366 100644
--- a/docs/networking.md
+++ b/docs/networking.md
@@ -2,7 +2,7 @@
This page describes how addressing works across the boxes: the assignment mechanism, the
per-site domains and prefixes, the home router HA pair, and the overlays/tunnels that tie the
-sites together. Switch-level home topology (jim/dave/brian, the ONT path) lives in
+sites together. Switch-level home topology (`jim`/`dave`/`brian`/`shytzel`, the WAN paths) lives in
[sites/home/switches.md](sites/home/switches.md).
## Assignments
@@ -314,6 +314,29 @@ which the other sites use as their next-hop into the home prefixes.
### Router HA
+#### Switch attachments
+
+Both routers use `lan-dave` and `lan-jim` under an RSTP bridge named `lan`. The
+[failover validation](sites/home/router-failover.md) covers router, attachment and switch outages.
+[`routing-common`](../nixos/boxes/home/routing-common/default.nix) owns the bridge, `mstpd` setup and
+explicit port costs: `dave` is preferred at cost 10 and `jim` is redundant at cost 100. Native
+`core`, the client VLANs and `river`'s Digiweb VLANs sit above this bridge. The `jim` path can reach
+the ONT through the RSTP-protected connection between `jim` and `brian` without traversing `dave`.
+
+RSTP follows the fabric topology, including failures beyond a local cable that retains carrier;
+an active-backup bond using only MII monitoring would miss those failures. The guest bridge has an
+explicit jumbo MTU. Root priorities and port roles across the complete fabric must agree with the
+intended preference; local port costs alone do not establish a working redundant path.
+On networkd configuration events, the dispatcher inspects the current bridge ports and applies
+the costs through `mstpctl`, retrying until the daemon has registered each port. The daemon's
+configuration hook reapplies them on restart or reload. The shared DHCPv4 service
+waits for IPv4 readiness on the client interfaces and retries delayed startup without exhausting
+systemd's start limit.
+
+`river` keeps its NVMe/RDMA traffic on a separate VF, outside the bridge; see
+[river](sites/home/river.md#storage). The physical fabric is documented in
+[switches.md](sites/home/switches.md).
+
#### VRRP
[`routing-common/keepalived.nix`](../nixos/boxes/home/routing-common/keepalived.nix) defines separate
@@ -351,15 +374,20 @@ route on `wan`, while `river`'s `pppd` hooks start and stop it. Consumers such a
`wantedBy` + `partOf` + `after`, never `requires`/`wants`, so they cannot pull the target in early
and they reload on WAN flap.
+DDNS starts after `network.target` rather than this WAN gate and can fail before the WAN is
+ready. Its timer retries periodically; `doas systemctl start ddns-update.service` retries
+immediately once connectivity is available.
+
### WAN paths (summary)
#### `river`
The VM on `palace` runs Digiweb PPPoE directly on VLAN 10 (`vlans.pon-isp`), trunked untranslated
-through the switches. The carrying interface is named `wan-pon-isp`; VLAN ID 141 with that name is
-reserved for a future translation scheme. MTU 1508 preserves a 1500-byte PPP session, IPCP requests
-the static address, and the `pppd` hooks own `wan-online.target`. `wan-pon-ont` (VLAN 140) reaches
-the ONT management subnet at `192.168.100.0/24`, where `river` takes `.100`.
+over its RSTP `lan` bridge. `dave` is the normal carrying path; `jim` provides the redundant path.
+The VLAN interface is named `wan-pon-isp`; VLAN ID 141 with that name is reserved for a future
+translation scheme. MTU 1508 preserves a 1500-byte PPP session, IPCP
+requests the static address, and the `pppd` hooks own `wan-online.target`. `wan-pon-ont` (VLAN 140)
+reaches the ONT management subnet at `192.168.100.0/24`, where `river` takes `.100`.
#### `stream`
diff --git a/docs/sites/home/README.md b/docs/sites/home/README.md
index 8b14d69..f2a7478 100644
--- a/docs/sites/home/README.md
+++ b/docs/sites/home/README.md
@@ -33,10 +33,12 @@ clients and the two WAN paths. VLAN IDs, prefixes, MTUs and router addressing li
## Switch fabric
-The boxes hang off three hand-configured switches — `jim` and `dave` (MikroTik, RouterOS) and
-`brian` (Ubiquiti, UniFi) — which are **not** managed by this flake. The physical topology, VLAN
-map, the Digiweb WAN path (trunked VLAN 10 + PVID 140 at the ONT edge), and the multi-ONT plan are
-documented in [switches.md](switches.md).
+The boxes hang off four hand-configured switches: `jim` and `dave` (MikroTik, RouterOS), `brian`
+(Ubiquiti, UniFi), and the L2-managed Zyxel switch `shytzel`. They are **not** managed by this
+flake. The physical topology, VLAN map, the Digiweb WAN path (trunked VLAN 10 + PVID 140 at the ONT
+edge), and the multi-ONT plan are documented in [switches.md](switches.md). Router attachments
+are described in [Router HA](../../networking.md#router-ha); tested failure scenarios and checks
+for maintenance are documented in [router-failover.md](router-failover.md).
## Wireless APs
diff --git a/docs/sites/home/aps.md b/docs/sites/home/aps.md
index 5a97530..af573e3 100644
--- a/docs/sites/home/aps.md
+++ b/docs/sites/home/aps.md
@@ -13,7 +13,8 @@ Only the DNS records live in the flake
| | vibe | wave |
|---|---|---|
| Model | MikroTik **cAP ax** (`cAPGi-5HaxD2HaxD`) | Cudy **AX3000** (OpenWrt id `cudy,ap3000-v1`) |
-| OS | RouterOS 7.x | OpenWrt 25.12.x (MT7981B / Filogic 820) |
+| OS | RouterOS 7.25.1 | OpenWrt 25.12.x (MT7981B / Filogic 820) |
+| RouterBOOT | 7.25.1 | — |
| Radio | 2×2 both bands | 2×2 both bands (2 spatial streams) |
| Uplink | trunk (multi-port; `ether2` is a wired LAN port) | single **2.5 GbE** trunk |
| Management | core/hi/lo `.15` | core/lo `.14` (**not** `hi`; see below) |
@@ -58,6 +59,10 @@ with `ssh admin@vibe '/interface wifi export show-sensitive'` (`.passphrase=` pr
RouterOS, one hardware-offloaded bridge `main` with `vlan-filtering=yes`. Access: `ssh admin@vibe`
— key auth for `admin` is installed (`~/.ssh/id_rsa`), with `admin`/`admin` as a fallback.
+`wifi-qcom` matches the RouterOS version. Follow the shared
+[upgrade procedure](switches.md#upgrades) for backups and version checks. A DFS channel
+availability check can delay the 5 GHz radio after reboot while 2.4 GHz is already running.
+
- **Uplink `ether1`** — trunk, tagged VLANs 100/110/120; native/untagged is the default VLAN 1
(PVID, no IP). `ether2` is a wired **access port** on VLAN 110 (`lo`). `l2mtu 9214`.
- **Radios** — `wifi1` (5 GHz, 20/40/80) + `wifi2` (2.4 GHz, 20/40) both broadcast `wlan0`
diff --git a/docs/sites/home/diagrams/README.md b/docs/sites/home/diagrams/README.md
new file mode 100644
index 0000000..a387925
--- /dev/null
+++ b/docs/sites/home/diagrams/README.md
@@ -0,0 +1,21 @@
+# Home network diagrams
+
+`switch-ports.json` holds the socket inventory, NIC capacities and cable endpoints. Regenerate
+`switch-panels.svg` and the cable table in `../switches.md` from the repository root:
+
+```sh
+python3 docs/sites/home/diagrams/render-switch-panels.py
+```
+
+The renderer uses only the Python standard library. Socket geometry and schematic cable routing
+live in the renderer. Keep the inventory and routing together when adding a connection.
+
+`home-fabric.mmd` is the Mermaid source for the logical fabric diagram. Render it with Mermaid
+CLI (the checked-in SVG was rendered with Mermaid 11.12.0):
+
+```sh
+mmdc -i docs/sites/home/diagrams/home-fabric.mmd -o docs/sites/home/diagrams/home-fabric.svg
+```
+
+Check rendered SVGs visually after editing. Both diagrams are checked in so Markdown viewers
+can show them without Mermaid support.
diff --git a/docs/sites/home/diagrams/home-fabric.mmd b/docs/sites/home/diagrams/home-fabric.mmd
new file mode 100644
index 0000000..756f6e9
--- /dev/null
+++ b/docs/sites/home/diagrams/home-fabric.mmd
@@ -0,0 +1,14 @@
+flowchart LR
+ virgin["Virgin Media modem"] --- shytzel["shytzel"]
+ ont["Digiweb ONT"] --- brian["brian"]
+ shytzel ---|"VLAN 130 trunk"| jim["jim"]
+ brian ---|"2 × 10G LAG"| dave["dave · RSTP root"]
+ brian -. "1G backup" .- jim
+ jim ---|"10G fabric trunk"| dave
+ dave ---|"100G"| palace["palace · river / cellar / sfh"]
+ jim ---|"1G host + separate 1G river trunk"| palace
+ dave ---|"100G"| castle["castle · NVMe-oF root"]
+ jim -. "1G · normally down" .- castle
+ dave ---|"10G LAN"| stream["stream"]
+ jim -. "1G LAN backup" .- stream
+ jim ---|"Separate VLAN 130 WAN handoff"| stream
diff --git a/docs/sites/home/diagrams/home-fabric.svg b/docs/sites/home/diagrams/home-fabric.svg
new file mode 100644
index 0000000..60a32ee
--- /dev/null
+++ b/docs/sites/home/diagrams/home-fabric.svg
@@ -0,0 +1 @@
+
diff --git a/docs/sites/home/diagrams/render-switch-panels.py b/docs/sites/home/diagrams/render-switch-panels.py
new file mode 100644
index 0000000..3ac76ad
--- /dev/null
+++ b/docs/sites/home/diagrams/render-switch-panels.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+"""Render the rack panel map and cable table from switch-ports.json."""
+import html
+import json
+from pathlib import Path
+import re
+import textwrap
+
+ROOT = Path(__file__).resolve().parent
+DATA = json.loads((ROOT / "switch-ports.json").read_text())
+COLORS = ["#2563eb", "#7c3aed", "#d97706", "#0891b2", "#64748b", "#16a34a", "#db2777", "#dc2626", "#92400e", "#0f766e", "#4f46e5"]
+TAGS = {}
+POSITIONS = {}
+foreground = []
+for link, color in zip(DATA["links"], COLORS):
+ for end in ("from", "to"):
+ TAGS[tuple(link[end])] = (link["id"], color)
+
+svg = ['')
+(ROOT / "switch-panels.svg").write_text('\n'.join(svg) + '\n')
+
+rows = ["| Cable | Endpoint | Endpoint | Connection |", "|---|---|---|---|"]
+for link in DATA["links"]:
+ ends = [f'`{link[end][0]}` `{link[end][1]}`' for end in ("from", "to")]
+ rows.append(f'| **{link["id"]}** | {ends[0]} | {ends[1]} | {link["description"]} |')
+page = ROOT.parent / "switches.md"
+content = page.read_text()
+start, end = "", ""
+if start in content:
+ content = re.sub(re.escape(start) + r'.*?' + re.escape(end),
+ start + '\n' + '\n'.join(rows) + '\n' + end, content, flags=re.S)
+ page.write_text(content)
diff --git a/docs/sites/home/diagrams/switch-panels.svg b/docs/sites/home/diagrams/switch-panels.svg
new file mode 100644
index 0000000..1e6296d
--- /dev/null
+++ b/docs/sites/home/diagrams/switch-panels.svg
@@ -0,0 +1,517 @@
+
diff --git a/docs/sites/home/diagrams/switch-ports.json b/docs/sites/home/diagrams/switch-ports.json
new file mode 100644
index 0000000..ce9e181
--- /dev/null
+++ b/docs/sites/home/diagrams/switch-ports.json
@@ -0,0 +1,608 @@
+{
+ "view": "Front view; rack order is brian, jim, dave, then palace several U below. Socket order is physical; spacing and chassis widths are schematic. Stream sits physically left of brian and is drawn above it.",
+ "sources": {
+ "brian": "https://dl.ui.com/qig/usw-pro-xg-8-poe/",
+ "jim": "https://mikrotik.com/product/CRS326-24G-2SplusRM",
+ "dave": "https://mikrotik.com/product/crs504_4xq_in",
+ "stream": "https://www.gowinfanless.com/news/blogs/the-r86s-revolution-low-power-2-5gbe-and-10gbe-networking"
+ },
+ "brian": {
+ "model": "USW-Pro-XG-8-PoE",
+ "ports": [
+ {
+ "socket": "1",
+ "interface": "Port 1",
+ "label": "fort",
+ "role": "Configured port label",
+ "peer": "fort",
+ "peer_port": null
+ },
+ {
+ "socket": "2",
+ "interface": "Port 2",
+ "label": "castle",
+ "role": "Configured port label",
+ "peer": "castle",
+ "peer_port": null
+ },
+ {
+ "socket": "3",
+ "interface": "Port 3",
+ "label": "portcullis test",
+ "role": "portcullis test connection",
+ "peer": "portcullis",
+ "peer_port": null
+ },
+ {
+ "socket": "4",
+ "interface": "Port 4",
+ "label": "portcullis KVM",
+ "role": "Second JetKVM",
+ "peer": "portcullis-kvm",
+ "peer_port": null
+ },
+ {
+ "socket": "5",
+ "interface": "Port 5",
+ "label": "jim P7",
+ "role": "Backup fabric trunk",
+ "peer": "jim",
+ "peer_port": "7"
+ },
+ {
+ "socket": "6",
+ "interface": "Port 6",
+ "label": "Digiweb ONT",
+ "role": "Native VLAN 140; tagged VLAN 10",
+ "peer": "Digiweb ONT",
+ "peer_port": null
+ },
+ {
+ "socket": "7",
+ "interface": "Port 7",
+ "label": "wave",
+ "role": "AP trunk",
+ "peer": "wave",
+ "peer_port": "eth0"
+ },
+ {
+ "socket": "8",
+ "interface": "Port 8",
+ "label": "vibe",
+ "role": "AP trunk",
+ "peer": "vibe",
+ "peer_port": "ether1"
+ },
+ {
+ "socket": "9",
+ "interface": "SFP+ 1",
+ "label": "dave Q3.2",
+ "role": "LAG member 1",
+ "peer": "dave",
+ "peer_port": "Q3.2"
+ },
+ {
+ "socket": "10",
+ "interface": "SFP+ 2",
+ "label": "dave Q3.4",
+ "role": "LAG member 2",
+ "peer": "dave",
+ "peer_port": "Q3.4"
+ }
+ ]
+ },
+ "jim": {
+ "model": "CRS326-24G-2S+RM",
+ "ports": [
+ {
+ "socket": "1",
+ "interface": "ether1",
+ "default_name": "ether1",
+ "label": "shytzel LAG",
+ "role": "Configured member of shytzel-downlink",
+ "peer": "shytzel",
+ "peer_port": null
+ },
+ {
+ "socket": "2",
+ "interface": "ether2",
+ "default_name": "ether2",
+ "label": "shytzel LAG",
+ "role": "Configured member of shytzel-downlink",
+ "peer": "shytzel",
+ "peer_port": null
+ },
+ {
+ "socket": "3",
+ "interface": "ether3",
+ "default_name": "ether3",
+ "label": "shytzel LAG",
+ "role": "Configured member of shytzel-downlink",
+ "peer": "shytzel",
+ "peer_port": null
+ },
+ {
+ "socket": "4",
+ "interface": "ether4",
+ "default_name": "ether4",
+ "label": "shytzel LAG",
+ "role": "Configured member of shytzel-downlink",
+ "peer": "shytzel",
+ "peer_port": null
+ },
+ {
+ "socket": "5",
+ "interface": "palace",
+ "default_name": "ether5",
+ "label": "palace host",
+ "role": "Core / netboot trunk",
+ "peer": "palace",
+ "peer_port": "host-jim"
+ },
+ {
+ "socket": "6",
+ "interface": "stream",
+ "default_name": "ether6",
+ "label": "stream LAN",
+ "role": "Backup RSTP trunk",
+ "peer": "stream",
+ "peer_port": "lan-jim"
+ },
+ {
+ "socket": "7",
+ "interface": "brian-downlink",
+ "default_name": "ether7",
+ "label": "brian P5",
+ "role": "Backup fabric trunk",
+ "peer": "brian",
+ "peer_port": "RJ45 5"
+ },
+ {
+ "socket": "8",
+ "interface": "palace-kvm",
+ "default_name": "ether8",
+ "label": "JetKVM",
+ "role": "lo access",
+ "peer": "palace-kvm",
+ "peer_port": null
+ },
+ {
+ "socket": "9",
+ "interface": "ether9",
+ "default_name": "ether9",
+ "label": "spare",
+ "role": "Unbridged spare",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "10",
+ "interface": "castle",
+ "default_name": "ether10",
+ "label": "castle",
+ "role": "Backup attachment, normally down",
+ "peer": "castle",
+ "peer_port": "et2.5g"
+ },
+ {
+ "socket": "11",
+ "interface": "fort",
+ "default_name": "ether11",
+ "label": "fort",
+ "role": "lo access (configured label)",
+ "peer": "fort",
+ "peer_port": null
+ },
+ {
+ "socket": "12",
+ "interface": "laptop-dock",
+ "default_name": "ether12",
+ "label": "dock",
+ "role": "lo access (configured label)",
+ "peer": "laptop-dock",
+ "peer_port": null
+ },
+ {
+ "socket": "13",
+ "interface": "pronter",
+ "default_name": "ether13",
+ "label": "pronter",
+ "role": "lo access (configured label)",
+ "peer": "pronter",
+ "peer_port": null
+ },
+ {
+ "socket": "14",
+ "interface": "untrusted-sta1",
+ "default_name": "ether14",
+ "label": "guest",
+ "role": "Untrusted access",
+ "peer": "untrusted-sta1",
+ "peer_port": null
+ },
+ {
+ "socket": "15",
+ "interface": "ether15",
+ "default_name": "ether15",
+ "label": "unmapped",
+ "role": "lo access; peer unconfirmed",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "16",
+ "interface": "ether16",
+ "default_name": "ether16",
+ "label": "unmapped",
+ "role": "lo access; peer unconfirmed",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "17",
+ "interface": "ether17",
+ "default_name": "ether17",
+ "label": "unmapped",
+ "role": "lo access; peer unconfirmed",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "18",
+ "interface": "ether18",
+ "default_name": "ether18",
+ "label": "unmapped",
+ "role": "lo access; peer unconfirmed",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "19",
+ "interface": "ether19",
+ "default_name": "ether19",
+ "label": "unmapped",
+ "role": "lo access; peer unconfirmed",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "20",
+ "interface": "ether20",
+ "default_name": "ether20",
+ "label": "unmapped",
+ "role": "lo access; peer unconfirmed",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "21",
+ "interface": "stream-wan",
+ "default_name": "ether21",
+ "label": "stream WAN",
+ "role": "Untagged VLAN 130 handoff",
+ "peer": "stream",
+ "peer_port": "wan"
+ },
+ {
+ "socket": "22",
+ "interface": "river",
+ "default_name": "ether22",
+ "label": "river backup",
+ "role": "Dedicated router trunk",
+ "peer": "palace",
+ "peer_port": "river-jim-phy"
+ },
+ {
+ "socket": "23",
+ "interface": "ups",
+ "default_name": "ether23",
+ "label": "UPS",
+ "role": "lo access",
+ "peer": "ups",
+ "peer_port": null
+ },
+ {
+ "socket": "24",
+ "interface": "dave-management",
+ "default_name": "ether24",
+ "label": "dave mgmt",
+ "role": "Core management",
+ "peer": "dave",
+ "peer_port": "MGMT"
+ },
+ {
+ "socket": "S1",
+ "interface": "dave-uplink",
+ "default_name": "sfp-sfpplus1",
+ "label": "dave Q3.1",
+ "role": "10G fabric trunk",
+ "peer": "dave",
+ "peer_port": "Q3.1"
+ },
+ {
+ "socket": "S2",
+ "interface": "sfp-spare",
+ "default_name": "sfp-sfpplus2",
+ "label": "fergal",
+ "role": "10G trunk; colony staging",
+ "peer": "fergal",
+ "peer_port": "lan1"
+ }
+ ]
+ },
+ "dave": {
+ "model": "CRS504-4XQ-IN",
+ "ports": [
+ {
+ "socket": "Q1",
+ "interface": "palace",
+ "default_name": "qsfp28-1-1",
+ "label": "palace",
+ "role": "100G; whole cage",
+ "peer": "palace",
+ "peer_port": "uplink-dave"
+ },
+ {
+ "socket": "Q2",
+ "interface": "castle",
+ "default_name": "qsfp28-2-1",
+ "label": "castle",
+ "role": "100G; whole cage",
+ "peer": "castle",
+ "peer_port": "et100g"
+ },
+ {
+ "socket": "Q3.1",
+ "interface": "jim-downlink",
+ "default_name": "qsfp28-3-1",
+ "label": "jim SFP+1",
+ "role": "10G breakout lane 1",
+ "peer": "jim",
+ "peer_port": "S1"
+ },
+ {
+ "socket": "Q3.2",
+ "interface": "brian1",
+ "default_name": "qsfp28-3-2",
+ "label": "brian SFP+1",
+ "role": "10G breakout lane 2; LAG",
+ "peer": "brian",
+ "peer_port": "9"
+ },
+ {
+ "socket": "Q3.3",
+ "interface": "stream",
+ "default_name": "qsfp28-3-3",
+ "label": "stream LAN",
+ "role": "10G breakout lane 3",
+ "peer": "stream",
+ "peer_port": "lan-dave"
+ },
+ {
+ "socket": "Q3.4",
+ "interface": "brian2",
+ "default_name": "qsfp28-3-4",
+ "label": "brian SFP+2",
+ "role": "10G breakout lane 4; LAG",
+ "peer": "brian",
+ "peer_port": "10"
+ },
+ {
+ "socket": "Q4",
+ "interface": "qsfp28-4-1",
+ "default_name": "qsfp28-4-1",
+ "label": "unmapped",
+ "role": "No identified peer",
+ "peer": null,
+ "peer_port": null
+ },
+ {
+ "socket": "MGMT",
+ "interface": "management",
+ "default_name": "ether1",
+ "label": "jim P24",
+ "role": "100M management RJ45",
+ "peer": "jim",
+ "peer_port": "24"
+ }
+ ]
+ },
+ "links": [
+ {
+ "id": "A",
+ "from": [
+ "brian",
+ "9"
+ ],
+ "to": [
+ "dave",
+ "Q3.2"
+ ],
+ "description": "10G; first LAG member"
+ },
+ {
+ "id": "B",
+ "from": [
+ "brian",
+ "10"
+ ],
+ "to": [
+ "dave",
+ "Q3.4"
+ ],
+ "description": "10G; second LAG member"
+ },
+ {
+ "id": "C",
+ "from": [
+ "brian",
+ "5"
+ ],
+ "to": [
+ "jim",
+ "7"
+ ],
+ "description": "1G; redundant fabric trunk"
+ },
+ {
+ "id": "D",
+ "from": [
+ "jim",
+ "S1"
+ ],
+ "to": [
+ "dave",
+ "Q3.1"
+ ],
+ "description": "10G; direct fabric trunk"
+ },
+ {
+ "id": "E",
+ "from": [
+ "jim",
+ "24"
+ ],
+ "to": [
+ "dave",
+ "MGMT"
+ ],
+ "description": "100M; dave management"
+ },
+ {
+ "id": "F",
+ "from": [
+ "jim",
+ "5"
+ ],
+ "to": [
+ "palace",
+ "host-jim"
+ ],
+ "description": "1G; host core / netboot"
+ },
+ {
+ "id": "G",
+ "from": [
+ "jim",
+ "22"
+ ],
+ "to": [
+ "palace",
+ "river-jim-phy"
+ ],
+ "description": "1G; river backup trunk"
+ },
+ {
+ "id": "H",
+ "from": [
+ "dave",
+ "Q1"
+ ],
+ "to": [
+ "palace",
+ "uplink-dave"
+ ],
+ "description": "100G; host PF and guest VFs"
+ },
+ {
+ "id": "I",
+ "from": [
+ "stream",
+ "ETH0"
+ ],
+ "to": [
+ "jim",
+ "21"
+ ],
+ "description": "1G WAN; 2.5G socket on stream"
+ },
+ {
+ "id": "J",
+ "from": [
+ "stream",
+ "ETH1"
+ ],
+ "to": [
+ "jim",
+ "6"
+ ],
+ "description": "1G backup LAN; 2.5G socket on stream"
+ },
+ {
+ "id": "K",
+ "from": [
+ "stream",
+ "SFP+ R"
+ ],
+ "to": [
+ "dave",
+ "Q3.3"
+ ],
+ "description": "10G preferred LAN"
+ }
+ ],
+ "stream": {
+ "model": "BROUNION R86S",
+ "ports": [
+ {
+ "socket": "ETH0",
+ "interface": "wan",
+ "label": "wan",
+ "role": "1G connection to jim 21",
+ "nic": "Intel I225-V",
+ "capacity": "2.5G"
+ },
+ {
+ "socket": "ETH1",
+ "interface": "lan-jim",
+ "label": "lan-jim",
+ "role": "1G connection to jim 6",
+ "nic": "Intel I225-V",
+ "capacity": "2.5G"
+ },
+ {
+ "socket": "ETH2",
+ "interface": "et2",
+ "label": "et2",
+ "role": "Unused",
+ "nic": "Intel I225-V",
+ "capacity": "2.5G"
+ },
+ {
+ "socket": "SFP+ R",
+ "interface": "lan-dave",
+ "label": "lan-dave",
+ "role": "10G connection to dave Q3.3",
+ "nic": "Mellanox ConnectX-3",
+ "capacity": "10G"
+ },
+ {
+ "socket": "SFP+ L",
+ "interface": "et5",
+ "label": "et5",
+ "role": "Unused",
+ "nic": "Mellanox ConnectX-3",
+ "capacity": "10G"
+ }
+ ]
+ },
+ "palace": {
+ "ports": [
+ {
+ "socket": "uplink-dave",
+ "nic": "Mellanox ConnectX-4",
+ "capacity": "100G QSFP28"
+ },
+ {
+ "socket": "host-jim",
+ "nic": "Intel I211",
+ "capacity": "1G RJ45"
+ },
+ {
+ "socket": "river-jim-phy",
+ "nic": "Intel I211",
+ "capacity": "1G RJ45"
+ }
+ ]
+ }
+}
diff --git a/docs/sites/home/palace.md b/docs/sites/home/palace.md
index 27a671d..b81cda6 100644
--- a/docs/sites/home/palace.md
+++ b/docs/sites/home/palace.md
@@ -18,7 +18,7 @@ SR-IOV VFs, PCI NVMe drives and LVM disks.
| Host storage | 500 GB Samsung SSD 860 EVO containing the EFI partition and the `main` LVM thin pool |
| Bulk storage | Three 8 TB Seagate IronWolf disks in the `hdds` VG, providing the RAID-backed `hdd-storage` and `frigate` LVs |
| NVMe storage | Three 2 TB Samsung NVMe devices passed through to `cellar`; SPDK combines them as the `NVMeRaid` RAID 0 device |
-| Network / graphics | Mellanox ConnectX-4 100G adapter with four SR-IOV VFs, two Intel I211 Gigabit Ethernet controllers, and an AMD Radeon RX 550/560-family GPU |
+| Network / graphics | Mellanox ConnectX-4 100G adapter using SR-IOV, two Intel I211 Gigabit Ethernet controllers (the only BASE-T NICs), and an AMD Radeon RX 550/560-family GPU |
## Role
@@ -34,33 +34,116 @@ See the consolidated [network assignments](../../networking.md#box-assignments)
## Networking
-100G `et100g` (mlx5, MTU 9000) uplinks to the `dave` switch and carries `lan-hi` (VLAN 100, the
-`hi` assignment). A udev rule creates four SR-IOV VFs on the PF:
+100G `uplink-dave` (mlx5, MTU 9000) connects to the `dave` switch and carries `lan-hi` (VLAN 100,
+the `hi` assignment). A udev rule creates five SR-IOV VFs on the PF. Two physically separate Intel
+connections lead to `jim`: `host-jim` serves `palace` and `sfh`, while `river-jim-phy` is exported
+exclusively to `river`.
-| VF | Consumer | VLAN handling |
-|---|---|---|
-| 0 | `cellar` | `hi` |
-| 1 | `river` | untagged VF; `river` tags its LAN and WAN VLANs |
-| 2 | `sfh` | `hi` |
-| 3 | `sfh` container MACVLAN parent | `hi` |
+- `lan-core` is a bridge with the `core` assignment (no gateway); the 1G `host-jim` connection and
+ the `lan-lo-phy` VLAN ride on it. `lan-lo` is a second, L3-less bridge used for VM netboot and
+ `lo` clients.
+- The separate 1G `river-jim-phy` connection is exported exclusively to `river` as the
+ passthru-mode macvtap `river-jim-tap`; the guest names it `lan-jim`. Direct PCI passthrough is
+ blocked by its shared X399 IOMMU group; see [PCI isolation](#pci-isolation).
-- `lan-core` is a bridge with the `core` assignment (no gateway); the 1G
- `lan-core-phy` and the `lan-lo-phy` VLAN ride on it. `lan-lo` is a second, L3-less bridge used
- for VM netboot and `lo` clients.
-- The 1G `et1g0` (igb) is exported to `river` as a passthru-mode macvtap (`vm-et1g0`) — river sees
- it as `wan-old`.
+### PCI isolation
+
+The Intel I211 controllers at `04:00.0` (`river-jim-phy`) and `06:00.0` (`host-jim`) share IOMMU
+group 13 with chipset USB/SATA controllers, PCIe bridges and the wireless controller. Passing
+only the router NIC through VFIO would leave the group nonviable; detaching the whole group
+would remove essential host devices. The dedicated passthru-mode macvtap keeps the NIC host-owned.
+Direct passthrough would need a NIC or firmware layout that isolates the controller.
+
+Keep both Intel connections: `host-jim` provides independent host management and VM netboot,
+while `river-jim-phy` carries the router's redundant trunk. The host physical router interface and
+its macvtap both use `hiMTU`; the macvtap enables all-multicast reception so guest RSTP can receive
+BPDUs without a guest-to-host receive-filter synchronization service.
+
+### End-to-end interface topology
+
+```text
+dave
+└─ palace uplink-dave (ConnectX-4 PF)
+ ├─ VLAN 100 ───────────────────────────────> palace lan-hi
+ ├─ VF 0 cellar-hi ────────────────────────> cellar lan-hi
+ ├─ VF 1 river-rdma ───────────────────────> river rdma-hi ──> cellar NVMe/RDMA
+ ├─ VF 2 sfh-hi ───────────────────────────> sfh lan-hi ─────> cellar NVMe/RDMA
+ ├─ VF 3 sfh-ctrs ─────────────────────────> sfh ctrs-hi
+ │ ├─> hass host0
+ │ └─> unifi host0
+ └─ VF 4 river-dave ───────────────────────> river lan-dave ──┐
+ ├─> river lan (RSTP)
+jim │
+├─ palace river-jim-phy ── river-jim-tap ─────> river lan-jim ───┘
+└─ palace host-jim ──> palace lan-core
+ ├─ sfh-core-tap ───────────────────────────> sfh ctrs-core ──> unifi lan-core
+ └─ VLAN 110 lan-lo-phy ──> palace lan-lo
+ └─ sfh-lo-tap ──────────────────────────> sfh ctrs-lo
+ ├─> sfh PXE
+ └─> hass lan-lo
+```
+
+Inside `river`, `lan-dave` and `lan-jim` terminate on the shared `lan` RSTP bridge. `lan-core` is a
+MACVLAN above it; `lan-hi`, `lan-lo` and `lan-untrusted` are VLAN uppers. `river` also adds its
+Digiweb VLANs 10 and 140 to this bridge. `rdma-hi` is deliberately outside it.
+
+VF 4 explicitly enables trust and disables MAC spoof checking for bridge traffic, which can use
+MACs other than the VF's assigned address. Its automatic link state follows the physical port;
+the storage VFs retain forced link state so local VF-to-VF storage can continue without carrier.
+The firmware must expose enough VFs before the udev allocation can succeed.
+
+### Interface map
+
+The physical connections terminate on `palace`; PCI VFs and virtual interfaces hand traffic
+from the host to its guests. Interface names in the destination columns belong to that box.
+
+#### Physical connections
+
+| Switch port | Host interface | Host networks | Guest handoff |
+|---|---|---|---|
+| `dave` `palace` | `uplink-dave` | VLAN 100 → `lan-hi` | Five PCI VFs, listed below |
+| `jim` `palace` | `host-jim` | Native `core` → `lan-core`; VLAN 110 → `lan-lo-phy` → `lan-lo` | `sfh-core-tap` and `sfh-lo-tap` |
+| `jim` `river` | `river-jim-phy` | No host L3 configuration | `river-jim-tap` macvtap → `river` `lan-jim` |
+
+#### ConnectX-4 VFs
+
+All five VFs belong to `uplink-dave`. VLAN filtering and carrier policy are applied on the PF.
+
+| VF / PCI BDF | QEMU device | Guest | Guest interface | VLAN / carrier | Role |
+|---|---|---|---|---|---|
+| 0 / `44:00.1` | `cellar-hi` | [cellar](cellar.md) | `lan-hi` | 100 / forced up | Storage target and management |
+| 1 / `44:00.2` | `river-rdma` | [river](river.md#storage) | `rdma-hi` | 100 / forced up | Dedicated NVMe/RDMA root path |
+| 2 / `44:00.3` | `sfh-hi` | [sfh](sfh/README.md) | `lan-hi` | 100 / forced up | NVMe/RDMA root and management |
+| 3 / `44:00.4` | `sfh-ctrs` | [sfh](sfh/README.md#networking) | `ctrs-hi` | 100 / forced up | Container `hi` network |
+| 4 / `44:00.5` | `river-dave` | [river](river.md#virtual-machine-and-network-attachment) | `lan-dave` | Unfiltered trunk / automatic | Preferred RSTP attachment |
+
+#### Virtual Ethernet handoffs
+
+| Host interface | Kind / parent | Guest interface | Downstream use |
+|---|---|---|---|
+| `river-jim-tap` | Passthru macvtap on `river-jim-phy` | `river` `lan-jim` | Backup RSTP attachment |
+| `sfh-core-tap` | TAP on `lan-core` | `sfh` `ctrs-core` | `unifi` `lan-core` |
+| `sfh-lo-tap` | TAP on `lan-lo` | `sfh` `ctrs-lo` | PXE boot; `hass` `lan-lo` |
+
+`sfh` keeps no L3 configuration on the three `ctrs-*` parents. Its nspawn configuration creates the
+container-side names shown above; `host0` remains the default `hi` interface in both containers.
+See [the `sfh` page](sfh/README.md#networking) for its container-oriented view and
+[failover validation](router-failover.md) for tested failure scenarios and maintenance checks.
## VMs
+The RAID-backed `hdds` LVs need `dm-raid` loaded in the initrd: LVM's initial autoactivation can
+run before stage-two module loading. `sfh` requires the `frigate` LV before QEMU can start.
+
| VM | vCPUs | RAM | Passthrough | Notes |
|---|---|---|---|---|
-| `cellar` | 8c × 2t | 16 GiB | VF 0 (`44:00.1`); NVMe `41:00.0`–`43:00.0` | pinned to NUMA node 1; split IRQ chip + vIOMMU |
-| `river` | 3c × 2t | 4 GiB | VF 1 (`44:00.2`); macvtap `vm-et1g0` | only an ESP local disk (plus an installer ISO) — root is NVMe-oF from `cellar` |
-| `sfh` | 8c × 2t | 32 GiB | VF 2 (`44:00.3`), VF 3 (`44:00.4`); two USB host ports | no boot disk — netboots; gets the `hdds/frigate` LV |
+| `cellar` | 8c × 2t | 16 GiB | `cellar-hi` VF 0 (`44:00.1`); NVMe `41:00.0`–`43:00.0` | pinned to NUMA node 1; split IRQ chip + vIOMMU |
+| `river` | 3c × 2t | 4 GiB | `river-rdma` VF 1 (`44:00.2`), `river-dave` VF 4 (`44:00.5`), `river-jim-tap` | only an ESP local disk (plus an installer ISO) — root is NVMe-oF from `cellar` |
+| `sfh` | 8c × 2t | 32 GiB | `sfh-hi` VF 2 (`44:00.3`), `sfh-ctrs` VF 3 (`44:00.4`); two USB host ports | no boot disk — netboots; gets the `hdds/frigate` LV |
Boot ordering is enforced with systemd dependencies: `vm@river` waits for `cellar`'s SSH port (and
-for the `vm-et1g0` device), and `vm@sfh` waits for `river` — storage first, then the router, then
-everything that boots off both.
+for the `river-jim-tap` device), and `vm@sfh` waits for `river` — storage first, then the router,
+then everything that boots off both.
## Notable config files
diff --git a/docs/sites/home/river.md b/docs/sites/home/river.md
index 2c4b4ef..87fdc82 100644
--- a/docs/sites/home/river.md
+++ b/docs/sites/home/river.md
@@ -24,9 +24,10 @@ See the consolidated [network assignments](../../networking.md#box-assignments)
### Link and addressing
`services.pppd` peer `digiweb` attaches directly to `wan-pon-isp`, the raw ISP VLAN 10
-(`vlans.pon-isp`) trunked untranslated from the ONT through `brian` and `dave`; see
-[switches.md](switches.md). The netdev has no L3 configuration and uses `MTUBytes=1508`, leaving a
-clean `mtu`/`mru 1500` after PPPoE overhead.
+(`vlans.pon-isp`) trunked untranslated from the ONT. It normally reaches `river` through `brian`
+and `dave`; the configured RSTP path through `jim` is intended to provide redundancy after
+successful deployment and validation. See [switches.md](switches.md). The netdev has no L3
+configuration and uses `MTUBytes=1508`, leaving a clean `mtu`/`mru 1500` after PPPoE overhead.
The static `84.203.124.128` address is requested through IPCP. The provider-wide credentials are
deliberately not secret; the peer persists indefinitely with LCP echo monitoring and ignores
@@ -46,23 +47,45 @@ every WAN flap.
### Traffic shaping
-The shared `wan-ifb` ingress-shaping pieces are inert here. CAKE is specific to `stream`, and
-`networkd-dispatcher` is `mkForce false` pending scheduling tests.
+CAKE and the `wan-ifb` network configuration remain specific to `stream`. The shared IFB netdev
+exists here but has no matching `.network` file to trigger the ingress-mirroring rule. The source
+enables the shared `networkd-dispatcher` for RSTP setup.
## Platform
### Virtual machine and network attachment
-The 100G `lan` NIC is VF 1 of `palace`'s `et100g` (MTU 9000), with every router VLAN tagged on top
-as `55-lan`. A macvtap of `palace`'s 1G `et1g0` remains as the old `wan-old` path without L3
-configuration. Deployments use the `hi` assignment.
+The router's ordinary networks sit above the `lan` RSTP bridge. Its preferred port, `lan-dave`, is
+an unfiltered ConnectX-4 VF trunk; `lan-jim` is the 1G `river-jim-tap` macvtap exported by
+`palace`. Native `core`, the client VLANs and Digiweb VLANs 10/140 use `dave` normally and fail
+over to `jim`. The shared policy is documented in [Router HA](../../networking.md#router-ha);
+[failover validation](router-failover.md) covers attachment and switch outages.
+
+The bridge uses the trunk VF's assigned MAC so the ConnectX-4's local switching can deliver
+traffic from `palace` and the other VFs to the router. On `lan-hi` and `rdma-hi`, `arp_ignore=1`
+and `arp_announce=2` keep router and storage addresses on their respective interfaces; otherwise
+the storage VF can answer ARP for the router VIP and attract traffic to the wrong interface.
+
+The separate ConnectX-4 VF `rdma-hi` is not a bridge port. It has a PF-applied VLAN 100 and exists
+only for storage traffic.
+
+Ordinary Ethernet traverses the guest software bridge; this does not require mlx5
+switchdev or host representors. The bridge disables LRO on its ports; RDMA stays outside this
+path.
### Storage
The VM's local disk holds only an ESP; `/nix` and `/persist` are LVs on `cellar`'s
-`nqn.2016-06.io.spdk:river` namespace over RDMA. The initrd brings up `lan-hi`
-with `roceBootModules`, and `KeepConfiguration=static` prevents networkd from dropping the address
-during reconfiguration. An installer ISO remains attached.
+`nqn.2016-06.io.spdk:river` namespace over RDMA. The initrd brings up `rdma-hi` with the dedicated
+`rdma` assignment and a link-scoped host route to `cellar`, alongside `roceBootModules`.
+
+NixOS copies the main system's `.link` units into the systemd initrd, so the `rdma-hi` rename is
+shared, but its `.network` and `.netdev` units are separate. `river` therefore declares the storage
+assignment and route once under `boot.initrd.systemd.network` and again under the main
+`systemd.network`. The systemd initrd leaves its kernel network state in place during the stage-two
+handover; `KeepConfiguration=static` on the main `.network` prevents networkd from dropping and
+recreating the static address and route underneath the mounted NVMe-oF root. An installer ISO
+remains attached.
## Netboot
@@ -76,4 +99,4 @@ assignment, restricted to the `hi` and `lo` prefixes.
- [`nixos/boxes/home/routing-common/default.nix`](../../../nixos/boxes/home/routing-common/default.nix) —
shared router definition (assignments, firewall/NAT, `as211024`).
- [`nixos/boxes/home/palace/vms/default.nix`](../../../nixos/boxes/home/palace/vms/default.nix) —
- the VM definition on `palace` (VF 1, macvtap, ESP disk).
+ the VM definition on `palace` (dedicated RDMA VF, trunk VF, macvtap and ESP disk).
diff --git a/docs/sites/home/router-failover.md b/docs/sites/home/router-failover.md
new file mode 100644
index 0000000..306be33
--- /dev/null
+++ b/docs/sites/home/router-failover.md
@@ -0,0 +1,62 @@
+# Router failover validation
+
+Failure-scenario checks for the deployed home router pair. The design lives in
+[Router HA](../../networking.md#router-ha), the physical paths in [switches.md](switches.md),
+and the host/VF layout in [palace.md](palace.md) and [river.md](river.md).
+
+## Validated scenarios
+
+These checks were performed on 2026-11-15. WAN and ONT probes check Ethernet path availability;
+reads from `river`'s root LV and probes over `rdma-hi` check its separate storage path.
+
+| Scenario | Test | Validated behavior |
+|---|---|---|
+| Router startup | Boot both routers | Kea starts without retries; the configured bridge MAC, ARP policy and administrative RSTP costs are applied |
+| Primary router offline | Reboot its host | `stream` takes over both IPv4 and IPv6 VIPs, then returns to standby when `river` recovers |
+| Preferred guest port down | Disable `river`'s `lan-dave`, then restore it | `lan-jim` becomes root/forwarding; WAN, ONT and 9000-byte RDMA pings pass while direct root-LV reads continue for 20 seconds |
+| Backup guest port down | Disable `river`'s `lan-jim`, then restore it | `lan-dave` stays root/forwarding; WAN, ONT, jumbo RDMA and root-LV read checks pass |
+| Physical `palace` attachment lost | Disable `dave`'s `palace` port for 30 seconds with automatic restoration | `lan-dave` loses carrier and `lan-jim` becomes root/forwarding; forced-link storage VFs retain local RDMA and root-LV reads succeed |
+| Whole `dave` outage | Reboot the switch | `jim` becomes root and forwards on `brian-downlink`; Digiweb WAN and ONT access use `jim`–`brian` while local RDMA reads continue |
+| Whole `jim` outage | Reboot the switch | `river` continues using `dave`; WAN, ONT and root-storage read probes succeed |
+| Restoration | Restore each failed attachment or switch | The normal RSTP preferences return and `palace` is reachable on `hi` again |
+
+Brief WAN/ONT probe losses occur during the tested `dave` tree transitions; subsequent probes
+succeed on the backup tree and after restoration. The monitors recorded no storage-read
+failures or matched kernel NVMe errors. The whole-switch test demonstrates the backup WAN path
+without relying on the direct `jim`–`dave` trunk.
+
+## Test limits
+
+The storage checks exercise reads, not write integrity or long-duration endurance. They validate
+local VF-to-VF RDMA during physical carrier loss, not failure of the ConnectX-4 itself. Storage
+still depends on the adapter, `palace` and `cellar`; see [river's storage](river.md#storage).
+
+`brian`'s live RSTP port state was not captured during the outage tests. Its backup
+connection is validated by successful WAN and ONT traffic while `dave` is offline.
+
+Disabling only the guest trunk VF while the host PF remains healthy is an artificial fault:
+`palace`'s `hi` return traffic can still target that local VF's MAC and become unreachable through
+`river`. During physical carrier loss, external `hi` access to the host and its guests is also
+unavailable. `palace` remains accessible on `core` through a jump connection from `stream`;
+direct routed management access depends on a working return route.
+
+## Repeating the checks
+
+Perform maintenance from a box independent of `palace`, such as `tower`. Before interrupting
+`palace` or `dave`, power `castle` off cleanly: its NVMe-oF root depends on `cellar` and `dave`.
+Keep a known-working boot entry and working JetKVM video and keyboard access.
+
+1. Check boot defaults, system profiles, switch VLAN membership and spanning-tree state. When
+ changing host and guest networking together, stage matching configurations with `deploy --boot`.
+2. Verify VF isolation, VLAN/trust/carrier policies, MTUs, the RoCE GID and the mounted NVMe
+ subsystem before injecting a fault.
+3. Arrange independent automatic restoration before disabling an interface or switch port.
+ Run probes and storage reads in a service that survives loss of the SSH connection; bound
+ remote commands so a path change cannot leave a check waiting indefinitely.
+4. Inject one fault at a time. Monitor root/alternate port roles, native `core`, client VLANs,
+ ONT access, PPPoE, DNS through the floating VIPs and storage I/O. Capture BPDUs when changing
+ the topology and watch for returned own-source packets or MAC flapping.
+5. Restore the path and verify the normal RSTP roles, VIP ownership, services and management
+ access before testing another fault.
+
+Switch changes require confirmation; see [switches.md](switches.md#accessing-the-switches).
diff --git a/docs/sites/home/sfh/README.md b/docs/sites/home/sfh/README.md
index d64e260..b68269d 100644
--- a/docs/sites/home/sfh/README.md
+++ b/docs/sites/home/sfh/README.md
@@ -31,16 +31,21 @@ See the consolidated [network assignments](../../../networking.md#box-assignment
## Networking
-Four NICs, all MTU 9000 where jumbo-capable:
+| Interface | `palace` attachment | Purpose |
+|---|---|---|
+| `lan-hi` | `sfh-hi` SR-IOV VF 2 | Box's `hi` assignment and NVMe/RDMA root path; MTU 9000 |
+| `ctrs-hi` | `sfh-ctrs` SR-IOV VF 3 | L3-less MACVLAN parent for each container's `host0`; MTU 9000 |
+| `ctrs-core` | Virtio via `sfh-core-tap`, `lan-core` and `host-jim` | L3-less MACVLAN parent for UniFi's `lan-core` management attachment |
+| `ctrs-lo` | Virtio via `sfh-lo-tap`, `lan-lo` and `host-jim` | L3-less MACVLAN parent for `hass`'s `lan-lo` and the VM's PXE path |
-- `lan-hi` — SR-IOV VF 2, the box's own `hi` assignment.
-- `lan-hi-ctrs` — SR-IOV VF 3, no L3: the MACVLAN parent for the containers' `hi` interfaces
- (`host0` inside each container).
-- `lan-core-ctrs` / `lan-lo-ctrs` — virtio NICs (bridged to palace's `lan-core` / `lan-lo`), no
- L3: MACVLAN parents for containers that need a `core` or `lo` interface.
+The two VFs separate the box's RDMA root traffic from container `hi` traffic; they do not provide
+redundant switch paths. The Virtio attachments reach the `jim`-facing `palace` host bridges. Container
+`host0` names are intentionally unchanged.
The per-container MACVLAN wiring lives in `systemd.nspawn.*.networkConfig` in
-[`sfh/default.nix`](../../../../nixos/boxes/home/palace/vms/sfh/default.nix).
+[`sfh/default.nix`](../../../../nixos/boxes/home/palace/vms/sfh/default.nix). The
+[`palace` interface topology](../palace.md#end-to-end-interface-topology) traces these interfaces
+back through the VM handoffs to the physical switch connections.
## Containers
diff --git a/docs/sites/home/sfh/containers/hass.md b/docs/sites/home/sfh/containers/hass.md
index 8e64160..23f1b99 100644
--- a/docs/sites/home/sfh/containers/hass.md
+++ b/docs/sites/home/sfh/containers/hass.md
@@ -47,8 +47,8 @@ Passed through from `sfh` (USB host ports on `palace`):
## Networking
-MACVLAN interfaces created from `sfh`'s container NICs: `host0` on `lan-hi-ctrs` (the `hi` assignment,
-alt name `frigate`, default gateway via the VIP) and `lan-lo` on `lan-lo-ctrs` (the `lo`
+MACVLAN interfaces created from `sfh`'s container NICs: `host0` on `ctrs-hi` (the `hi` assignment,
+alt name `frigate`, default gateway via the VIP) and `lan-lo` on `ctrs-lo` (the `lo`
assignment, no gateway) — the `lo` interface reaches the IoT devices (the Reolink camera lives there).
## Notable config files
diff --git a/docs/sites/home/sfh/containers/unifi.md b/docs/sites/home/sfh/containers/unifi.md
index d785048..5963acd 100644
--- a/docs/sites/home/sfh/containers/unifi.md
+++ b/docs/sites/home/sfh/containers/unifi.md
@@ -29,8 +29,8 @@ management network. It is imported, listed in `sfh`'s `my.containers.instances`,
## Networking
-Two MACVLAN interfaces come from `sfh`'s container NICs: `host0` on `lan-hi-ctrs` carries the `hi`
-assignment (`unifi-ctr`, default gateway via the VIP), while `lan-core` on `lan-core-ctrs` carries
+Two MACVLAN interfaces come from `sfh`'s container NICs: `host0` on `ctrs-hi` carries the `hi`
+assignment (`unifi-ctr`, default gateway via the VIP), while `lan-core` on `ctrs-core` carries
the gatewayless `core` assignment (`unifi-ctr-core`). The `core` interface is how the controller
talks to `brian` and the other switches on their management network.
diff --git a/docs/sites/home/stream.md b/docs/sites/home/stream.md
index 6f376d3..a4e0dc6 100644
--- a/docs/sites/home/stream.md
+++ b/docs/sites/home/stream.md
@@ -16,7 +16,7 @@ redundant router pair with [`river`](river.md) and is dual-homed to both switche
| CPU | Intel Celeron N5105 (4 cores / 4 threads) |
| Memory | 16 GiB |
| Storage | 512 GB Samsung SSD 970 PRO NVMe containing `/boot`, `/nix` and `/persist`; integrated 128 GB eMMC is present but is not used by the declared filesystems |
-| Network | Three Intel `igc` interfaces and a dual-port Mellanox `mlx4_en` adapter; `wan`, `lan-jim` and `lan-dave` use three of these ports |
+| Network | Three Intel I225-V 2.5G interfaces (`igc`) and a dual-port Mellanox ConnectX-3 10G SFP+ adapter (`mlx4_en`); `wan`, `lan-jim` and `lan-dave` use three of these ports |
The platform configuration enables `kvm-intel`, `intel_iommu=on` and Intel microcode updates.
@@ -39,8 +39,9 @@ lease this section assumes — see [wwan.md](wwan.md).
### Link and addressing
-`wan` is a renamed igc NIC (`00:f0:cb:ee:ca:dd`) towards the cable modem. The modem segment is
-switch VLAN 130; `jim` handles the tag, so the box interface is untagged. See
+`wan` is a renamed igc NIC (`00:f0:cb:ee:ca:dd`) on a physically separate connection to `jim`'s
+`stream-wan` port. The Virgin Media modem connects to `shytzel`, which trunks
+VLAN 130 to `jim`; `stream-wan` delivers it untagged to the box. See
[switches.md](switches.md) for the fabric side.
`DHCP=ipv4` pulls the public lease. `dhcpV4Config.UseDNS=false` points resolution at the local
@@ -79,11 +80,15 @@ box sets:
## Switching (RSTP)
-`stream` is dual-homed to both switches: `lan-jim` (igc) and `lan-dave` (mlx4_en), both MTU 9000,
-are enslaved to the `lan` bridge with `STP=true`. The explicit bridge-port costs prefer
-`lan-dave` at 10 over `lan-jim` at 100. [`routing-common/mstpd.nix`](../../../nixos/boxes/home/routing-common/mstpd.nix)
-runs a patched `mstpd` and forces RSTP on `lan` once it is configured, so exactly one uplink carries
-traffic at a time. (The remaining NICs are renamed `et2`/`et5` and left unconfigured.)
+`stream` is dual-homed to both switches: `lan-dave` (mlx4_en) and `lan-jim` (igc), both MTU 9000,
+are ports of the shared `lan` bridge. `routing-common` owns the bridge, its STP setting and the
+explicit `dave`/`jim` costs of 10/100. [`routing-common/mstpd.nix`](../../../nixos/boxes/home/routing-common/mstpd.nix)
+runs a patched `mstpd` and forces RSTP once `lan` is configured, so exactly one uplink carries
+traffic at a time and `dave` is preferred. (The remaining NICs are renamed `et2`/`et5` and left
+unconfigured.)
+
+The separate `wan` connection to `jim` is not a bridge port. Retaining it provides an isolated
+untagged DHCP handoff for Virgin Media and a simple path for a future replacement direct-IP WAN.
## Deployment
@@ -96,8 +101,8 @@ traffic at a time. (The remaining NICs are renamed `et2`/`et5` and left unconfig
## Notable config files
- [`nixos/boxes/home/stream.nix`](../../../nixos/boxes/home/stream.nix) — box config: DHCP WAN,
- modem management, CAKE, `wan-online.target` gate, STP bridge.
+ modem management, CAKE, `wan-online.target` gate and hardware link matching.
- [`nixos/boxes/home/routing-common/default.nix`](../../../nixos/boxes/home/routing-common/default.nix) —
- shared router definition (index 1).
+ shared router definition, including the `lan` bridge and port preferences (index 1).
- [`nixos/boxes/home/routing-common/mstpd.nix`](../../../nixos/boxes/home/routing-common/mstpd.nix) —
RSTP on the `lan` bridge.
diff --git a/docs/sites/home/switches.md b/docs/sites/home/switches.md
index 8004997..ec5344a 100644
--- a/docs/sites/home/switches.md
+++ b/docs/sites/home/switches.md
@@ -1,12 +1,11 @@
# Home switches
-Reference for the two MikroTik switches on the home network — **jim** and **dave** — plus the
-Ubiquiti switch **brian**, and how the home boxes and the Digiweb WAN hang off them. These switches
-are **not** managed by this flake; they are configured by hand (RouterOS on jim/dave, UniFi on
-brian). It covers the physical topology, the VLAN map, and how the Digiweb WAN reaches river.
+Reference for the home switches: MikroTik boxes `jim` and `dave`, Ubiquiti switch `brian`, and
+Zyxel L2 switch `shytzel`. These switches are **not** managed by this flake; they are configured by
+hand. This page covers the physical topology, VLAN map, and WAN paths.
-In short: the Digiweb ISP VLAN (10) is trunked straight through to river (which runs PPPoE on it),
-and the ONT's untagged management is PVID'd onto VLAN 140 at brian, its edge switch. VLAN 10 is
+In short: the Digiweb ISP VLAN (10) is trunked straight through to `river` (which runs PPPoE on it),
+and the ONT's untagged management is PVID'd onto VLAN 140 at `brian`, its edge switch. VLAN 10 is
carried untranslated because a single ONT makes it unique on the fabric — see
[the WAN path](#the-digiweb-wan-path-trunked-vlan-10--pvid-140) and
[why not translation](#why-not-translation-for-one-ont). The router side lives in
@@ -16,72 +15,116 @@ but belongs to the colony site — see [fergal](#fergal-portculliss-switch).
## The switches
-| | jim | dave | brian |
-|---|---|---|---|
-| Identity | `jim-sw` | `dave-sw` | (UniFi) |
-| Model | CRS326-24G-2S+ | CRS504-4XQ | Ubiquiti Switch Pro XG 8 PoE |
-| Switch chip | Marvell 98DX3236 | Marvell 98DX4310 (+ Atheros 8227 for the 1G mgmt port) | — |
-| OS | RouterOS | RouterOS | UniFi |
-| Ports | 24×1G + 2×SFP+ | 4×QSFP28 (100G, breakout-capable) + 1G mgmt | 8×10GBASE-T PoE + 2×10G SFP+ |
-| Bridge | `main`, `vlan-filtering=yes` | `main`, `vlan-filtering=yes` | UniFi VLAN profiles |
+| | `jim` | `dave` | `brian` | `shytzel` |
+|---|---|---|---|---|
+| Identity | `jim-sw` | `dave-sw` | UniFi | `shytzel` |
+| Model | CRS326-24G-2S+ | CRS504-4XQ | Ubiquiti Switch Pro XG 8 PoE | Zyxel GS1920 |
+| Switch chip | Marvell 98DX3236 | Marvell 98DX4310 (+ Atheros 8227 for the 100M management port) | — | — |
+| OS | RouterOS 7.25.1 | RouterOS 7.25.1 | UniFi | Zyxel firmware |
+| RouterBOOT | 7.25.1 | 7.25.1 | — | — |
+| Ports | 24×1G + 2×SFP+ | 4×QSFP28 + 100M management | 8×10GBASE-T PoE + 2×10G SFP+ | Not recorded |
+| VLAN configuration | `main`, `vlan-filtering=yes` | `main`, `vlan-filtering=yes` | UniFi VLAN profiles | 802.1Q L2; management only on `core` |
-jim and dave run a single hardware-offloaded bridge (`main`) with VLAN filtering. Access to the
-MikroTiks is SSH as `admin` / `admin` by short hostname (see [Accessing the switches](#accessing-the-switches)).
-Only jim and dave can do hardware VLAN translation (`/interface ethernet switch rule` on the Marvell
-chips); brian cannot rewrite tags, only trunk/PVID them.
+`jim` and `dave` run a single hardware-offloaded bridge (`main`) with VLAN filtering. Access to the
+MikroTiks is SSH as `admin` / `admin` by short hostname (see
+[Accessing the switches](#accessing-the-switches)). Only `jim` and `dave` can do hardware VLAN
+translation (`/interface ethernet switch rule` on the Marvell chips); `brian` and `shytzel` provide
+ordinary L2 VLAN switching.
## Physical topology
-The two WAN sources enter at the top: the Virgin Media modem lands on **jim** (VLAN 130), and the
-Digiweb **ONT** lands on **brian**. Both `jim` and `brian` are edge switches that uplink down into
-the **dave** core; the home boxes hang off dave's 100G ports, with backup links up to jim. jim's
-second SFP+ port (`sfp-spare`, `sfp-sfpplus2`) feeds [fergal](#fergal-portculliss-switch), which
-[`portcullis`](../colony/portcullis.md) hangs off while it is staged at home.
+The Virgin Media modem lands on `shytzel`, which trunks VLAN 130 to `jim`; the Digiweb ONT lands on
+`brian`. `brian` has its primary LAG to the `dave` core and an RSTP-protected secondary connection
+to `jim`; `jim` and `dave` are also joined directly. The home boxes hang off `dave`'s high-speed
+ports, with backup links to `jim`. `jim`'s second SFP+ port (`sfp-spare`, `sfp-sfpplus2`) feeds
+[fergal](#fergal-portculliss-switch), which [`portcullis`](../colony/portcullis.md) hangs off while
+it is staged at home.
-```
- Virgin Media cable modem Digiweb ONT
- stream WAN, VLAN 130 river WAN, management + VLAN 10
- | |
- jim ---- 10G ---- fergal ---- portcullis brian
- | 10G trunk 802.3ad LAG |
- +--------------------+ +---------------+
- | |
- +---+----------+---+
- | dave |
- +--------+---------+
- |
- +---------------------+---------------------+
- | | |
- palace (100G) castle (100G) stream
- river VM NVMe-oF root second router
+[](diagrams/home-fabric.svg)
+
+The [Mermaid source](diagrams/home-fabric.mmd) defines the logical topology; the rendered SVG
+also works in Markdown viewers without Mermaid support.
+
+Solid and dotted lines distinguish normal carrying paths from backup attachments; RSTP selects
+ports for the whole fabric. The separate WAN connection to `stream` is outside its LAN bridge.
+The 10G trunk from `jim` to [fergal](#fergal-portculliss-switch) serves the staged colony boxes.
+
+### Front-panel port maps
+
+In the rack, viewed from the front, the order is **`brian`, `jim`, `dave`**, with `palace` several
+units below. `stream` sits to the left of `brian`; it is drawn above it to make the connections
+easier to follow. The map follows each model's physical socket order: `brian` has a single RJ45 row,
+`jim` has three banks with even ports above odd ports, and `dave` has four QSFP28 cages. Chassis
+widths, spacing and the gap above `palace` are schematic; exact rack-unit positions are not known.
+`palace`'s NICs are rear connections, shown only as endpoints rather than an invented front layout.
+
+[](diagrams/switch-panels.svg)
+
+Drawn lines and matching letter badges identify the two ends of a cable. Cable routes are
+schematic, not a record of how the cables are dressed in the rack. NIC labels give socket
+capacity; `stream`'s 2.5G copper ports connect to `jim` at 1G. Q3.1–Q3.4 are breakout lanes within
+**one physical cage** on `dave`, not four separate front-panel sockets. Its management RJ45
+is separate from the console above it; `jim`'s console is also separate from Ethernet port 1.
+
+
+| Cable | Endpoint | Endpoint | Connection |
+|---|---|---|---|
+| **A** | `brian` `9` | `dave` `Q3.2` | 10G; first LAG member |
+| **B** | `brian` `10` | `dave` `Q3.4` | 10G; second LAG member |
+| **C** | `brian` `5` | `jim` `7` | 1G; redundant fabric trunk |
+| **D** | `jim` `S1` | `dave` `Q3.1` | 10G; direct fabric trunk |
+| **E** | `jim` `24` | `dave` `MGMT` | 100M; dave management |
+| **F** | `jim` `5` | `palace` `host-jim` | 1G; host core / netboot |
+| **G** | `jim` `22` | `palace` `river-jim-phy` | 1G; river backup trunk |
+| **H** | `dave` `Q1` | `palace` `uplink-dave` | 100G; host PF and guest VFs |
+| **I** | `stream` `ETH0` | `jim` `21` | 1G WAN; 2.5G socket on stream |
+| **J** | `stream` `ETH1` | `jim` `6` | 1G backup LAN; 2.5G socket on stream |
+| **K** | `stream` `SFP+ R` | `dave` `Q3.3` | 10G preferred LAN |
+
+
+Port labels describe configured use, not current link state. `jim` ports 1–4 are configured
+members of `shytzel-downlink`; only port 1 had an active link at inspection. `brian` port 3 is
+reserved for testing `portcullis`, and port 4 serves its second JetKVM.
+
+The panel layouts reference the manufacturers' [Pro XG 8 PoE guide](https://dl.ui.com/qig/usw-pro-xg-8-poe/),
+[CRS326-24G-2S+RM panel](https://mikrotik.com/product/CRS326-24G-2SplusRM),
+[CRS504-4XQ-IN panel](https://mikrotik.com/product/crs504_4xq_in), and
+[R86S panel photograph](https://www.gowinfanless.com/news/blogs/the-r86s-revolution-low-power-2-5gbe-and-10gbe-networking).
+`stream`'s connected SFP+ socket is the right-hand one, confirmed physically; the left is unused.
+Its copper socket mapping is inferred from PCI order and sequential permanent MACs. The SFP+
+sockets are labelled left/right in the drawing because both carry the same printed label.
+Port use comes from RouterOS interface/bonding configuration, LLDP, the UniFi controller and
+physical connection confirmation.
+The editable inventory is [switch-ports.json](diagrams/switch-ports.json); run
+`python3 docs/sites/home/diagrams/render-switch-panels.py` to regenerate the SVG and cable table.
- Backup links to jim (normally idle):
- * palace: 1G
- * stream: 1G; STP selects the active link
- * castle: 2.5G, normally down; no live failover
-```
Notes:
-- **river** runs as a VM on the **palace** host; its uplink is dave's 100G `palace` port. jim also
- has 1G `palace`/`stream` ports, but those are secondary links and do **not** carry the WAN.
+- **river** uses `palace`'s 100G connection to `dave` and the dedicated 1G `river-jim-phy`
+ connection to `jim`. The separate `host-jim` connection serves host management and VM netboot.
+ See [palace's networking](palace.md#networking) for the handoffs and
+ [failover validation](router-failover.md) for tested failure scenarios.
+- `jim`'s `palace-kvm` port is a JetKVM connection, not either of `palace`'s network attachments.
- **stream** (the second router box) is dual-homed to both jim and dave (STP picks the active path).
+- The `brian`–`jim` secondary connection provides the redundant Digiweb path. VLANs 10 and 140 are
+ tagged on `brian-downlink` and `river`, keeping the ONT reachable without `dave`.
-### Castle storage dependency
+### `castle` storage dependency
`castle` is dual-homed without STP: its primary link is dave's 100G `castle` port (`et100g`), while
-the 2.5G link to jim (`et2.5g`) is normally down and provides no live failover. Its root disk is
-NVMe-oF over `et100g` and dave, so interrupting either freezes `castle` mid-I/O. Do dave maintenance
-from a box that does not depend on it, or power `castle` off cleanly first.
+the backup NIC (`et2.5g`) connects at 1G to jim and is normally down, providing no live failover.
+Its root disk is NVMe-oF over `et100g` and dave, so interrupting either freezes `castle` mid-I/O.
+Do dave maintenance from a box that does not depend on it, or power `castle` off cleanly first.
## VLANs
| VLAN | Name | Purpose |
|---|---|---|
-| — (native) | core | Switch management, `192.168.64.0/24` (jim `.10`, dave `.11`, brian `.13`) |
+| — (native) | core | Switch management, `192.168.64.0/24` (`jim` `.10`, `dave` `.11`, `shytzel` `.12`, `brian` `.13`) |
| 100 | hi | High-performance / jumbo network (MTU 9000) |
| 110 | lo | Standard LAN |
| 120 | untrusted | Guest / untrusted network |
-| 130 | wan | **stream's WAN** — Virgin Media cable modem (untagged on jim's `wan1`/`wan2`/`wan-in`) |
+| 130 | wan | `stream`'s WAN — tagged from `shytzel`, untagged on `jim`'s `stream-wan` |
| 140 | wan-pon-ont | ONT management, `192.168.100.0/24` (PVID'd at the ONT edge) |
| 10 | pon-isp | Digiweb ISP transport — **trunked straight through** to river, PPPoE runs on it |
| 141 | wan-pon-isp | **Reserved** — the translated ISP VLAN for the future multi-ONT design |
@@ -107,11 +150,12 @@ we just carry it end to end and let river run PPPoE directly on it:
reach the ONT web UI at `192.168.100.1`. Doing the PVID at the ONT-facing edge keeps it clean —
the untagged frames never share a domain with anything else.
-2. **VLAN 10 (ISP) trunked straight through, untranslated.** brian → dave → palace carry tagged
- VLAN 10 by ordinary bridge-VLAN membership. No `/interface ethernet switch rule`, no pinning, no
- asymmetric-learning issues — it's just a normal tagged VLAN. river attaches PPPoE to VLAN 10
- directly (`wan-pon-isp` netdev = VLAN `pon-isp` = 10; baby-jumbo MTU 1508 so PPP nets a clean
- 1500).
+2. **VLAN 10 (ISP) trunked straight through, untranslated.** The primary path is
+ brian → dave → palace; the RSTP-protected secondary path reaches jim before returning to the
+ router attachment. Both carry tagged VLAN 10 by ordinary bridge-VLAN membership. No
+ `/interface ethernet switch rule`, no pinning, no asymmetric-learning issues — it's just a
+ normal tagged VLAN. river attaches PPPoE to VLAN 10 directly (`wan-pon-isp` netdev = VLAN
+ `pon-isp` = 10; baby-jumbo MTU 1508 so PPP nets a clean 1500).
Net result: **river runs PPPoE single-tagged on VLAN 10 and holds a VLAN 140 address to reach the
ONT.** See [`nixos/boxes/home/palace/vms/river.nix`](../../../nixos/boxes/home/palace/vms/river.nix)
@@ -139,18 +183,41 @@ How each switch is set up for the Digiweb WAN path. **Confirm any change on the
- The ONT port has **native/untagged network = VLAN 140** (PVID) and is a **tagged member of VLAN 10**,
so the ONT's untagged management lands on 140 and its tagged ISP frames pass through.
- The `brian-downlink` LAG up to dave trunks **tagged 140 + tagged 10** (alongside the LAN VLANs).
+- Its secondary connection to jim carries the same relevant VLANs and participates in RSTP, so it
+ normally remains blocked while the primary path is healthy.
**dave (RouterOS)** — trunks both WAN-pon VLANs to `brian-downlink` and `palace`. The ISP VLAN 10 row:
```
-/interface bridge vlan add bridge=main vlan-ids=10 tagged=brian-downlink,palace
+/interface bridge vlan add bridge=main vlan-ids=10 tagged=brian-downlink,palace,jim-downlink
```
-VLAN 140 also spans `brian-downlink,palace` (it carries a few other members too). No switch rules —
-this is plain tagged bridging.
+VLAN 140 also spans `brian-downlink,palace,jim-downlink` (it carries a few other members too).
+The direct connection to `jim` must carry both VLANs: if only `palace`'s `dave` connection fails,
+the surviving RSTP path from `river` through `jim` can still traverse `dave` to reach `brian`.
+These are ordinary tagged VLANs with no switch rules.
+
+**jim (RouterOS)** — the physical `brian-downlink` is the RSTP-protected secondary connection from
+`brian`. VLANs 10 and 140 have `brian-downlink`, `river` and `dave-uplink` as tagged members; no
+translation rules are involved. RSTP selects one tree for the bridge, so VLAN membership must
+include the surviving paths through `dave` as well as the direct backup connection to `brian`.
+The `river` port is native `core` with VLANs 10, 100, 110, 120 and 140 tagged. It
+faces `palace`'s `river-jim-phy`; `palace-kvm` is the separate JetKVM connection and must remain
+unchanged. See [failover validation](router-failover.md) for attachment and switch-outage checks.
+
+Other connections on `jim`:
+
+| Interface | Connection |
+|---|---|
+| `ether9` | Disconnected, unbridged spare |
+| `stream-wan` | Dedicated untagged VLAN 130 handoff to `stream` `wan` |
+| `river` | `river-jim-phy`, redundant native-core trunk |
+
+The modem connects to `shytzel`. `stream` has two distinct connections to `jim`: `stream-wan`
+for its isolated WAN handoff and `stream` for the `lan-jim` RSTP trunk. VLAN 130 has
+`shytzel-downlink` tagged and only `stream-wan` untagged.
+
+`sfp-spare` (`sfp-sfpplus2`) stays at `pvid=1`—the switch feeding `portcullis` is reached over
+VLAN 1 untagged—and is a tagged member of `hi` (100) and `lo` (110):
-**jim (RouterOS)** — carries **none** of the Digiweb WAN path: no translation rules, and no VLAN
-10/140/141 rows. jim only handles stream's VLAN-130 WAN and the LAN VLANs. `sfp-spare`
-(`sfp-sfpplus2`) stays at `pvid=1` — the switch feeding `portcullis` is reached over VLAN 1
-untagged — and is a **tagged** member of `hi` (100) and `lo` (110) so those reach `portcullis`:
```
/interface bridge vlan set [find bridge=main vlan-ids=100] tagged=...,sfp-spare
/interface bridge vlan set [find bridge=main vlan-ids=110] tagged=...,sfp-spare
@@ -163,8 +230,7 @@ management addresses and static default routes exist only so the boxes themselve
reach out; they must **never** forward traffic or advertise themselves as routers. RouterOS defaults
work against this: `ip-forward` and IPv6 `forward` ship **on**, and with IPv6 forwarding on RouterOS
also emits Router Advertisements (`ra-lifetime=30m`) on every L3 interface — so a switch silently
-becomes a competing IPv6 default router. This surfaced after the 7.18 → 7.23 upgrade, when clients
-picked up dave/jim as default routers alongside river.
+becomes a competing IPv6 default router.
The required config on each RouterOS box:
```
@@ -181,7 +247,7 @@ The required config on each RouterOS box:
actively clears the rogue default from clients (they otherwise cache it for up to ~30 min).
**After any RouterOS upgrade, re-check `/ip settings` and `/ipv6 settings`** — an upgrade can reset
-these to the forwarding-on defaults. brian (UniFi) is not a RouterOS box and was not affected.
+these to the forwarding-on defaults. `brian` uses UniFi and does not have these RouterOS settings.
## Future: multiple ONTs (per-port VLAN translation)
@@ -225,6 +291,31 @@ documented in [sites/colony/fergal.md](../colony/fergal.md).
## Accessing the switches
+### Upgrades
+
+Save a text export and an encrypted binary backup, and download both before rebooting. Local
+upgrade backups are kept outside Git under `.cache/mikrotik//`; the backup password is
+stored alongside them with restricted permissions. Binary backups are intended for restoration
+on the same box and RouterOS version.
+
+Upgrade one switch at a time. Upgrade `dave` while `jim` carries the backup path, verify its
+return, then upgrade `jim`. Check RouterOS packages first, then upgrade RouterBOOT and reboot
+again if its running version differs. After each upgrade, verify bridge VLAN membership, RSTP
+roles, hardware offload and the [forwarding/RA settings](#switches-must-not-route).
+
+During failover, use bounded SSH calls and an explicit IPv4 management address where useful.
+RouterOS parses newlines in SSH command strings separately: send a multi-statement block as one
+line with semicolons so local variables and braces retain their scope.
+
+Whole-switch outage behavior is covered by the
+[validated scenarios](router-failover.md#validated-scenarios).
+
+RouterOS uses Ed25519 host keys by default. Upgrading an older installation can change the
+host-key type from RSA; verify the box's identity independently before updating the client's
+`known_hosts` entry.
+
+### SSH
+
The switches resolve by **short hostname** on the home network — the home routers serve their
records in the home zone
([`nixos/boxes/home/routing-common/dns.nix`](../../../nixos/boxes/home/routing-common/dns.nix):
@@ -241,13 +332,15 @@ sshpass -p admin ssh -o IdentityAgent=none -o PubkeyAuthentication=no \
-o UserKnownHostsFile=/tmp/sw_known_hosts admin@jim
```
-**Always confirm config changes on the switch** (print the affected menu, apply, re-verify). brian
-is UniFi — configured through its controller, not RouterOS CLI.
+**Always confirm config changes on the switch** (print the affected menu, apply, re-verify).
+`brian` is configured through its UniFi controller. `shytzel` is an L2 managed switch whose
+management interface is reachable only through `core`.
## Management IPs
| | core (`192.168.64.0/24`) | hi (`192.168.68.0/22`) | lo (`192.168.72.0/21`) |
|---|---|---|---|
-| jim | `.10` (on `main`) | `.10` | `.10` |
-| dave | `.11` (on `management`, the 1G Atheros port) | `.11` | `.11` |
-| brian | `.13` (core) | — | — |
+| `jim` | `.10` (on `main`) | `.10` | `.10` |
+| `dave` | `.11` (on `management`, the 1G Atheros port) | `.11` | `.11` |
+| `brian` | `.13` (`core`) | — | — |
+| `shytzel` | `.12` | — | — |
diff --git a/nixos/boxes/home/palace/default.nix b/nixos/boxes/home/palace/default.nix
index 92a17a2..cf34d59 100644
--- a/nixos/boxes/home/palace/default.nix
+++ b/nixos/boxes/home/palace/default.nix
@@ -57,6 +57,8 @@ in
kernelParams = [ "amd_iommu=on" ];
initrd = {
availableKernelModules = [ "xhci_pci" "ahci" "usb_storage" "usbhid" "sd_mod" "sr_mod" ];
+ # LVM autoactivation can discover the RAID LVs before stage-two module loading.
+ kernelModules = [ "dm-raid" ];
};
};
@@ -94,7 +96,7 @@ in
extraOptions = [ "-A /var/log/smartd/" "--interval=600" ];
};
udev.extraRules = ''
- ACTION=="add", SUBSYSTEM=="net", ENV{ID_NET_DRIVER}=="mlx5_core", ENV{ID_PATH}=="pci-0000:44:00.0", ATTR{device/sriov_numvfs}="4"
+ ACTION=="add", SUBSYSTEM=="net", ENV{ID_NET_DRIVER}=="mlx5_core", ENV{ID_PATH}=="pci-0000:44:00.0", ATTR{device/sriov_numvfs}="5"
'';
};
@@ -119,24 +121,24 @@ in
network = {
links = {
- "10-et1g0" = {
+ "10-river-jim-phy" = {
matchConfig = {
PermanentMACAddress = "e0:d5:5e:68:0c:6e";
Driver = "igb";
};
- linkConfig.Name = "et1g0";
+ linkConfig.Name = "river-jim-phy";
};
- "10-lan-core-phy" = {
+ "10-host-jim" = {
matchConfig.PermanentMACAddress = "e0:d5:5e:68:0c:70";
- linkConfig.Name = "lan-core-phy";
+ linkConfig.Name = "host-jim";
};
- "10-et100g" = {
+ "10-uplink-dave" = {
matchConfig = {
PermanentMACAddress = "24:8a:07:ac:59:c0";
Driver = "mlx5_core";
};
linkConfig = {
- Name = "et100g";
+ Name = "uplink-dave";
MTUBytes = toString hiMTU;
};
};
@@ -169,8 +171,8 @@ in
}
];
- "50-et100g" = {
- matchConfig.Name = "et100g";
+ "50-uplink-dave" = {
+ matchConfig.Name = "uplink-dave";
vlan = [ "lan-hi" ];
networkConfig = networkd.noL3;
linkConfig.RequiredForOnline = "no";
@@ -182,9 +184,10 @@ in
LinkState=yes
MACAddress=52:54:00:cc:3e:70
- # river
+ # river RDMA
[SR-IOV]
VirtualFunction=1
+ VLANId=${toString vlans.hi}
LinkState=yes
MACAddress=52:54:00:8a:8a:f2
@@ -195,12 +198,20 @@ in
LinkState=yes
MACAddress=52:54:00:ac:15:a9
- # sfh bridge
+ # sfh containers
[SR-IOV]
VirtualFunction=3
VLANId=${toString vlans.hi}
LinkState=yes
MACAddress=52:54:00:90:34:95
+
+ # river LAN trunk
+ [SR-IOV]
+ VirtualFunction=4
+ LinkState=auto
+ MACAddress=52:54:00:8a:8a:f4
+ Trust=yes
+ MACSpoofCheck=no
'';
};
"60-lan-hi" = mkMerge [
@@ -208,8 +219,8 @@ in
{ networkConfig = vlanDns "hi"; }
];
- "50-lan-core-phy" = {
- matchConfig.Name = "lan-core-phy";
+ "50-host-jim" = {
+ matchConfig.Name = "host-jim";
networkConfig = {
Bridge = "lan-core";
} // networkd.noL3;
diff --git a/nixos/boxes/home/palace/vms/default.nix b/nixos/boxes/home/palace/vms/default.nix
index 21bb92e..0fe461f 100644
--- a/nixos/boxes/home/palace/vms/default.nix
+++ b/nixos/boxes/home/palace/vms/default.nix
@@ -10,6 +10,7 @@
inherit (lib) mkMerge;
inherit (lib.my) vm;
inherit (lib.my.c) networkd;
+ inherit (lib.my.c.home) hiMTU;
installerDisk = {
name = "installer";
@@ -28,10 +29,11 @@
{
systemd.network = {
netdevs = {
- "25-vm-et1g0" = {
+ "25-river-jim-tap" = {
netdevConfig = {
- Name = "vm-et1g0";
+ Name = "river-jim-tap";
Kind = "macvtap";
+ MTUBytes = toString hiMTU;
};
# TODO: Upstream this missing section
extraConfig = ''
@@ -41,16 +43,24 @@
};
};
networks = {
- "75-et1g0" = {
- matchConfig.Name = "et1g0";
- linkConfig.RequiredForOnline = "no";
+ "75-river-jim-phy" = {
+ matchConfig.Name = "river-jim-phy";
+ linkConfig = {
+ RequiredForOnline = "no";
+ MTUBytes = toString hiMTU;
+ };
networkConfig = {
- MACVTAP = "vm-et1g0";
+ MACVTAP = "river-jim-tap";
} // networkd.noL3;
};
- "75-vm-et1g0" = {
- matchConfig.Name = "vm-et1g0";
- linkConfig.RequiredForOnline = "no";
+ "75-river-jim-tap" = {
+ matchConfig.Name = "river-jim-tap";
+ linkConfig = {
+ RequiredForOnline = "no";
+ MTUBytes = toString hiMTU;
+ # Guest receive-filter changes are not synchronized to the host macvtap.
+ AllMulticast = true;
+ };
networkConfig = networkd.noL3;
};
};
@@ -79,7 +89,7 @@
"vm@river" =
let
- vtapUnit = "sys-subsystem-net-devices-vm\\x2det1g0.device";
+ vtapUnit = "sys-subsystem-net-devices-river\\x2djim\\x2dtap.device";
in
mkMerge [
(awaitVM "cellar")
@@ -109,7 +119,7 @@
(vm.disk "cellar" "persist")
];
hostDevices = {
- et100g0vf0 = {
+ cellar-hi = {
index = 0;
hostBDF = "44:00.1";
};
@@ -142,8 +152,8 @@
memory = 4096;
cleanShutdown.timeout = 60;
networks = {
- et1g0 = {
- ifname = "vm-et1g0";
+ lan-jim = {
+ ifname = "river-jim-tap";
bridge = null;
tapFD = 100;
# Real hardware MAC
@@ -156,10 +166,14 @@
(mkMerge [ (vm.disk "river" "esp") { frontendOpts.bootindex = 0; } ])
];
hostDevices = {
- et100g0vf1 = {
+ river-rdma = {
index = 0;
hostBDF = "44:00.2";
};
+ river-dave = {
+ index = 1;
+ hostBDF = "44:00.5";
+ };
};
};
@@ -175,13 +189,14 @@
networks = {
netboot = {
bridge = "lan-lo";
+ ifname = "sfh-lo-tap";
waitOnline = "carrier";
mac = "52:54:00:a5:7e:93";
extraOptions.bootindex = 1;
};
core = {
bridge = "lan-core";
- ifname = "vm-sfh-core";
+ ifname = "sfh-core-tap";
waitOnline = "carrier";
mac = "52:54:00:72:67:51";
};
@@ -190,11 +205,11 @@
(vm.lvmDisk' "hdds" "frigate")
];
hostDevices = {
- et100g0vf2 = {
+ sfh-hi = {
index = 0;
hostBDF = "44:00.3";
};
- et100g0vf3 = {
+ sfh-ctrs = {
index = 1;
hostBDF = "44:00.4";
};
diff --git a/nixos/boxes/home/palace/vms/river.nix b/nixos/boxes/home/palace/vms/river.nix
index 890bed1..cf83a01 100644
--- a/nixos/boxes/home/palace/vms/river.nix
+++ b/nixos/boxes/home/palace/vms/river.nix
@@ -1,3 +1,8 @@
+{ lib, ... }:
+let
+ inherit (lib.my) net;
+ inherit (lib.my.c.home) domain prefixes hiMTU;
+in
{
imports = [ (import ../../routing-common 0) ];
@@ -6,6 +11,17 @@
nixpkgs = "mine";
home-manager = "mine";
+ assignments.rdma = {
+ name = "river-rdma";
+ inherit domain;
+ mtu = hiMTU;
+ ipv4 = {
+ address = net.cidr.host 3 prefixes.hi.v4;
+ mask = 32;
+ gateway = null;
+ };
+ };
+
configuration = { lib, modulesPath, pkgs, config, assignments, allAssignments, ... }:
let
inherit (builtins) elemAt;
@@ -14,11 +30,11 @@
inherit (lib.my.c) networkd;
inherit (lib.my.c.home) vlans domain prefixes roceBootModules routersPubV4;
- # river reaches the ONT over its 100G `lan` uplink to the dave switch (which downlinks to
- # brian, where the ONT lands). Digiweb delivers the ISP VLAN (pon-isp, 10) single-tagged at
- # the ONT alongside the ONT's untagged management traffic. With a single ONT there's no VLAN
- # collision, so the switches simply trunk the ISP's VLAN 10 straight through to river (PPPoE
- # runs directly on it) and PVID the ONT's untagged management port onto wan-pon-ont (140).
+ # river reaches the ONT through its RSTP `lan` bridge, normally over `lan-dave`. Digiweb
+ # delivers the ISP VLAN (pon-isp, 10) single-tagged at the ONT alongside the ONT's untagged
+ # management traffic. With a single ONT there's no VLAN collision, so the switches simply
+ # trunk the ISP's VLAN 10 straight through to river (PPPoE runs directly on it) and PVID the
+ # ONT's untagged management port onto wan-pon-ont (140).
# river takes .100 in the ONT's /24 (matching stream's modem-mgmt .100) to reach its web
# UI at 192.168.100.1. (See docs/sites/home/switches.md for the switch side and the multi-ONT plan.)
ontV4 = net.cidr.host 100 prefixes.ont.v4;
@@ -35,24 +51,36 @@
boot = {
kernelModules = [ "kvm-amd" ];
kernelParams = [ "console=ttyS0,115200n8" ];
+ kernel.sysctl = {
+ # Keep storage and router addresses on their own interfaces on the shared L2 network.
+ "net.ipv4.conf.rdma-hi.arp_ignore" = 1;
+ "net.ipv4.conf.lan-hi.arp_ignore" = 1;
+ "net.ipv4.conf.rdma-hi.arp_announce" = 2;
+ "net.ipv4.conf.lan-hi.arp_announce" = 2;
+ };
initrd = {
availableKernelModules = [
"virtio_pci" "ahci" "sr_mod" "virtio_blk"
- "8021q"
] ++ roceBootModules;
kernelModules = [ "dm-snapshot" ];
systemd = {
network = {
- # Don't need to put the link config here, they're copied from main config
- netdevs = mkVLAN "lan-hi" vlans.hi;
+ # NixOS copies the main system's .link units into the systemd initrd, so the
+ # rdma-hi rename is already available here. It does not copy .network units.
networks = {
- "20-lan" = {
- matchConfig.Name = "lan";
- vlan = [ "lan-hi" ];
- linkConfig.RequiredForOnline = "no";
- networkConfig = networkd.noL3;
- };
- "30-lan-hi" = networkdAssignment "lan-hi" assignments.hi;
+ "20-rdma-hi" = mkMerge [
+ (networkdAssignment "rdma-hi" assignments.rdma)
+ {
+ networkConfig.IPv6AcceptRA = mkForce false;
+ routes = [
+ {
+ Destination = "${allAssignments.cellar.hi.ipv4.address}/32";
+ PreferredSource = assignments.rdma.ipv4.address;
+ Scope = "link";
+ }
+ ];
+ }
+ ];
};
};
};
@@ -86,9 +114,6 @@
};
fstrim.enable = true;
- # TODO: re-enable once scheduling is tested
- networkd-dispatcher.enable = mkForce false;
-
pppd = {
enable = true;
peers.digiweb = {
@@ -118,8 +143,8 @@
};
};
- # PPPoE WAN (Digiweb): pppd owns the `wan` interface on top of wan-pon-isp (the switch's
- # swap of the ISP's VLAN 10), and its ip-up/ip-down hooks toggle the shared
+ # PPPoE WAN (Digiweb): pppd owns the `wan` interface on top of wan-pon-isp (the ISP's
+ # VLAN 10, trunked untranslated), and its ip-up/ip-down hooks toggle the shared
# wan-online.target. Nothing else Wants the target, so it stays inactive until the link
# is actually up.
systemd.targets.wan-online.unitConfig.DefaultDependencies = false;
@@ -159,10 +184,18 @@
(mkVLAN "wan-pon-ont" vlans.wan-pon-ont)
# The ISP VLAN is trunked through untranslated, so this is the raw pon-isp (10)
(mkVLAN "wan-pon-isp" vlans.pon-isp)
+ {
+ # The adapter's local switching must recognize the router's destination MAC.
+ "25-lan".netdevConfig = {
+ Name = "lan";
+ Kind = "bridge";
+ MACAddress = "52:54:00:8a:8a:f4";
+ };
+ }
];
links = {
- "10-wan-old" = {
+ "10-lan-jim" = {
matchConfig = {
# Matching against MAC address seems to break VLAN interfaces
# (since they share the same MAC address)
@@ -170,34 +203,61 @@
PermanentMACAddress = "e0:d5:5e:68:0c:6e";
};
linkConfig = {
- Name = "wan-old";
+ Name = "lan-jim";
RxBufferSize = 4096;
TxBufferSize = 4096;
+ MTUBytes = toString lib.my.c.home.hiMTU;
};
};
- "10-lan" = {
+ "10-rdma-hi" = {
matchConfig = {
Driver = "mlx5_core";
PermanentMACAddress = "52:54:00:8a:8a:f2";
};
linkConfig = {
- Name = "lan";
+ Name = "rdma-hi";
+ MTUBytes = toString lib.my.c.home.hiMTU;
+ };
+ };
+
+ "10-lan-dave" = {
+ matchConfig = {
+ Driver = "mlx5_core";
+ PermanentMACAddress = "52:54:00:8a:8a:f4";
+ };
+ linkConfig = {
+ Name = "lan-dave";
MTUBytes = toString lib.my.c.home.hiMTU;
};
};
};
networks = {
+ "30-rdma-hi" = mkMerge [
+ (networkdAssignment "rdma-hi" assignments.rdma)
+ {
+ networkConfig = {
+ IPv6AcceptRA = mkForce false;
+ # Keep the address and route established in the initrd: dropping either while
+ # stage 2 networkd takes over would interrupt the mounted NVMe-oF root.
+ KeepConfiguration = "static";
+ };
+ routes = [
+ {
+ Destination = "${allAssignments.cellar.hi.ipv4.address}/32";
+ PreferredSource = assignments.rdma.ipv4.address;
+ Scope = "link";
+ }
+ ];
+ }
+ ];
"55-lan" = {
- # both WAN VLANs arrive single-tagged on the 100G uplink to dave: wan-pon-ont (140,
- # the ONT's management, PVID-tagged at the brian edge) and the ISP's VLAN 10, trunked
- # straight through
+ # Both WAN VLANs arrive single-tagged on the RSTP bridge: wan-pon-ont (140, the ONT's
+ # management, PVID-tagged at the brian edge) and the ISP's VLAN 10, trunked straight
+ # through.
vlan = [ "wan-pon-ont" "wan-pon-isp" ];
};
- # So we don't drop the IP we use to connect to NVMe-oF!
- "60-lan-hi".networkConfig.KeepConfiguration = "static";
-
# ONT management: the brian edge PVIDs the ONT's untagged port onto wan-pon-ont, so give
# ourselves an address in its /24 to reach the ONT's web UI at 192.168.100.1.
"70-wan-pon-ont" = {
diff --git a/nixos/boxes/home/palace/vms/sfh/default.nix b/nixos/boxes/home/palace/vms/sfh/default.nix
index b06bbef..e992c14 100644
--- a/nixos/boxes/home/palace/vms/sfh/default.nix
+++ b/nixos/boxes/home/palace/vms/sfh/default.nix
@@ -105,29 +105,29 @@ in
MTUBytes = toString lib.my.c.home.hiMTU;
};
};
- "10-lan-hi-ctrs" = {
+ "10-ctrs-hi" = {
matchConfig = {
Driver = "mlx5_core";
PermanentMACAddress = "52:54:00:90:34:95";
};
linkConfig = {
- Name = "lan-hi-ctrs";
+ Name = "ctrs-hi";
MTUBytes = toString lib.my.c.home.hiMTU;
};
};
- "10-lan-core-ctrs" = {
+ "10-ctrs-core" = {
matchConfig = {
Driver = "virtio_net";
PermanentMACAddress = "52:54:00:72:67:51";
};
- linkConfig.Name = "lan-core-ctrs";
+ linkConfig.Name = "ctrs-core";
};
- "10-lan-lo-ctrs" = {
+ "10-ctrs-lo" = {
matchConfig = {
Driver = "virtio_net";
PermanentMACAddress = "52:54:00:a5:7e:93";
};
- linkConfig.Name = "lan-lo-ctrs";
+ linkConfig.Name = "ctrs-lo";
};
};
@@ -139,18 +139,18 @@ in
networkConfig = vlanDns "hi" // { KeepConfiguration = "static"; };
}
];
- "30-lan-hi-ctrs" = {
- matchConfig.Name = "lan-hi-ctrs";
+ "30-ctrs-hi" = {
+ matchConfig.Name = "ctrs-hi";
linkConfig.RequiredForOnline = "no";
networkConfig = networkd.noL3;
};
- "30-lan-core-ctrs" = {
- matchConfig.Name = "lan-core-ctrs";
+ "30-ctrs-core" = {
+ matchConfig.Name = "ctrs-core";
linkConfig.RequiredForOnline = "no";
networkConfig = networkd.noL3;
};
- "30-lan-lo-ctrs" = {
- matchConfig.Name = "lan-lo-ctrs";
+ "30-ctrs-lo" = {
+ matchConfig.Name = "ctrs-lo";
linkConfig.RequiredForOnline = "no";
networkConfig = networkd.noL3;
};
@@ -160,12 +160,12 @@ in
systemd.nspawn = {
hass = {
networkConfig = {
- MACVLAN = mkForce "lan-hi-ctrs:host0 lan-lo-ctrs:lan-lo";
+ MACVLAN = mkForce "ctrs-hi:host0 ctrs-lo:lan-lo";
};
};
unifi = {
networkConfig = {
- MACVLAN = mkForce "lan-hi-ctrs:host0 lan-core-ctrs:lan-core";
+ MACVLAN = mkForce "ctrs-hi:host0 ctrs-core:lan-core";
};
};
};
@@ -217,7 +217,7 @@ in
mkMerge [
instances
(mapAttrs (n: i: {
- networking.macVLAN = "lan-hi-ctrs";
+ networking.macVLAN = "ctrs-hi";
}) instances)
];
};
diff --git a/nixos/boxes/home/routing-common/default.nix b/nixos/boxes/home/routing-common/default.nix
index 7322457..ae0211d 100644
--- a/nixos/boxes/home/routing-common/default.nix
+++ b/nixos/boxes/home/routing-common/default.nix
@@ -3,7 +3,7 @@ let
inherit (builtins) elemAt;
inherit (lib.my) net mkVLAN;
inherit (lib.my.c) pubDomain;
- inherit (lib.my.c.home) domain vlans prefixes vips routers routersPubV4;
+ inherit (lib.my.c.home) domain vlans prefixes vips routers routersPubV4 hiMTU;
name = elemAt routers index;
otherIndex = 1 - index;
@@ -106,7 +106,7 @@ in
./dns.nix
./radvd.nix
./kea.nix
- ];
+ ] ++ [ ./mstpd.nix ];
# Per-box WAN-management specifics: the Virgin Media modem on stream lives on the `wan`
# interface itself, whereas river's ONT sits on its own interface. Declared as options the
@@ -203,6 +203,14 @@ in
netdevs = mkMerge [
{
+ "25-lan" = {
+ netdevConfig = {
+ Name = "lan";
+ Kind = "bridge";
+ MTUBytes = toString hiMTU;
+ };
+ bridgeConfig.STP = true;
+ };
"25-wan-ifb".netdevConfig = {
Name = "wan-ifb";
Kind = "ifb";
@@ -238,6 +246,16 @@ in
in
mkMerge [
{
+ "50-lan-dave" = {
+ matchConfig.Name = "lan-dave";
+ networkConfig.Bridge = "lan";
+ bridgeConfig.Cost = 10;
+ };
+ "50-lan-jim" = {
+ matchConfig.Name = "lan-jim";
+ networkConfig.Bridge = "lan";
+ bridgeConfig.Cost = 100;
+ };
"55-lan" = {
matchConfig.Name = "lan";
vlan = [ "lan-hi" "lan-lo" "lan-untrusted" "wan-tunnel" ];
diff --git a/nixos/boxes/home/routing-common/kea.nix b/nixos/boxes/home/routing-common/kea.nix
index 57237a6..a5eef59 100644
--- a/nixos/boxes/home/routing-common/kea.nix
+++ b/nixos/boxes/home/routing-common/kea.nix
@@ -26,10 +26,18 @@ in
};
systemd.services = {
- kea-dhcp4-server.serviceConfig = {
- # Sometimes interfaces might not be ready in time and Kea doesn't like that
- Restart = "on-failure";
- DynamicUser = mkForce false;
+ kea-dhcp4-server = {
+ unitConfig.StartLimitIntervalSec = 0;
+ preStart = ''
+ ${config.systemd.package}/lib/systemd/systemd-networkd-wait-online \
+ --interface=lan-hi:routable --interface=lan-lo:routable \
+ --interface=lan-untrusted:routable --ipv4 --timeout=60
+ '';
+ serviceConfig = {
+ Restart = "on-failure";
+ RestartSec = 5;
+ DynamicUser = mkForce false;
+ };
};
kea-dhcp-ddns-server.serviceConfig.DynamicUser = mkForce false;
};
diff --git a/nixos/boxes/home/routing-common/mstpd.nix b/nixos/boxes/home/routing-common/mstpd.nix
index 8c545f8..1d555cb 100644
--- a/nixos/boxes/home/routing-common/mstpd.nix
+++ b/nixos/boxes/home/routing-common/mstpd.nix
@@ -6,6 +6,34 @@ let
# Delete postInstall since it nukes the bridge-stp script we need
postInstall = "";
};
+ configureRSTP = pkgs.writeShellScript "configure-router-rstp" ''
+ set -eu
+ [ "$1" = lan ] || exit 0
+ event_port="''${2:-}"
+ ${mstpd}/bin/mstpctl addbridge lan
+ ${mstpd}/bin/mstpctl setforcevers lan rstp
+ for port in lan-dave lan-jim; do
+ if [ "$event_port" = "$port" ] || [ -e "/sys/class/net/lan/brif/$port" ]; then
+ case "$port" in
+ lan-dave) cost=10 ;;
+ lan-jim) cost=100 ;;
+ esac
+ # networkd can report configuration before mstpd has registered the port.
+ configured=false
+ for attempt in {1..50}; do
+ if ${mstpd}/bin/mstpctl setportpathcost lan "$port" "$cost"; then
+ configured=true
+ break
+ fi
+ ${pkgs.coreutils}/bin/sleep 0.2
+ done
+ if [ "$configured" = false ]; then
+ echo "Failed to configure RSTP cost for $port" >&2
+ exit 1
+ fi
+ fi
+ done
+ '';
in
{
environment = {
@@ -17,6 +45,7 @@ in
"bridge-stp.conf".text = ''
MANAGE_MSTPD=n
MSTP_BRIDGES=lan
+ config_cmd=${configureRSTP}
'';
};
};
@@ -27,8 +56,9 @@ in
onState = [ "configured" ];
script = ''
#!${pkgs.runtimeShell}
- if [ "$IFACE" = "lan" ]; then
- ${mstpd}/sbin/mstpctl setforcevers "$IFACE" rstp
+ # A queued event can retain the interface's name from before a rename.
+ if [ -d /sys/class/net/lan/bridge ]; then
+ ${configureRSTP} lan "$IFACE"
fi
'';
};
diff --git a/nixos/boxes/home/stream.nix b/nixos/boxes/home/stream.nix
index 51ac0c6..c6d1544 100644
--- a/nixos/boxes/home/stream.nix
+++ b/nixos/boxes/home/stream.nix
@@ -18,8 +18,6 @@
modemV4 = net.cidr.host 100 prefixes.modem.v4;
in
{
- imports = [ ./routing-common/mstpd.nix ];
-
config = {
boot = {
kernelModules = [ "kvm-intel" ];
@@ -109,15 +107,6 @@
};
systemd.network = {
- netdevs = {
- "25-lan" = {
- netdevConfig = {
- Name = "lan";
- Kind = "bridge";
- };
- bridgeConfig.STP = true;
- };
- };
links = {
"10-wan" = {
matchConfig = {
@@ -169,17 +158,6 @@
};
};
networks = {
- "50-lan-jim" = {
- matchConfig.Name = "lan-jim";
- networkConfig.Bridge = "lan";
- bridgeConfig.Cost = 100;
- };
- "50-lan-dave" = {
- matchConfig.Name = "lan-dave";
- networkConfig.Bridge = "lan";
- bridgeConfig.Cost = 10;
- };
-
"50-wan-ifb" = {
matchConfig.Name = "wan-ifb";
networkConfig = networkd.noL3;