24 Commits

Author SHA1 Message Date
jackos1998 e47e30df19 .claude: Add shared permissions allow list, ignore local settings
CI / Check, build and cache nixfiles (push) Failing after 2m30s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:13:03 +01:00
jackos1998 e077c6e30c home-manager/common: Add treemd 2026-07-28 12:06:24 +01:00
jackos1998 6f8d499ec2 docs: Add home APs and switches references
Document the home network's out-of-band devices that aren't managed by
this flake:

- `home-aps.md` — the two dumb APs (`vibe` MikroTik cAP ax, `wave` Cudy
  AX3000 on OpenWrt): shared VLAN-trunk design (`hi`/`lo` are both
  trusted client VLANs differing only by MTU), SSIDs, per-AP management
  addressing, roaming (802.11k/v), and the OpenWrt flash + on-device
  config for `wave` (incl. the `mtk_eth_soc` eth0 2026-byte MTU cap that
  keeps it off the jumbo `hi` VLAN, wpad-mbedtls swap, HE160).
- `home-switches.md` — the hand-configured switches (`jim`/`dave`
  MikroTik, `brian` UniFi) topology, VLAN map, and ONT/WAN path.

Add the `home-aps.md` pointer to AGENTS.md alongside the existing
switches reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:06:24 +01:00
jackos1998 d0859d4c5c nixos/home: Add wave AP DNS records, renumber vibe to .15
New Cudy AX3000 AP `wave` takes host `.14` on core (backup) and lo
(primary); replaces the decommissioned AP of the same name. It's managed
on `lo`, not `hi`, because its 2.5GbE (`mtk_eth_soc` eth0) caps at MTU
2026 and so can't join `hi`'s jumbo (9000) fabric — and `hi`/`lo` are
both trusted client VLANs differing only by MTU.

Renumber the MikroTik AP `vibe` from `.13` to `.15` and give it the same
core/hi/lo records as `jim`/`dave` (was `hi`-only), matching its
on-device reconfiguration. `vibe` stays on `hi` since it can do jumbo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:46:13 +01:00
jackos1998 9b582bec7b home-manager/gui: Add pi-coding-agent
CI / Check, build and cache nixfiles (push) Failing after 2m29s
2026-07-26 18:55:20 +01:00
jackos1998 8c4223af18 AGENTS.md: Document switches, deploy --boot, and memory policy
CI / Check, build and cache nixfiles (push) Successful in 45m13s
- Note `CLAUDE.md` is a symlink to `AGENTS.md` (edit the real file).
- Policy: prefer recording durable guidance here over agent memory.
- Add the "Home switches" section (`jim`/`dave`/`brian`, access,
  confirm-before-apply) pointing at `home-switches.md`.
- Document `deploy --boot` for staging a config without live-switching.
- Note the `ssh-agent` hang workaround for outbound SSH (`ssh-machine`/`deploy`).
- Add a comment convention: comment where useful, not for trivial code, and
  describe general purpose rather than the one-off reason for a change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:35:50 +01:00
jackos1998 b95992735e nixos/home: Trunk Digiweb VLAN 10 to river instead of translating
With a single ONT the ISP's VLAN 10 is unique on the fabric, so there's no
need to swap it to a private fabric VLAN. Carry pon-isp (10) trunked straight
through the switches to river and run PPPoE directly on it, dropping the old
wan-pon-isp (141) translation. The ONT's untagged management is PVID'd onto
wan-pon-ont (140) at its edge switch (brian).

wan-pon-isp (141) is kept as a constant, reserved for the future multi-ONT
case where per-port VLAN translation disambiguates two VLAN-10 ONTs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:29:16 +01:00
jackos1998 0d1562d150 home-manager/gui: Add more LSPs
CI / Check, build and cache nixfiles (push) Successful in 44m33s
2026-07-23 01:37:33 +01:00
jackos1998 49de78d47f nixos/home: De-share Virgin Media modem specifics from routing-common
The modem management subnet is stream-specific (it shares stream's `wan`
interface), so it no longer belongs in the shared routing-common. Drop the
`modem` network assignment and configure the address directly on stream's
`wan` (modemV4). The firewall reject and DNS wan-A-record skipBroadcasts that
existed only because of the modem move to per-box options
`my.homeRouter.{firewall.untrustedRejectV4,dns.wanSkipBroadcasts}`, set solely
by stream. river needs neither: its ONT sits on a separate interface already
blocked by the interface-based forward rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:35:08 +01:00
jackos1998 832d0b5542 nixos/home: Rework river WAN to single-tag VLAN 140/141
Digiweb's ONT delivers its untagged management traffic (192.168.100.0/24)
alongside the tagged ISP VLAN 10. The switch feeding river now tags the ONT's
untagged port as wan-pon-ont (140) and swaps the ISP VLAN 10 to wan-pon-isp
(141) on ingress, so both reach river single-tagged: river takes an address in
the ONT's /24 on 140 to reach its web UI, and PPPoE runs directly on 141.

Replaces the old QinQ-into-131 (wanStacked) transport; the wan-pon (131) VLAN
constant is dropped and prefixes.ont added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:35:08 +01:00
jackos1998 4d4c05ea70 Symlink CLAUDE.md to AGENTS.md
CI / Check, build and cache nixfiles (push) Successful in 49m29s
2026-07-22 18:44:03 +01:00
jackos1998 0bd08c4a91 nixos/home: Recurse over IPv4 only on the home routers
CI / Check, build and cache nixfiles (push) Successful in 46m1s
The routers' IPv6 default route runs over the as211024 mesh as a
proto-static route that blackholes rather than withdrawing when the mesh
flaps (e.g. during ipsec churn on a deploy). With "::" in the recursor's
outgoing.source_address, pdns-recursor kept selecting IPv6 to reach
authoritative servers, stalled on timeouts, and took recursion down with
it whenever the mesh bounced. Drop "::" so upstream recursion goes out
the WAN over IPv4 and stays up; AAAA records are still served fine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:56:43 +01:00
jackos1998 a86888a2c7 nixos/home: Use Digiweb static IP for river WAN
Digiweb assigned river a static IPv4 (84.203.124.128). Point river's
public address at it and have pppd actively request it as the IPCP local
address (local:remote form) rather than passively accepting whatever the
peer offers via noipdefault. The address stays sourced from the
routersPubV4 constant, so the ipsec l2 mesh peer, the nft DNS redirect,
and the cross-router route all follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:06:21 +01:00
jackos1998 dc1ec3bf5a docs: Ask before running fmt
CI / Check, build and cache nixfiles (push) Successful in 45m46s
fmt reindents non-canonically-formatted files wholesale, burying logical
changes in whitespace churn. Note to match surrounding style by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:11:25 +01:00
jackos1998 823ed83252 nixos/home: Unstack river WAN to single-tag VLAN 10
Digiweb's BRAS answers PADI only on a single-tagged ISP VLAN (pon-isp,
10); the double-stacked wan-pon (131) transport never got a PADO because
the UniFi access switch can't push the outer tag onto already-tagged
ingress frames (no QinQ/tag-stacking). Attach VLAN 10 directly to the
physical `lan` link for now.

Kept reversible via a `wanStacked` flag: flip it back to true to restore
the 131->10 stack once a QinQ-capable device sits in front of the ONT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:11:25 +01:00
jackos1998 300103f2ba nixos/home: Correct river wan-vlan-outer MTU to 1512
CI / Check, build and cache nixfiles (push) Successful in 45m9s
The outer WAN VLAN carries the inner VLAN's frames, whose 4B 802.1Q tag
counts as payload at this layer. To fit the inner's 1508B baby-jumbo
payload plus that tag it needs 1512, not 1508. Harmless before only
because the physical `lan` (9000) absorbed the extra bytes; this makes
the outer's MTU literally reflect what it transports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:57:13 +01:00
jackos1998 d33f32ce5b docs: Document home-router HA pattern and boxes terminology
CI / Check, build and cache nixfiles (push) Successful in 45m40s
Capture what this session surfaced: the routing-common indexed-module
active/backup pattern, VRRP VIPs for gateway+DNS, the wan-online.target
convention, the networkd helper locations, and the "boxes not fleet"
terminology. Also note the fish login shell caveat for ssh scripting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:44:22 +01:00
jackos1998 14d9bba4eb nixos/home: Serve DNS on VRRP VIP and fix ipsec WAN ordering
Clients were handed both routers' real addresses as resolvers, so a downed
router meant per-query resolver timeouts. Serve pdns-recursor on the VRRP
VIPs (with non-local bind so the backup can pre-bind them) and advertise the
VIP via kea and radvd, so DNS follows the master. untrusted advertises
Cloudflare over v6 to match its v4 config.

ipsec started before the WAN's public IP was up: stream's wan carries a
static modem address that satisfies wait-online before the DHCP lease, so
libreswan loaded its mesh conns (left=<public IP>) unoriented and never
initiated. Gate stream's wan-online.target on the DHCP default route
instead, and mkForce ipsec onto wan-online.target only (dropping the
strongswan/libreswan multi-user.target pull-in) so the gate actually holds
on both boxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:38:02 +01:00
jackos1998 e63cee7b09 nixos/home: Migrate river WAN to PPPoE over Digiweb
river's WAN moves to a PPPoE session (Digiweb, over VLAN 10 with baby-jumbo
1508 MTU) driven by pppd, with its ip-up/ip-down hooks toggling a new
wan-online.target. stream keeps its DHCP WAN, so the existing 50-wan /
50-wan-ifb networkd config moves out of routing-common into stream, and the
shared consumers (ipsec, ipv6-clear-default-route) now attach to
wan-online.target instead of depending on systemd-networkd-wait-online@wan
directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:37:05 +01:00
jackos1998 a8318d3de2 nixos/home: Separate HDD LV for frigate
CI / Check, build and cache nixfiles (push) Successful in 51m19s
2026-07-19 11:47:37 +01:00
jackos1998 aab4a193ae nixos/home: Add brian
CI / Check, build and cache nixfiles (push) Failing after 3h2m50s
2026-07-15 02:22:15 +01:00
jackos1998 541102f683 nixos/netboot: Switch to iPXE snp.efi
CI / Check, build and cache nixfiles (push) Successful in 45m47s
2026-07-14 22:42:04 +01:00
jackos1998 805590a705 nixos/routing-common: Fix mstpd missing bridge-stp script
CI / Check, build and cache nixfiles (push) Successful in 46m37s
2026-07-12 18:40:38 +01:00
jackos1998 f8dbd99a7b nixos/routing-common: Add missing ttl param to DDNS update 2026-07-12 17:15:57 +01:00
28 changed files with 973 additions and 99 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(nix eval:*)",
"Bash(nix flake check:*)",
"Bash(nix build:*)",
"Bash(check-system:*)",
"Bash(build-system:*)",
"Bash(build-home:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(drill:*)",
"Bash(ping:*)"
]
}
}
+1
View File
@@ -4,3 +4,4 @@ result*
!/.vms/.gitkeep !/.vms/.gitkeep
/.keys/*.key /.keys/*.key
*.swp *.swp
/.claude/settings.local.json
+83 -5
View File
@@ -2,11 +2,23 @@
This file provides guidance to coding agents when working with code in this repository. This file provides guidance to coding agents when working with code in this repository.
`CLAUDE.md` at the repo root is a symlink to this file — edit `AGENTS.md`, not the symlink (some
tools refuse to write through a symlink and will error on `CLAUDE.md`).
**Prefer this file over agent memory.** When you learn something durable about this repo — a
convention, a workflow gotcha, a design rationale — record it here (or in a repo doc this file points
to, e.g. `home-switches.md`), not in agent memory. AGENTS.md is versioned and shared; memory is not.
Claude Code permissions live in two files: `.claude/settings.json` (versioned, shared — the
committed allow list of safe-to-auto-approve commands) and `.claude/settings.local.json` (personal,
gitignored — where interactive "always allow" grants accumulate). Put durable, generally-safe
commands in the shared file; leave one-off or machine-specific grants in the local one.
## Overview ## Overview
Personal Nix flake managing NixOS systems and home-manager configurations for a fleet of Personal Nix flake managing NixOS systems and home-manager configurations for a set of
machines (servers, home boxes, routers). It is built around a **custom module system** layered machines — always called **"boxes"**, never "fleet". It is built around a **custom module
on top of NixOS/home-manager, not the stock flake `nixosConfigurations` pattern. system** layered on top of NixOS/home-manager, not the stock flake `nixosConfigurations` pattern.
## Commands ## Commands
@@ -28,10 +40,16 @@ Common ones:
Pass the flake-qualified node, e.g. `deploy .#git`. The deploy node name is **always** the system Pass the flake-qualified node, e.g. `deploy .#git`. The deploy node name is **always** the system
name (`deploy-rs.nix` keys nodes directly off `nixos.systems` / `home-manager.homes`); a system is name (`deploy-rs.nix` keys nodes directly off `nixos.systems` / `home-manager.homes`); a system is
only a deploy target when `config.my.deploy.enable` is true (defaults true; auto-disabled for dev only a deploy target when `config.my.deploy.enable` is true (defaults true; auto-disabled for dev
VMs and containers). VMs and containers). Pass `--boot` to stage a config as the boot default **without** live-switching
(`deploy --boot .#<host>`) — the box keeps running its current generation until it reboots. Use this
when a live `switch` would break connectivity mid-change (e.g. a router's WAN VLAN rework), then
reboot to cut over.
- `ssh-machine <name> [cmd]` — SSH to a NixOS system or home-manager config by name. Resolves the - `ssh-machine <name> [cmd]` — SSH to a NixOS system or home-manager config by name. Resolves the
target and ssh options (identity, port) from its deploy-rs node, so it needs `my.deploy.enable` target and ssh options (identity, port) from its deploy-rs node, so it needs `my.deploy.enable`
(same gate as `deploy`). (same gate as `deploy`). Boxes default to the `fish` login shell, so pipe multi-statement remote
scripts through `bash` (e.g. `ssh-machine <name> bash -s < script.sh`) rather than `&&`/`for`.
If outbound SSH hangs at the publickey step (flaky `ssh-agent`), disable the agent for the call:
`SSH_AUTH_SOCK= ssh-machine …` (or add `-o IdentityAgent=none` to a raw `ssh`).
- `ragenix` — edit age secrets using `.keys/dev.key` as identity (see Secrets). - `ragenix` — edit age secrets using `.keys/dev.key` as identity (see Secrets).
- `repl``nix repl .#`. - `repl``nix repl .#`.
- `update-nixpkgs` / `update-home-manager` — bump pinned inputs. - `update-nixpkgs` / `update-home-manager` — bump pinned inputs.
@@ -40,6 +58,12 @@ Check everything (what CI runs): `nix flake check --no-build`.
CI builds each attr of `.#ci.x86_64-linux` (systems, homes, packages, shell) and pushes to the CI builds each attr of `.#ci.x86_64-linux` (systems, homes, packages, shell) and pushes to the
Harmonia binary cache; see `.gitea/workflows/ci.yaml` and `ci/push-to-cache.sh`. Harmonia binary cache; see `.gitea/workflows/ci.yaml` and `ci/push-to-cache.sh`.
For DNS lookups use **`drill`** (ldns) — `dig` isn't installed in this environment (it fails with
exit 127, which is easy to miss if stderr is redirected). E.g. `drill -Q @<resolver> <name> A`.
For privilege escalation use **`doas`**, not `sudo` — the boxes don't install `sudo` (it fails with
`command not found`). E.g. `doas ip link set <if> up`.
## Architecture ## Architecture
### The custom module system ### The custom module system
@@ -91,6 +115,51 @@ Per-host configs live under `nixos/boxes/<host>` (some are single `.nix` files,
with nested VMs/containers under e.g. `colony/vms`). Many "systems" are VMs or containers managed with nested VMs/containers under e.g. `colony/vms`). Many "systems" are VMs or containers managed
via the `vms` / `containers` modules and the `l2mesh` VXLAN module. via the `vms` / `containers` modules and the `l2mesh` VXLAN module.
### Home routers (`nixos/boxes/home/routing-common`)
The two home routers, `river` and `stream`, share `routing-common`, which is a **function of an
`index`** (`import ../../routing-common 0` for river, `1` for stream). The index derives per-box
addresses, keepalived VRRP priorities/state, DNS `ns` numbering, etc., so the two boxes are an
active/backup HA pair from one definition. They differ where hardware/uplink differ: `stream` has a
DHCP WAN, `river` runs PPPoE (`services.pppd`, Digiweb) — box-specific bits live in the respective
box file, not `routing-common`.
- **HA is VRRP (`keepalived`).** Per-VLAN floating **VIPs** (`lib.my.c.home.vips`) are what clients
use as both gateway *and* DNS server. `kea` (DHCP) and `radvd` (RAs; started only on the master)
hand out the VIP, and `pdns-recursor` binds the VIPs (with `net.ipv*.ip_nonlocal_bind` so the
backup can pre-bind). Point client-facing services at the VIP, not a box's real address, so
failover follows the master instead of relying on client resolver timeouts.
- **`wan-online.target`** is a shared abstract target meaning "the public WAN/IPv4 route is up".
`routing-common` only declares it; each box wires *how it is reached* (`stream`: a oneshot that
waits for the DHCP default route; `river`: the pppd `ip-up`/`ip-down` hooks). Services that need
the WAN attach **to** it via `wantedBy` + `partOf` + `after` (not `requires`/`wants`), so an empty
target is never pulled in and prematurely activated, and they re-load on WAN flap.
- networkd helpers used heavily here: `lib.my.networkdAssignment` and `lib.my.mkVLAN` live under
**`lib.my`**, while networkd snippet constants like `networkd.noL3` live under **`lib.my.c`** —
easy to mix up. Set an interface MTU via the `.network`'s `linkConfig.MTUBytes` (`[Link]`), not
`netdevConfig` (`[NetDev]` rejects `MTUBytes`).
### Home switches (`jim` / `dave` / `brian`)
The home boxes and the Digiweb WAN hang off hand-configured switches that are **not** managed by
this flake: `jim` and `dave` (MikroTik, RouterOS) and `brian` (Ubiquiti, UniFi). The full topology,
VLAN map, and the ONT/WAN path live in **`home-switches.md`** at the repo root — read it before
touching anything WAN/VLAN-related, and update it when the switch layout changes.
- **Access:** the switches resolve by **short hostname** on the home network (the routers serve
their records in the home zone — `routing-common/dns.nix`: `jim`/`dave`/`brian`). From a home box,
SSH to the MikroTiks as `admin`/`admin` (e.g. `ssh admin@jim`); `brian` is configured via the
UniFi controller, not a CLI.
- **Changing switch config is out-of-band and hard to revert — always confirm before applying:**
print the affected menu, make the change, then re-verify. The nix config and the switches must
agree on VLAN numbering (e.g. `lib.my.c.home.vlans`), so a switch-side change usually pairs with a
box change; `home-switches.md` documents the switch layout and per-switch config for the WAN design.
### Home wireless APs (`vibe` / `wave`)
The home Wi-Fi APs are also **not** managed by this flake: `vibe` (MikroTik cAP ax, RouterOS) and
`wave` (Cudy AX3000 running OpenWrt/UCI). They are dumb APs — bridge clients onto the right VLAN,
routers do DHCP/RA/firewall. The trunk/VLAN design, the OpenWrt flash + config for `wave`, and the
per-AP management addressing live in **`home-aps.md`** at the repo root — read it before touching AP
config, and update it when an AP changes. Only the DNS records live in the flake
(`routing-common/dns.nix`).
## Secrets ## Secrets
age-encrypted secrets in `secrets/`, managed with **ragenix**. Each module declares age-encrypted secrets in `secrets/`, managed with **ragenix**. Each module declares
@@ -103,6 +172,12 @@ private keys) is required for editing secrets, deploying, and running dev VMs.
## Conventions ## Conventions
- Format with `nixpkgs-fmt` (`fmt`). 2-space indent, `inherit (...)` blocks at the top of `let`. - Format with `nixpkgs-fmt` (`fmt`). 2-space indent, `inherit (...)` blocks at the top of `let`.
**Ask before running `fmt`** — some files aren't canonically formatted, so `fmt` can reindent a
whole file and bury a logical change in whitespace churn. Match the surrounding style by hand and
leave formatting to the user unless they ask.
- Comment where it genuinely aids understanding, but not for trivial/obvious code — match the file's
existing (fairly sparse) comment density. When adding something general, comment its general
purpose, not the specific change or one-off reason it was introduced for.
- Prefer `lib.my` helpers (`mkOpt'`, `mkBoolOpt'`, `mkDefault'`) and `lib.my.c` constants over - Prefer `lib.my` helpers (`mkOpt'`, `mkBoolOpt'`, `mkDefault'`) and `lib.my.c` constants over
reimplementing. reimplementing.
- New shared functionality → a module in `*/modules/` + entry in `_list.nix`, options under `my.*`. - New shared functionality → a module in `*/modules/` + entry in `_list.nix`, options under `my.*`.
@@ -111,3 +186,6 @@ private keys) is required for editing secrets, deploying, and running dev VMs.
as `overlays.default`. as `overlays.default`.
- In prose and commit messages, quote code-like identifiers (commands, options, paths, package and - In prose and commit messages, quote code-like identifiers (commands, options, paths, package and
attribute names) in backticks. attribute names) in backticks.
- Call the machines **"boxes"**, never "fleet".
- Commit subjects follow `area/scope: Capitalized summary` (e.g. `nixos/home: ...`); keep logically
distinct changes in separate commits.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
Generated
+130 -4
View File
@@ -8,7 +8,7 @@
"ragenix", "ragenix",
"nixpkgs" "nixpkgs"
], ],
"systems": "systems_7" "systems": "systems_8"
}, },
"locked": { "locked": {
"lastModified": 1761656077, "lastModified": 1761656077,
@@ -67,6 +67,34 @@
"type": "github" "type": "github"
} }
}, },
"bun2nix": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"pi-agent",
"nixpkgs"
],
"systems": [
"pi-agent",
"systems"
],
"treefmt-nix": "treefmt-nix_2"
},
"locked": {
"lastModified": 1778446047,
"narHash": "sha256-oQvcadh2BCkrog+SGrG6YffKJrveYpjj3TdQJWaKhaM=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "f2bc12af1a6369648aac41041ceeaa0b866599c6",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "2.1.0",
"repo": "bun2nix",
"type": "github"
}
},
"copyparty": { "copyparty": {
"inputs": { "inputs": {
"flake-utils": "flake-utils_5", "flake-utils": "flake-utils_5",
@@ -256,6 +284,28 @@
"type": "github" "type": "github"
} }
}, },
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"pi-agent",
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1777988971,
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -276,7 +326,7 @@
}, },
"flake-utils_10": { "flake-utils_10": {
"inputs": { "inputs": {
"systems": "systems_9" "systems": "systems_10"
}, },
"locked": { "locked": {
"lastModified": 1709126324, "lastModified": 1709126324,
@@ -294,7 +344,7 @@
}, },
"flake-utils_11": { "flake-utils_11": {
"inputs": { "inputs": {
"systems": "systems_10" "systems": "systems_11"
}, },
"locked": { "locked": {
"lastModified": 1705309234, "lastModified": 1705309234,
@@ -444,7 +494,7 @@
}, },
"flake-utils_9": { "flake-utils_9": {
"inputs": { "inputs": {
"systems": "systems_8" "systems": "systems_9"
}, },
"locked": { "locked": {
"lastModified": 1731533236, "lastModified": 1731533236,
@@ -586,6 +636,21 @@
"type": "github" "type": "github"
} }
}, },
"jail-nix": {
"locked": {
"lastModified": 1776230864,
"narHash": "sha256-YsEjjdOsGEzTeD+iT7ONh071BqWAOQWpzYVei3okAXE=",
"owner": "~alexdavid",
"repo": "jail.nix",
"rev": "404e7da9da5ab9aa643666682b2ba1312fa5fbe8",
"type": "sourcehut"
},
"original": {
"owner": "~alexdavid",
"repo": "jail.nix",
"type": "sourcehut"
}
},
"libnetRepo": { "libnetRepo": {
"flake": false, "flake": false,
"locked": { "locked": {
@@ -795,6 +860,29 @@
"type": "github" "type": "github"
} }
}, },
"pi-agent": {
"inputs": {
"bun2nix": "bun2nix",
"jail-nix": "jail-nix",
"nixpkgs": [
"nixpkgs-unstable"
],
"systems": "systems_7"
},
"locked": {
"lastModified": 1784984457,
"narHash": "sha256-y7jv+RTP0TkcJaDm7/D2xIFF9JDWKc6g5L3X3EUu3Eo=",
"owner": "lukasl-dev",
"repo": "pi.nix",
"rev": "fd2c62853f66a5803dae621769570f1165e59b80",
"type": "github"
},
"original": {
"owner": "lukasl-dev",
"repo": "pi.nix",
"type": "github"
}
},
"pyproject-nix": { "pyproject-nix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -860,6 +948,7 @@
"nixpkgs-mine-stable": "nixpkgs-mine-stable", "nixpkgs-mine-stable": "nixpkgs-mine-stable",
"nixpkgs-stable": "nixpkgs-stable", "nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable", "nixpkgs-unstable": "nixpkgs-unstable",
"pi-agent": "pi-agent",
"ragenix": "ragenix", "ragenix": "ragenix",
"sharry": "sharry" "sharry": "sharry"
} }
@@ -957,6 +1046,21 @@
"type": "github" "type": "github"
} }
}, },
"systems_11": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": { "systems_2": {
"locked": { "locked": {
"lastModified": 1681028828, "lastModified": 1681028828,
@@ -1098,6 +1202,28 @@
"type": "github" "type": "github"
} }
}, },
"treefmt-nix_2": {
"inputs": {
"nixpkgs": [
"pi-agent",
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1775636079,
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
},
"utils": { "utils": {
"inputs": { "inputs": {
"systems": "systems_3" "systems": "systems_3"
+2
View File
@@ -49,6 +49,8 @@
copyparty.inputs.nixpkgs.follows = "nixpkgs-unstable"; copyparty.inputs.nixpkgs.follows = "nixpkgs-unstable";
hass-west-wood.url = "github:devplayer0/hass-west-wood"; hass-west-wood.url = "github:devplayer0/hass-west-wood";
hass-west-wood.inputs.nixpkgs.follows = "nixpkgs-unstable"; hass-west-wood.inputs.nixpkgs.follows = "nixpkgs-unstable";
pi-agent.url = "github:lukasl-dev/pi.nix";
pi-agent.inputs.nixpkgs.follows = "nixpkgs-unstable";
}; };
outputs = outputs =
+145
View File
@@ -0,0 +1,145 @@
# Home wireless APs
Reference for the home Wi-Fi access points. Like the switches (`home-switches.md`), these are **not**
managed by this flake — they are configured on-device (RouterOS on the MikroTik, OpenWrt/UCI on the
Cudy). This file documents the shared VLAN/trunk design and each AP.
Only the DNS records live in the flake (`nixos/boxes/home/routing-common/dns.nix`, `h.nul.ie` zone).
Everything else here is applied by hand on the device.
## The APs
| | 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) |
| 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) |
Both mirror the same two SSIDs. `wave` replaced an older AP of the same name; the new one is the
Cudy running OpenWrt.
> **Note on "AX3000":** MT7981B is 2×2 (2 spatial streams). The "3000" is *aggregate* Mbps —
> 574 (2.4 GHz 2ss) + 2402 (5 GHz 2ss @ **160 MHz**) — not three streams. `iw` confirms 2×2
> (`Available Antennas TX/RX 0x3`; "3 streams: not supported"). 160 MHz is what earns the "3000".
## Shared design (dumb AP)
Every AP is a **dumb AP**: it bridges wireless clients onto the right VLAN and does **no** routing,
DHCP, RA or firewalling. The home routers (`river`/`stream`) own DHCP/RA/gateway (per-VLAN VRRP VIPs)
and firewalling. The uplink is a **tagged trunk**:
| VLAN | `lib.my.c.home.vlans` | Role | AP use |
|---|---|---|---|
| — (native) | `core` | switch/fabric management (1500) | backup management (untagged) |
| 100 | `hi` | trusted LAN, **high-MTU** (jumbo 9000) | `vibe` management (it's jumbo-capable) |
| 110 | `lo` | trusted LAN (1500) | main SSID `wlan0`; `wave` management |
| 120 | `untrusted` | guest network | guest SSID `wlan1` |
`hi` and `lo` are both **trusted** client VLANs — the only difference is MTU (`hi` carries jumbo
9000, `lo` is standard 1500). An AP puts its own management on whichever it can do: `vibe` (jumbo)
sits on `hi`, `wave` (eth0 capped at 2026) sits on `lo`. The main SSID lands on `lo` because Wi-Fi
clients are 1500 regardless.
### SSIDs
| SSID | Bands | Security | VLAN |
|---|---|---|---|
| `wlan0` (main) | 5 GHz + 2.4 GHz | WPA2/WPA3-PSK (`sae-mixed`) | 110 (`lo`) |
| `wlan1` (guest) | 2.4 GHz | WPA2-PSK (`psk2`) | 120 (`untrusted`) |
**Passphrases are never stored in this repo.** `vibe` is the source of truth; read them out-of-band
with `ssh admin@vibe '/interface wifi export show-sensitive'` (`.passphrase=` prints **unquoted**).
## vibe (MikroTik cAP ax)
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.
- **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`
(WPA2/WPA3-PSK), untagged onto VLAN 110. `wifi3` is a virtual AP on `wifi2` broadcasting `wlan1`
(WPA2-PSK), untagged onto VLAN 120. `country=Ireland`.
- **Bridge VLANs** — 100 tagged `main,ether1`; 110 tagged `main,ether1` + untagged
`ether2,wifi1,wifi2`; 120 tagged `main,ether1` + untagged `wifi3`.
- **Management** — `jim`/`dave`-style (core/hi/lo), on host `.15`: `192.168.64.15` on core
(native/untagged, backup), `192.168.68.15/22` + `2a0e:97c0:4d0:1::1:6` on the `hi` VLAN-100
interface (holds the default route, via the hi VIP `192.168.71.254`), and
`192.168.72.15/21` + `2a0e:97c0:4d0:2::1:6` on `lo` VLAN 110. No IP on `untrusted`.
`l2mtu 9214`, so `hi` carries jumbo (9000) here — `vibe` sits on `hi` because it *can* jumbo,
unlike `wave` (see its MTU note).
- **Roaming** — 802.11k/v via a `/interface wifi steering` profile (`rrm=yes wnm=yes`,
`neighbor-group=home-aps`) assigned to `wifi1`/`wifi2`/`wifi3`.
- **Resolver** — the hi VIP `192.168.71.254` / `2a0e:97c0:4d0:1::ffff`.
## wave (Cudy AX3000, OpenWrt)
Single-port AP, so the port is a VLAN **trunk** carrying management + both SSIDs.
### Management addressing
`wave` takes host `.14`, on **`lo` (primary) and `core` (backup)** — deliberately **not** `hi`,
unlike the switches and `vibe`. `hi` is the jumbo (9000) VLAN, but `wave`'s eth0 caps at 2026 (see
MTU note), so there's no reason to put it there; `lo` is 1500 with a proper VRRP VIP for the default
route + resolver, and `core` has no VIP/v6 so it can only be a backup. No IP on `untrusted`. Records
in `dns.nix`:
| Name | VLAN | Address |
|---|---|---|
| `wave-core` | core (native/untagged) | `192.168.64.14/24` — backup, like the switches (no VIP → backup only) |
| `wave` | lo 110 | `192.168.72.14/21`, `2a0e:97c0:4d0:2::1:5` — primary; holds the default route + resolver (lo VIP `192.168.79.254` / `2a0e:97c0:4d0:2::ffff`) |
**Firewall:** management (SSH/LuCI) reachable from `core`/`lo` only; `untrusted` is a separate
zone with `input REJECT` (and `wave` has no IP there) — **no management via the guest VLAN**.
### brian switch port
`wave` hangs off **brian** (UniFi). Its port is a **trunk**: tagged VLAN **110/120** (`lo` + guest),
and **native/untagged = core** (the fabric's management VLAN, carrying `wave-core`). VLAN 100 (`hi`)
is **not** needed here — `wave` isn't on `hi` (see Management addressing). Configure via the UniFi
controller (brian has no CLI); see `home-switches.md`.
### Flashing OpenWrt (Cudy AX3000 / `cudy_ap3000-v1`)
Hardware: MT7981B, 512 MB RAM, 256 MB SPI-NAND, 1× 2.5 GbE (RTL8221B), 2×2 WiFi 6.
> ⚠️ **Serial caveat:** units with a serial starting `2543…` (post ~Nov 2025) use a different flash
> chip and can brick with older firmware. Match firmware to the unit.
OpenWrt can't be flashed directly over stock. Two-stage, via a Cudy **transition** firmware (Cudy
OpenWrt download page / `support@cudy.com`; `warnning.txt` in that bundle has the steps):
1. Stock Cudy UI: update to **≥ 2.4.7** (adds TFTP `recovery.bin` recovery), then flash the Cudy
**intermediate** firmware (`cudy_ap3000-v1-sysupgrade_*.bin`), "keep settings" **unchecked**.
It reboots into an OpenWrt-based build at `192.168.1.1` (SSH `root`, empty password).
2. From there, `sysupgrade -n` to vanilla OpenWrt (`…-cudy_ap3000-v1-squashfs-sysupgrade.bin` from
`downloads.openwrt.org`; this release ships **no** factory image — sysupgrade only).
Stock default (out of box) is a DHCP client falling back to **`192.168.10.254`**; the stock UI is a
customised LuCI (only 80/443, no SSH) with a first-boot "create admin password" wizard — so the
stock-side flashing is done from a browser, not headless.
### On-device config notes
- Package manager is **`apk`** (not `opkg`). WiFi runs **`wpad-mbedtls`** (full — swapped from the
default `wpad-basic-mbedtls`, which lacks 802.11v). **802.11k + 802.11v** (`ieee80211k` +
`bss_transition`) are enabled on all SSIDs. ⚠️ Swapping wpad **live** leaves the mac80211 vifs
stuck in a start→teardown loop (`nl80211 ... No such device`); a `wifi reload`/`network restart`
won't recover it — **reboot** after `apk add wpad-mbedtls`.
- Radios: `radio0` = 2.4 GHz, `radio1` = 5 GHz (keyed by `band`, don't assume). 5 GHz is pinned to
**channel 36 / HE160** (any 160 MHz block in IE is DFS; ch36 has the shortest ~60 s CAC).
- Bridge: `br-lan` with `vlan_filtering`, single port `eth0` — tagged `110/120`, untagged/PVID
VLAN 1 (= native/core). SSIDs attach via `network` = `lo`/`untrusted` (= `br-lan.110`/`.120`).
- Dumb-AP: no DHCP pools, `odhcpd.maindhcp=0`, `delegate=0` on the L3 interfaces.
- **MTU:** all interfaces are **1500**. The `mtk_eth_soc` 2.5 GbE (`eth0`) caps at **2026 bytes**
(`ip link set eth0 mtu 9000``SIOCSIFMTU: Invalid argument`), so `wave` can't join `hi`'s jumbo
(9000) fabric like `vibe` does — which is precisely **why `wave` is managed on `lo`, not `hi`**
(see Management addressing). Nothing on `wave` needs > 1500.
- **LuCI:** enabled, login `root` / `admin`. **SSH:** key-only (`PasswordAuth`/`RootPasswordAuth off`).
- `iperf3` installed for throughput testing.
### Access
- SSH: `ssh root@wave` (key-only; `wave`/`wave-core` resolve once `dns.nix` is deployed).
- LuCI: `http://192.168.72.14/` (or `http://wave/`), `root` / `admin`.
+5
View File
@@ -8,6 +8,8 @@ let
inherit (lib.my) mkOpt' dummyOption; inherit (lib.my) mkOpt' dummyOption;
in in
{ {
imports = [ inputs.pi-agent.homeModules.default ];
options = with lib.types; { options = with lib.types; {
my = { my = {
isStandalone = mkOption { isStandalone = mkOption {
@@ -27,6 +29,7 @@ in
}; };
}; };
}; };
config = mkMerge [ config = mkMerge [
{ {
my = { my = {
@@ -213,6 +216,7 @@ in
jq jq
yq-go yq-go
nix-tree nix-tree
treemd
]; ];
sessionVariables = { sessionVariables = {
@@ -229,6 +233,7 @@ in
inputs.deploy-rs.overlays.default inputs.deploy-rs.overlays.default
inputs.boardie.overlays.default inputs.boardie.overlays.default
inputs.nixGL.overlays.default inputs.nixGL.overlays.default
inputs.pi-agent.overlays.default
]; ];
config = { config = {
allowUnfree = true; allowUnfree = true;
+13
View File
@@ -71,8 +71,10 @@ in
python3Packages.python-lsp-server python3Packages.python-lsp-server
nil # nix language server nil # nix language server
nixd # another nix language server
zls # zig language server zls # zig language server
rust-analyzer rust-analyzer
pyright
cowsay cowsay
fortune fortune
@@ -85,6 +87,17 @@ in
ffmpeg-full ffmpeg-full
xournalpp xournalpp
(pkgs.symlinkJoin {
name = "pi-coding-agent";
buildInputs = [ pkgs.makeWrapper ];
paths = [ pkgs.pi-coding-agent-bun ];
postBuild = ''
wrapProgram $out/bin/pi \
--set NPM_CONFIG_PREFIX ${config.home.homeDirectory}/.pi/npm/ \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.nodejs_latest ]}
'';
})
]; ];
}; };
+208
View File
@@ -0,0 +1,208 @@
# 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.
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 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 7.18 | RouterOS 7.18 | UniFi |
| Ports | 24×1G + 2×SFP+ | 4×QSFP28 (100G, breakout-capable) + 1G mgmt | 8×10GBASE-T PoE + 2×25G SFP28 |
| Bridge | `main`, `vlan-filtering=yes` | `main`, `vlan-filtering=yes` | UniFi VLAN profiles |
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.
## Physical topology
The ONT terminates on brian; jim's `wan-pon-in` (`sfp-sfpplus2`) is a spare SFP+ port.
```
Virgin Media cable modem
│ VLAN 130 (wan1 / wan2 / wan-in)
┌───────────────┴──────────────────────────────────────┐
│ jim CRS326-24G-2S+ (Marvell 98DX3236) │
│ 1G edge: fort, pronter, laptop-dock, palace-kvm, │
│ ups, ether15-20, wan1/wan2/wan-in │
│ wan-pon-in = sfp-sfpplus2 (spare SFP+) │
└───────┬──────────────────────────────────────────────┘
│ dave-uplink = sfp-sfpplus1 (10G trunk)
│ also: palace, stream (1G secondaries),
│ castle (2.5G, normally down)
┌───────┴──────────────────────────────────────────────┐
│ dave CRS504-4XQ (Marvell 98DX4310) │
│ jim-downlink = qsfp28-3-1 │
└──┬──────────────────┬───────────────────┬────────────┘
│ palace │ castle │ brian-downlink
│ = qsfp28-1-1 │ (100G) │ 802.3ad LAG
│ (100G) │ │ (brian1 + brian2)
│ … │
┌───────┴────────┐ ┌───────┴─────────────┐
│ palace host │ │ brian │
│ └ river (VM) │ │ Switch Pro XG 8 PoE │
└────────────────┘ └───────┬─────────────┘
river WAN + LAN ride the 100G link │ hosts the ONT
┌───────┴─────────────┐
│ ONT (Digiweb) │ untagged mgmt
│ PPPoE via ONT │ 192.168.100.1
└─────────────────────┘ + VLAN 10; PVID 140
```
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.
- **stream** (the second router box) is dual-homed to both jim and dave (STP picks the active path).
- **castle** is dual-homed but **not** via STP: its primary uplink is dave's **100G** `castle` port
(`et100g`, active), and it has a secondary **2.5G** link to jim's `castle` edge port (`et2.5g`,
**normally down** — no live failover). ⚠️ **castle's root disk is NVMe-oF over the fabric** (via
`et100g`→dave), so rebooting **dave** — or downing castle's `et100g` — freezes castle mid-I/O.
Do dave maintenance (upgrades/reboots) from a host that doesn't depend on dave for storage or
network, or with castle cleanly powered off; don't drive it from castle.
- **brian** is a Ubiquiti **Switch Pro XG 8 PoE** (8×10GBASE-T), downlinked from dave over an
**802.3ad LAG** (`brian-downlink` = `brian1` + `brian2`, layer-2 hash). It hosts the ONT.
## VLANs
| VLAN | Name | Purpose |
|---|---|---|
| — (native) | core | Switch management, `192.168.64.0/24` (jim `.10`, dave `.11`, 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`) |
| 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 |
Switch L3 presence (`/interface vlan` on `main`) exists **only** for VLANs the switch is managed
from — `hi` (100) and `lo` (110), plus native core. WAN and guest VLANs deliberately have no switch
L3 interface.
## The Digiweb WAN path (trunked VLAN 10 + PVID 140)
The ONT presents two things on one wire:
- **untagged** management traffic (`192.168.100.x`), and
- **tagged VLAN 10** carrying the Digiweb ISP session (the BRAS requires VLAN 10).
With a **single ONT** there's no reason to translate anything — VLAN 10 is unique on the fabric, so
we just carry it end to end and let river run PPPoE directly on it:
1. **Untagged mgmt → VLAN 140, at the ONT's edge switch (brian).** brian sets the ONT port's PVID to
140 so the untagged management traffic becomes VLAN 140, and allows tagged VLAN 10 through the
same port. river takes `192.168.100.100/24` on VLAN 140 (matching stream's modem-mgmt `.100`) to
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).
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` for the river side.
```
ONT ──(untagged + VLAN10)── brian ──(VLAN140 + VLAN10)── dave ──(VLAN140 + VLAN10)── river
ONT port │ PVID140 + tagged 10 │ plain bridging
└─ brian-downlink LAG ── dave ┘
```
### Why not translation (for one ONT)?
Translation would swap VLAN 10 → 141 with two pinned hardware ACL rules to keep VLAN 10 off the rest
of the fabric. That buys nothing with a single ONT — VLAN 10 is already unique, so trunking it is
simpler and rule-free. Translation only earns its keep when **two** ONTs both deliver VLAN 10 and
would collide (below).
## Switch configuration
How each switch is set up for the Digiweb WAN path. **Confirm any change on the box before applying**
(see [Accessing the switches](#accessing-the-switches)).
**brian (UniFi)** — hosts the ONT:
- 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).
**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
```
VLAN 140 also spans `brian-downlink,palace` (it carries a few other members too). No switch rules —
this is plain tagged bridging.
**jim (RouterOS)** — carries **none** of the Digiweb WAN path: no translation rules, and no VLAN
10/140/141 rows. `wan-pon-in` (`sfp-sfpplus2`) sits at `pvid=1` as a spare port. jim only handles
stream's VLAN-130 WAN and the LAN VLANs.
## Future: multiple ONTs (per-port VLAN translation)
If a second ONT arrives (e.g. a Digiweb line for stream, or a second river), trunking breaks: both
ONTs deliver **tagged VLAN 10**, and plain bridge-VLAN filtering can't tell them apart. That's when
translation earns its place — a switch rule matches on the **ingress port**, so each ONT's VLAN 10
becomes a *distinct* fabric VLAN:
- ONT-A port: VLAN 10 → **141** (→ river)
- ONT-B port: VLAN 10 → **142** (→ stream / second river)
- mgmt: PVID each ONT port onto its own VLAN (140, 143, …) so both ONTs' `192.168.100.1` stay in
separate L2/L3 domains.
The forward direction isolates naturally (each ONT maps to a different fabric VLAN). The **return**
direction is where port targeting is mandatory: both translate *back* to VLAN 10, so bridge VLAN 10
now has two members and a plain FDB-miss flood would leak one ONT's upstream to the other. Each
return must be pinned to its port with `new-dst-ports`:
```
# ONT-A: 141 in on palace → 10, forced out ONT-A's port
# ONT-B: 142 in on stream → 10, forced out ONT-B's port
```
Each ONT port must also be a tagged member of bridge VLAN 10 for correct egress tagging (the missing
piece that otherwise shows up as pppd "Timeout waiting for PADO"). The pins bypass the FDB, so the
two ISP sessions never mix.
**Why a new switch:** jim (the only box with spare SFP+ *and* the translation feature) has just
**one** free SFP+ port, so it can't host two ONTs. The plan is a dedicated
**CRS305-1G-4S+** (4×SFP+, same Marvell rule support) to land multiple ONTs and do the per-port
translation there, feeding distinct fabric VLANs up to dave.
## Accessing the switches
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`: `jim` → hi `.10`, `dave` → hi
`.11`, `brian` → core `.13`). From a box on the home network just `ssh admin@jim` / `admin@dave`.
**Key auth** for `admin` is installed on jim/dave (and the `vibe` AP) — `ssh -i ~/.ssh/id_rsa
admin@jim` works keyless (imported via `/user ssh-keys import`). Password `admin`/`admin` remains as
a fallback. Non-interactive password pattern (avoids the ssh-agent hang) if the key isn't available:
```
sshpass -p admin ssh -o IdentityAgent=none -o PubkeyAuthentication=no \
-o PreferredAuthentications=password -o StrictHostKeyChecking=accept-new \
-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.
## 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) | — | — |
+14 -1
View File
@@ -312,6 +312,16 @@ rec {
lo = 110; lo = 110;
untrusted = 120; untrusted = 120;
wan = 130; wan = 130;
# Digiweb delivers the ISP VLAN (pon-isp, 10) single-tagged at the ONT alongside the ONT's
# own untagged management traffic. With a single ONT we trunk pon-isp (10) straight through
# the switches to river (PPPoE runs directly on it), and the switch at the ONT edge PVIDs the
# untagged management port onto wan-pon-ont (140). wan-pon-isp (141) is reserved for the
# future multi-ONT case, where per-port VLAN translation on a dedicated switch swaps each
# ONT's VLAN 10 to a distinct fabric VLAN (see home-switches.md).
pon-isp = 10;
wan-pon-ont = 140;
wan-pon-isp = 141;
}; };
hiMTU = 9000; hiMTU = 9000;
routers = [ routers = [
@@ -319,7 +329,7 @@ rec {
"stream" "stream"
]; ];
routersPubV4 = [ routersPubV4 = [
"109.255.108.88" "84.203.124.128" # river: Digiweb static
"109.255.108.121" "109.255.108.121"
]; ];
@@ -327,6 +337,9 @@ rec {
modem = { modem = {
v4 = "192.168.0.0/24"; v4 = "192.168.0.0/24";
}; };
ont = {
v4 = "192.168.100.0/24";
};
all = { all = {
v4 = "192.168.64.0/18"; v4 = "192.168.64.0/18";
v6 = "2a0e:97c0:4d0::/60"; v6 = "2a0e:97c0:4d0::/60";
+4 -3
View File
@@ -175,11 +175,11 @@ rec {
}; };
vm = rec { vm = rec {
lvmDisk' = name: lv: { lvmDisk'' = name: vg: lv: {
inherit name; inherit name;
backend = { backend = {
driver = "host_device"; driver = "host_device";
filename = "/dev/main/${lv}"; filename = "/dev/${vg}/${lv}";
# It appears this needs to be set on the backend _and_ the format # It appears this needs to be set on the backend _and_ the format
discard = "unmap"; discard = "unmap";
}; };
@@ -189,7 +189,8 @@ rec {
}; };
frontend = "virtio-blk"; frontend = "virtio-blk";
}; };
lvmDisk = lv: lvmDisk' lv lv; lvmDisk' = vg: lv: lvmDisk'' lv vg lv;
lvmDisk = lvmDisk' "main";
disk = vm: lv: lvmDisk' lv "vm-${vm}-${lv}"; disk = vm: lv: lvmDisk' lv "vm-${vm}-${lv}";
}; };
@@ -95,6 +95,7 @@ in
shell = pkgs.bashInteractive; shell = pkgs.bashInteractive;
openssh.authorizedKeys.keyFiles = [ openssh.authorizedKeys.keyFiles = [
lib.my.c.sshKeyFiles.harmonia lib.my.c.sshKeyFiles.harmonia
lib.my.c.sshKeyFiles.me
]; ];
}; };
}; };
+15 -3
View File
@@ -53,7 +53,7 @@ in
}; };
}; };
}); });
kernelModules = [ "kvm-amd" ]; kernelModules = [ "dm-raid" "kvm-amd" ];
kernelParams = [ "amd_iommu=on" ]; kernelParams = [ "amd_iommu=on" ];
initrd = { initrd = {
availableKernelModules = [ "xhci_pci" "ahci" "usb_storage" "usbhid" "sd_mod" "sr_mod" ]; availableKernelModules = [ "xhci_pci" "ahci" "usb_storage" "usbhid" "sd_mod" "sr_mod" ];
@@ -126,9 +126,9 @@ in
}; };
linkConfig.Name = "et1g0"; linkConfig.Name = "et1g0";
}; };
"10-lan-core" = { "10-lan-core-phy" = {
matchConfig.PermanentMACAddress = "e0:d5:5e:68:0c:70"; matchConfig.PermanentMACAddress = "e0:d5:5e:68:0c:70";
linkConfig.Name = "lan-core"; linkConfig.Name = "lan-core-phy";
}; };
"10-et100g" = { "10-et100g" = {
matchConfig = { matchConfig = {
@@ -145,6 +145,12 @@ in
netdevs = mkMerge [ netdevs = mkMerge [
(mkVLAN "lan-hi" vlans.hi) (mkVLAN "lan-hi" vlans.hi)
(mkVLAN "lan-lo-phy" vlans.lo) (mkVLAN "lan-lo-phy" vlans.lo)
{
"25-lan-core".netdevConfig = {
Name = "lan-core";
Kind = "bridge";
};
}
{ {
"25-lan-lo".netdevConfig = { "25-lan-lo".netdevConfig = {
Name = "lan-lo"; Name = "lan-lo";
@@ -199,6 +205,12 @@ in
}; };
"60-lan-hi" = networkdAssignment "lan-hi" assignments.hi; "60-lan-hi" = networkdAssignment "lan-hi" assignments.hi;
"50-lan-core-phy" = {
matchConfig.Name = "lan-core-phy";
networkConfig = {
Bridge = "lan-core";
} // networkd.noL3;
};
"50-lan-lo-phy" = { "50-lan-lo-phy" = {
matchConfig.Name = "lan-lo-phy"; matchConfig.Name = "lan-lo-phy";
networkConfig = { networkConfig = {
+16 -5
View File
@@ -172,12 +172,23 @@
}; };
memory = 32768; memory = 32768;
cleanShutdown.timeout = 120; cleanShutdown.timeout = 120;
networks.netboot = { networks = {
bridge = "lan-lo"; netboot = {
waitOnline = "carrier"; bridge = "lan-lo";
mac = "52:54:00:a5:7e:93"; waitOnline = "carrier";
extraOptions.bootindex = 1; mac = "52:54:00:a5:7e:93";
extraOptions.bootindex = 1;
};
core = {
bridge = "lan-core";
ifname = "vm-sfh-core";
waitOnline = "carrier";
mac = "52:54:00:72:67:51";
};
}; };
drives = [
(vm.lvmDisk' "hdds" "frigate")
];
hostDevices = { hostDevices = {
et100g0vf2 = { et100g0vf2 = {
index = 0; index = 0;
+114 -6
View File
@@ -8,9 +8,23 @@
configuration = { lib, modulesPath, pkgs, config, assignments, allAssignments, ... }: configuration = { lib, modulesPath, pkgs, config, assignments, allAssignments, ... }:
let let
inherit (lib.my) networkdAssignment mkVLAN; inherit (builtins) elemAt;
inherit (lib) mkForce mkMerge;
inherit (lib.my) net networkdAssignment mkVLAN;
inherit (lib.my.c) networkd; inherit (lib.my.c) networkd;
inherit (lib.my.c.home) vlans domain prefixes roceBootModules; 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 takes .100 in the ONT's /24 (matching stream's modem-mgmt .100) to reach its web
# UI at 192.168.100.1. (See home-switches.md for the switch side and the multi-ONT plan.)
ontV4 = net.cidr.host 100 prefixes.ont.v4;
# river is routing-common index 0; the Digiweb static IP we request via IPCP
pubV4 = elemAt routersPubV4 0;
in in
{ {
imports = [ imports = [
@@ -71,11 +85,75 @@
dmeventd.enable = true; dmeventd.enable = true;
}; };
fstrim.enable = true; fstrim.enable = true;
# TODO: re-enable once scheduling is tested
networkd-dispatcher.enable = mkForce false;
pppd = {
enable = true;
peers.digiweb = {
autostart = true;
enable = true;
# Password is shared across all Digiweb customers, so no need for a secret
config = ''
plugin pppoe.so wan-pon-isp
name "digiweb@nga.digiweb.ie"
password "digiweb"
# request our static IP as the local address in IPCP (local:remote, remote left open)
${pubV4}:
# no usepeerdns: we ignore Digiweb's resolvers and use the local recursive resolver
lcp-echo-interval 1
lcp-echo-failure 4
noauth
persist
maxfail 0
holdoff 5
mtu 1500
mru 1500
noaccomp
default-asyncmap
ifname wan
'';
};
};
};
# 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
# 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;
environment.etc = {
ppp-up = {
target = "ppp/ip-up";
mode = "0755";
text = ''
#!${pkgs.runtimeShell}
${pkgs.iproute2}/bin/ip route add default dev wan scope link metric 100
${config.systemd.package}/bin/systemctl --no-block start wan-online.target
'';
};
ppp-down = {
target = "ppp/ip-down";
mode = "0755";
text = ''
#!${pkgs.runtimeShell}
${config.systemd.package}/bin/systemctl --no-block stop wan-online.target
${pkgs.iproute2}/bin/ip route del default dev wan scope link metric 100
'';
};
}; };
systemd.network = { systemd.network = {
netdevs = mkMerge [
(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)
];
links = { links = {
"10-wan" = { "10-wan-old" = {
matchConfig = { matchConfig = {
# Matching against MAC address seems to break VLAN interfaces # Matching against MAC address seems to break VLAN interfaces
# (since they share the same MAC address) # (since they share the same MAC address)
@@ -83,7 +161,7 @@
PermanentMACAddress = "e0:d5:5e:68:0c:6e"; PermanentMACAddress = "e0:d5:5e:68:0c:6e";
}; };
linkConfig = { linkConfig = {
Name = "wan"; Name = "wan-old";
RxBufferSize = 4096; RxBufferSize = 4096;
TxBufferSize = 4096; TxBufferSize = 4096;
}; };
@@ -101,8 +179,38 @@
}; };
}; };
# So we don't drop the IP we use to connect to NVMe-oF! networks = {
networks."60-lan-hi".networkConfig.KeepConfiguration = "static"; "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
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" = {
matchConfig.Name = "wan-pon-ont";
address = [ "${ontV4}/24" ];
linkConfig = {
RequiredForOnline = "no";
MTUBytes = "1500";
};
};
# pppd attaches PPPoE to this; just needs to be up with no L3. This is the ISP's
# VLAN 10 trunked straight through from the ONT (no switch translation; see "55-lan").
"71-wan-pon-isp" = {
matchConfig.Name = "wan-pon-isp";
linkConfig = {
RequiredForOnline = "no";
# baby jumbo: PPPoE's 8B overhead leaves a clean 1500 on ppp
MTUBytes = "1508";
};
networkConfig = networkd.noL3;
};
};
}; };
my = { my = {
@@ -1,6 +1,6 @@
{ {
imports = [ imports = [
# ./unifi.nix ./unifi.nix
./hass.nix ./hass.nix
]; ];
} }
@@ -24,6 +24,15 @@ in
address = net.cidr.host (65536*5+1) prefixes.hi.v6; address = net.cidr.host (65536*5+1) prefixes.hi.v6;
}; };
}; };
core = {
inherit domain;
name = "unifi-ctr-core";
mtu = 1500;
ipv4 = {
address = net.cidr.host 21 prefixes.core.v4;
gateway = null;
};
};
}; };
configuration = { lib, config, pkgs, assignments, ... }: configuration = { lib, config, pkgs, assignments, ... }:
@@ -48,7 +57,10 @@ in
}; };
systemd = { systemd = {
network.networks."80-container-host0" = networkdAssignment "host0" assignments.hi; network.networks = {
"80-container-host0" = networkdAssignment "host0" assignments.hi;
"80-lan-core" = networkdAssignment "lan-core" assignments.core;
};
}; };
services = { services = {
+26 -1
View File
@@ -72,6 +72,10 @@ in
fsType = "ext4"; fsType = "ext4";
neededForBoot = true; neededForBoot = true;
}; };
"/mnt/frigate" = {
device = "/dev/disk/by-label/frigate";
fsType = "ext4";
};
}; };
networking = { inherit domain; }; networking = { inherit domain; };
@@ -111,6 +115,13 @@ in
MTUBytes = toString lib.my.c.home.hiMTU; MTUBytes = toString lib.my.c.home.hiMTU;
}; };
}; };
"10-lan-core-ctrs" = {
matchConfig = {
Driver = "virtio_net";
PermanentMACAddress = "52:54:00:72:67:51";
};
linkConfig.Name = "lan-core-ctrs";
};
"10-lan-lo-ctrs" = { "10-lan-lo-ctrs" = {
matchConfig = { matchConfig = {
Driver = "virtio_net"; Driver = "virtio_net";
@@ -131,6 +142,11 @@ in
linkConfig.RequiredForOnline = "no"; linkConfig.RequiredForOnline = "no";
networkConfig = networkd.noL3; networkConfig = networkd.noL3;
}; };
"30-lan-core-ctrs" = {
matchConfig.Name = "lan-core-ctrs";
linkConfig.RequiredForOnline = "no";
networkConfig = networkd.noL3;
};
"30-lan-lo-ctrs" = { "30-lan-lo-ctrs" = {
matchConfig.Name = "lan-lo-ctrs"; matchConfig.Name = "lan-lo-ctrs";
linkConfig.RequiredForOnline = "no"; linkConfig.RequiredForOnline = "no";
@@ -145,6 +161,11 @@ in
MACVLAN = mkForce "lan-hi-ctrs:host0 lan-lo-ctrs:lan-lo"; MACVLAN = mkForce "lan-hi-ctrs:host0 lan-lo-ctrs:lan-lo";
}; };
}; };
unifi = {
networkConfig = {
MACVLAN = mkForce "lan-hi-ctrs:host0 lan-core-ctrs:lan-core";
};
};
}; };
systemd.services = { systemd.services = {
@@ -174,9 +195,13 @@ in
containers.instances = containers.instances =
let let
instances = { instances = {
# unifi = {}; unifi = {};
hass = { hass = {
bindMounts = { bindMounts = {
"/mnt/frigate" = {
mountPoint = "/var/lib/frigate";
readOnly = false;
};
"/dev/bus/usb/001/002".readOnly = false; "/dev/bus/usb/001/002".readOnly = false;
"/dev/video0".readOnly = false; "/dev/video0".readOnly = false;
"/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_ce549704fe38ef11a2c2e5d154516304-if00-port0" = { "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_ce549704fe38ef11a2c2e5d154516304-if00-port0" = {
+37 -54
View File
@@ -11,12 +11,6 @@ in
{ {
nixos.systems."${name}" = { nixos.systems."${name}" = {
assignments = { assignments = {
modem = {
ipv4 = {
address = net.cidr.host (254 - index) prefixes.modem.v4;
gateway = null;
};
};
core = { core = {
name = "${name}-core"; name = "${name}-core";
inherit domain; inherit domain;
@@ -100,9 +94,11 @@ in
configuration = { lib, pkgs, config, assignments, allAssignments, ... }: configuration = { lib, pkgs, config, assignments, allAssignments, ... }:
let let
inherit (lib) mkIf mkMerge mkForce; inherit (lib) mkIf mkMerge mkForce optionalString concatStringsSep;
inherit (lib.my) networkdAssignment; inherit (lib.my) mkOpt' networkdAssignment;
inherit (lib.my.c) networkd; inherit (lib.my.c) networkd;
cfg = config.my.homeRouter;
in in
{ {
imports = map (m: import m index) [ imports = map (m: import m index) [
@@ -112,6 +108,20 @@ in
./kea.nix ./kea.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
# box sets so routing-common itself carries no modem/ONT knowledge.
options.my.homeRouter = with lib.types; {
dns.wanSkipBroadcasts = mkOpt' (listOf str) [ ] ''
Broadcast addresses to exclude when auto-selecting the router's own `wan` A record,
for extra static subnets that share the `wan` interface.
'';
firewall.untrustedRejectV4 = mkOpt' (listOf str) [ ] ''
IPv4 prefixes untrusted clients must be explicitly rejected from reaching. Only needed
for subnets sharing the `wan` interface, since `wan` egress is otherwise accepted.
'';
};
config = { config = {
environment = { environment = {
systemPackages = with pkgs; [ systemPackages = with pkgs; [
@@ -152,26 +162,34 @@ in
networking = { inherit domain; }; networking = { inherit domain; };
systemd.services = # Uniform "WAN is up" gate. Consumers attach to this target (via wantedBy +
let # partOf) rather than depending on it, so it is never pulled in / prematurely
waitOnline = "systemd-networkd-wait-online@wan.service"; # activated. Each box wires up how the target actually gets reached: stream
in # gates it on networkd's wait-online, river drives it from the pppd hooks.
{ systemd.targets.wan-online.description = "WAN is online";
systemd.services = {
ipsec = { ipsec = {
after = [ waitOnline ]; after = [ "wan-online.target" ];
requires = [ waitOnline ]; # strongswan/libreswan force wantedBy=multi-user.target; drop it so the
# target is a true gate rather than mere ordering. This matters most on
# river, where the target is hook-driven and not in the boot transaction,
# so plain ordering wouldn't hold ipsec back at all. partOf re-loads ipsec
# (re-orienting its connections) whenever the WAN drops and returns.
wantedBy = mkForce [ "wan-online.target" ];
partOf = [ "wan-online.target" ];
}; };
ipv6-clear-default-route = { ipv6-clear-default-route = {
description = "Clear IPv6 RA default route"; description = "Clear IPv6 RA default route";
after = [ waitOnline ]; after = [ "wan-online.target" ];
requires = [ waitOnline ]; wantedBy = [ "wan-online.target" ];
partOf = [ "wan-online.target" ];
script = '' script = ''
# Seems like we can sometimes pick up a default route somehow... # Seems like we can sometimes pick up a default route somehow...
${pkgs.iproute2}/bin/ip -6 route del default via fe80::1 || true ${pkgs.iproute2}/bin/ip -6 route del default via fe80::1 || true
''; '';
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
}; };
}; };
@@ -220,41 +238,6 @@ in
in in
mkMerge [ mkMerge [
{ {
"50-wan-ifb" = {
matchConfig.Name = "wan-ifb";
networkConfig = networkd.noL3;
extraConfig = ''
[CAKE]
Bandwidth=490M
RTTSec=50ms
PriorityQueueingPreset=besteffort
# DOCSIS preset
OverheadBytes=18
MPUBytes=64
CompensationMode=none
'';
};
"50-wan" = mkMerge [
(networkdAssignment "wan" assignments.modem)
{
matchConfig.Name = "wan";
DHCP = "ipv4";
dns = [ "127.0.0.1" "::1" ];
dhcpV4Config.UseDNS = false;
qdiscConfig = {
Parent = "ingress";
Handle = "0xffff";
};
extraConfig = ''
[CAKE]
Parent=root
Bandwidth=48M
RTTSec=50ms
'';
}
];
"55-lan" = { "55-lan" = {
matchConfig.Name = "lan"; matchConfig.Name = "lan";
vlan = [ "lan-hi" "lan-lo" "lan-untrusted" "wan-tunnel" ]; vlan = [ "lan-hi" "lan-lo" "lan-untrusted" "wan-tunnel" ];
@@ -365,7 +348,7 @@ in
return return
} }
chain filter-untrusted { chain filter-untrusted {
ip daddr ${prefixes.modem.v4} reject ${optionalString (cfg.firewall.untrustedRejectV4 != [ ]) "ip daddr { ${concatStringsSep ", " cfg.firewall.untrustedRejectV4} } reject"}
oifname wan accept oifname wan accept
return return
} }
+28 -6
View File
@@ -13,6 +13,13 @@ let
in in
{ {
config = { config = {
# Let pdns-recursor bind the VRRP VIPs even on the backup, where the addresses
# aren't present locally
boot.kernel.sysctl = {
"net.ipv4.ip_nonlocal_bind" = 1;
"net.ipv6.ip_nonlocal_bind" = 1;
};
my = { my = {
secrets.files = { secrets.files = {
"home/pdns/auth.conf" = { "home/pdns/auth.conf" = {
@@ -40,6 +47,10 @@ in
"127.0.0.1" "::1" "127.0.0.1" "::1"
assignments.hi.ipv4.address assignments.hi.ipv6.address assignments.hi.ipv4.address assignments.hi.ipv6.address
assignments.lo.ipv4.address assignments.lo.ipv6.address assignments.lo.ipv4.address assignments.lo.ipv6.address
# VRRP VIPs: DNS follows the master, so clients only ever have one
# (always-live) resolver address and never hang on a dead router
vips.hi.v4 vips.hi.v6
vips.lo.v4 vips.lo.v6
]; ];
allow_from = [ allow_from = [
"127.0.0.0/8" "::1/128" "127.0.0.0/8" "::1/128"
@@ -53,7 +64,13 @@ in
}; };
outgoing = { outgoing = {
source_address = [ "0.0.0.0" "::" ]; # Query authoritative servers over IPv4 only. Our IPv6 default route runs over the
# as211024 mesh (`ip -6 route show default`), a proto-static route that isn't
# withdrawn when the mesh flaps (e.g. during ipsec churn) — it just blackholes. With
# "::" here the recursor keeps picking IPv6 to reach NS, stalls on timeouts, and
# takes recursion down with it. IPv4 upstream goes out the WAN directly and stays up;
# we still serve AAAA records regardless of transport.
source_address = [ "0.0.0.0" ];
}; };
recursor = { recursor = {
@@ -189,7 +206,7 @@ in
${name} IN LUA ${lib.my.dns.ifaceA { ${name} IN LUA ${lib.my.dns.ifaceA {
inherit pkgs; inherit pkgs;
iface = "wan"; iface = "wan";
skipBroadcasts = [ (lib.my.netBroadcast prefixes.modem.v4) ]; skipBroadcasts = config.my.homeRouter.dns.wanSkipBroadcasts;
}} }}
${otherName} IN LUA ${lib.my.dns.lookupIP { ${otherName} IN LUA ${lib.my.dns.lookupIP {
inherit pkgs; inherit pkgs;
@@ -223,11 +240,16 @@ in
dave-lo IN AAAA ${net.cidr.host (65536+2) prefixes.lo.v6} dave-lo IN AAAA ${net.cidr.host (65536+2) prefixes.lo.v6}
shytzel IN A ${net.cidr.host 12 prefixes.core.v4} shytzel IN A ${net.cidr.host 12 prefixes.core.v4}
brian IN A ${net.cidr.host 13 prefixes.core.v4}
wave IN A ${net.cidr.host 12 prefixes.hi.v4} vibe-core IN A ${net.cidr.host 15 prefixes.core.v4}
; wave IN AAAA ${net.cidr.host (65536+3) prefixes.hi.v6} vibe IN A ${net.cidr.host 15 prefixes.hi.v4}
vibe IN A ${net.cidr.host 13 prefixes.hi.v4} vibe IN AAAA ${net.cidr.host (65536+6) prefixes.hi.v6}
vibe IN AAAA ${net.cidr.host (65536+4) prefixes.hi.v6} vibe-lo IN A ${net.cidr.host 15 prefixes.lo.v4}
vibe-lo IN AAAA ${net.cidr.host (65536+6) prefixes.lo.v6}
wave-core IN A ${net.cidr.host 14 prefixes.core.v4}
wave IN A ${net.cidr.host 14 prefixes.lo.v4}
wave IN AAAA ${net.cidr.host (65536+5) prefixes.lo.v6}
ups IN A ${net.cidr.host 20 prefixes.lo.v4} ups IN A ${net.cidr.host 20 prefixes.lo.v4}
palace-kvm IN A ${net.cidr.host 21 prefixes.lo.v4} palace-kvm IN A ${net.cidr.host 21 prefixes.lo.v4}
@@ -33,7 +33,7 @@ def main():
print(f'Updating {args.record} -> {address}') print(f'Updating {args.record} -> {address}')
cf.dns.records.edit( cf.dns.records.edit(
zone_id=zone.id, dns_record_id=record.id, name=args.record, zone_id=zone.id, dns_record_id=record.id, name=args.record, ttl=60,
type='A', content=address) type='A', content=address)
if __name__ == '__main__': if __name__ == '__main__':
+4 -2
View File
@@ -83,7 +83,8 @@ in
} }
{ {
name = "domain-name-servers"; name = "domain-name-servers";
data = "${net.cidr.host 1 prefixes.hi.v4}, ${net.cidr.host 2 prefixes.hi.v4}"; # VRRP VIP so DNS follows the master and clients never hit a dead router
data = vips.hi.v4;
} }
{ {
name = "interface-mtu"; name = "interface-mtu";
@@ -116,7 +117,8 @@ in
} }
{ {
name = "domain-name-servers"; name = "domain-name-servers";
data = "${net.cidr.host 1 prefixes.lo.v4}, ${net.cidr.host 2 prefixes.lo.v4}"; # VRRP VIP so DNS follows the master and clients never hit a dead router
data = vips.lo.v4;
} }
]; ];
pools = [ pools = [
+5 -2
View File
@@ -3,11 +3,14 @@ let
# TODO: Move into nixpkgs # TODO: Move into nixpkgs
mstpd = pkgs.mstpd.overrideAttrs { mstpd = pkgs.mstpd.overrideAttrs {
patches = [ ./mstpd.patch ]; patches = [ ./mstpd.patch ];
# Delete postInstall since it nukes the bridge-stp script we need
postInstall = "";
}; };
in in
{ {
environment = { environment = {
systemPackages = [ systemPackages = [
# For kernel to call bridge-stp (see ./pkgs/os-specific/linux/kernel/bridge-stp-helper.patch)
mstpd mstpd
]; ];
etc = { etc = {
@@ -39,8 +42,8 @@ in
before = [ "network-pre.target" ]; before = [ "network-pre.target" ];
serviceConfig = { serviceConfig = {
Type = "forking"; Type = "forking";
ExecStart = "${mstpd}/sbin/bridge-stp restart"; ExecStart = "${mstpd}/bin/bridge-stp restart";
ExecReload = "${mstpd}/sbin/bridge-stp restart_config"; ExecReload = "${mstpd}/bin/bridge-stp restart_config";
PIDFile = "/run/mstpd.pid"; PIDFile = "/run/mstpd.pid";
Restart = "always"; Restart = "always";
PrivateTmp = true; PrivateTmp = true;
+9 -2
View File
@@ -2,7 +2,14 @@ index: { lib, pkgs, ... }:
let let
inherit (lib) mkForce concatMapStringsSep; inherit (lib) mkForce concatMapStringsSep;
inherit (lib.my) net; inherit (lib.my) net;
inherit (lib.my.c.home) domain prefixes; inherit (lib.my.c.home) domain prefixes vips;
# untrusted uses external (Cloudflare) resolvers, matching the v4 kea config;
# trusted VLANs use the internal recursor via its floating VRRP VIP
rdnss = name:
if name == "untrusted"
then "2606:4700:4700::1111 2606:4700:4700::1001"
else vips."${name}".v6;
mkInterface = name: '' mkInterface = name: ''
interface lan-${name} { interface lan-${name} {
@@ -10,7 +17,7 @@ let
AdvRASrcAddress { fe80::1; }; AdvRASrcAddress { fe80::1; };
AdvLinkMTU ${toString prefixes."${name}".mtu}; AdvLinkMTU ${toString prefixes."${name}".mtu};
prefix ${prefixes."${name}".v6} {}; prefix ${prefixes."${name}".v6} {};
RDNSS ${net.cidr.host 1 prefixes."${name}".v6} ${net.cidr.host 2 prefixes."${name}".v6} {}; RDNSS ${rdnss name} {};
DNSSL ${domain} dyn.${domain} ${lib.my.c.colony.domain} ${lib.my.c.britway.domain} {}; DNSSL ${domain} dyn.${domain} ${lib.my.c.colony.domain} ${lib.my.c.britway.domain} {};
}; };
''; '';
+77 -1
View File
@@ -8,7 +8,14 @@
configuration = { lib, pkgs, config, ... }: configuration = { lib, pkgs, config, ... }:
let let
inherit (lib); inherit (lib) mkMerge;
inherit (lib.my) net;
inherit (lib.my.c) networkd;
inherit (lib.my.c.home) prefixes;
# Static address on the Virgin Media modem's management subnet. Kept as a plain interface
# address (not a network assignment) since it's local to this box's WAN uplink.
modemV4 = net.cidr.host 100 prefixes.modem.v4;
in in
{ {
imports = [ ./routing-common/mstpd.nix ]; imports = [ ./routing-common/mstpd.nix ];
@@ -75,6 +82,32 @@
}; };
}; };
# wan carries a permanent static modem-management address (modemV4)
# alongside the DHCP public IP, so wait-online@wan reports "online" as soon as
# the static address is up - before the DHCP lease arrives. ipsec's left= is the
# public IP, so gating on wait-online lets it start unoriented and never connect.
# Gate instead on the DHCP default route, which only exists once the public lease
# is up (the static modem address has no gateway).
systemd.services.wan-wait-online = {
description = "Wait for the wan default route (public DHCP lease)";
after = [ "systemd-networkd.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
TimeoutStartSec = "300";
};
script = ''
until [ -n "$(${pkgs.iproute2}/bin/ip -4 route show default dev wan)" ]; do
sleep 1
done
'';
};
systemd.targets.wan-online = {
requires = [ "wan-wait-online.service" ];
after = [ "wan-wait-online.service" ];
wantedBy = [ "multi-user.target" ];
};
systemd.network = { systemd.network = {
netdevs = { netdevs = {
"25-lan" = { "25-lan" = {
@@ -147,6 +180,43 @@
matchConfig.Name = "lan-dave"; matchConfig.Name = "lan-dave";
networkConfig.Bridge = "lan"; networkConfig.Bridge = "lan";
}; };
"50-wan-ifb" = {
matchConfig.Name = "wan-ifb";
networkConfig = networkd.noL3;
extraConfig = ''
[CAKE]
Bandwidth=490M
RTTSec=50ms
PriorityQueueingPreset=besteffort
# DOCSIS preset
OverheadBytes=18
MPUBytes=64
CompensationMode=none
'';
};
"50-wan" = {
matchConfig.Name = "wan";
# Static modem-management address alongside the DHCP public lease. It has no
# gateway, so the wan-wait-online gate keys off the DHCP default route instead.
address = [ "${modemV4}/24" ];
DHCP = "ipv4";
dns = [ "127.0.0.1" "::1" ];
dhcpV4Config.UseDNS = false;
# IPv4-only WAN (public IPv6 arrives over the tunnel, not this link).
networkConfig.IPv6AcceptRA = false;
qdiscConfig = {
Parent = "ingress";
Handle = "0xffff";
};
extraConfig = ''
[CAKE]
Parent=root
Bandwidth=48M
RTTSec=50ms
'';
};
}; };
}; };
@@ -155,6 +225,12 @@
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPYTB4zeAqotrEJ8M+AiGm/s9PFsWlAodz3hYSROGuDb"; key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPYTB4zeAqotrEJ8M+AiGm/s9PFsWlAodz3hYSROGuDb";
}; };
server.enable = true; server.enable = true;
# The modem's management subnet shares the `wan` interface: skip its address when
# picking our own wan A record, and reject untrusted clients from reaching it.
homeRouter = {
dns.wanSkipBroadcasts = [ (lib.my.netBroadcast prefixes.modem.v4) ];
firewall.untrustedRejectV4 = [ prefixes.modem.v4 ];
};
# deploy.node.hostname = "192.168.68.2"; # deploy.node.hostname = "192.168.68.2";
}; };
}; };
+1
View File
@@ -83,6 +83,7 @@ in
inputs.boardie.overlays.default inputs.boardie.overlays.default
inputs.copyparty.overlays.default inputs.copyparty.overlays.default
inputs.hass-west-wood.overlays.default inputs.hass-west-wood.overlays.default
inputs.pi-agent.overlays.default
]; ];
config = { config = {
allowUnfree = true; allowUnfree = true;
+1 -1
View File
@@ -8,7 +8,7 @@ let
tftpRoot = pkgs.linkFarm "tftp-root" [ tftpRoot = pkgs.linkFarm "tftp-root" [
{ {
name = "ipxe-x86_64.efi"; name = "ipxe-x86_64.efi";
path = "${pkgs.ipxe}/ipxe.efi"; path = "${pkgs.ipxe}/snp.efi";
} }
]; ];
menuFile = pkgs.runCommand "menu.ipxe" { menuFile = pkgs.runCommand "menu.ipxe" {