Add token expiry, edit notice, log allowlist, local-zone test; merge PLAN.md into SPECS.md

get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd,
nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30
(default) or 90 days, or without limit. The overview and the Changes page
say which token made the unapplied changes.

options.json (router://options) no longer holds the options' source paths:
they differ between builds of a release, so a router rebuilding itself
offline had to build the file and failed (found by the update test).

Web UI: panels flow into columns on wide screens and the width is no longer
capped; on phones the navigation is a menu button; the Advanced page uses
panels; VLAN dropdowns show their chosen names; the duplicate WireGuard
heading is gone.

basic: a subtest for wan -> local rules.

SPECS.md is now the only document (decisions, how each part works, and what
is not done yet); PLAN.md is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-01 19:06:27 +01:00
co-authored by Claude Sonnet 5.5
parent 586274af4e
commit b1fa7b1396
25 changed files with 536 additions and 294 deletions
-244
View File
@@ -1,244 +0,0 @@
# nixos-router — Plan
Working name: `nixos-router`. A NixOS-based distribution for routers/firewalls, delivered as a flake exporting NixOS modules and bootable images (not a nixpkgs fork).
## Decisions
| Area | Decision |
|---|---|
| Audience | Own homelab first; keep structure clean enough to publish later. |
| Config model | Opinionated `router.*` option schema compiled to systemd-networkd + nftables, with build-time assertions. |
| Hardware | x86_64 UEFI first. aarch64 is a planned target; avoid x86-only assumptions. |
| v1 features | IPv6 + prefix delegation, VLANs / multiple LANs, WireGuard. |
| Deferred | Multi-WAN failover, QoS (CAKE/SQM), web UI. |
| Updates (v1) | Push from workstation (`nixos-rebuild --target-host` / colmena) + commit-confirm rollback timer. |
| Updates (later) | **A/B image updates** with automatic rollback. |
| Footprint | Minimal closure, read-only root, no Nix daemon/compiler on device, small persistent `/var`. |
## Architecture
1. **`router.*` schema** — users describe intent (wan, lan, vlans, zones, port forwards, wireguard); modules compile it to:
- systemd-networkd (DHCPv4/v6, DHCPv6-PD, VLANs, bridges, PPPoE later)
- nftables zone-based ruleset (generated, not `networking.firewall`)
- DHCP / DNS / RA services
2. **Build-time validation** — Nix assertions reject lock-out or inconsistent configs (no management access rule, overlapping subnets, zone referencing missing interface); generated ruleset checked with `nft -c`.
3. **Images** — x86_64 first (installer ISO + raw disk), aarch64 later. Read-only root (erofs/squashfs store), small persistent `/var`.
4. **Deployment** — deploy a prebuilt closure (`nix copy` + `switch-to-configuration`); never evaluate or build on the router. This keeps the device light and leads naturally to A/B updates.
## Implications
- No on-device evaluation: all designs must work from a prebuilt closure.
- Flake exposes `x86_64-linux` and `aarch64-linux` outputs from the start; hardware specifics (bootloader, kernel, NIC naming) live in separate `boards/` modules.
- IPv6 PD fan-out to multiple VLANs needs stable per-LAN subnet-ID allocation; this is the hardest part of the schema.
## Default zone policy (confirmed)
- WAN: deny inbound (except established/related, required ICMPv6, explicit port forwards).
- LAN: allow outbound to WAN.
- Other zones (e.g. IoT): isolated from each other and from LAN unless a rule opens them.
## Management access (confirmed)
- Key-only SSH, reachable from LAN zones (trusted, non-WAN, non-isolated zones). No GUI in v1.
- Build-time assertion fails if no management path exists.
- Isolated zones (e.g. IoT) get no router access beyond DHCP/DNS/NDP.
## Services (confirmed)
- DHCP: Kea (DHCPv4 + DHCPv6 incl. PD).
- DNS: Unbound.
- RA: systemd-networkd (or radvd if networkd falls short).
## Repo layout (confirmed)
```
flake.nix
modules/ # router.* schema and service modules
boards/ # hardware-specific modules (x86_64 first, aarch64 later)
tests/ # nixosTest VM tests
images/ # image/ISO builds
examples/ # sample configs
```
## Status
**WireGuard done**: `wireguard` VM test (5 VMs: ISP/provider, router, laptop, remote site, LAN host) covers handshakes, road-warrior DNS/SSH over IPv4 and IPv6, zone rules between tunnels and LAN, site-to-site routing without NAT or transfer net, provider tunnel with masquerade and IPv6 transfer net, and the kill switch (kill switch and masquerade mutation-tested). Not covered: preshared keys, `mtu`, tunnels with several peers, key rotation.
**VLANs + zones done**: `vlans` VM test (5 VMs: ISP, router, untagged `main`, VLAN 20 `iot`, VLAN 30 `cams`) covers VLAN devices, per-VLAN DHCP/gateway, opt-in internet, DNS vs SSH per LAN, intra-zone forwarding across VLANs, one-directional inter-zone rules and the destination restriction (each mutation-tested). Not covered yet: IPv6 on VLAN LANs (only the PD LAN has IPv6), port forwards from the WAN.
**PPPoE done**: `pppoe` VM test = the same topology with a fake ISP running `pppoe-server` (VLAN 7, CHAP, password containing a quote) — covers session, NAT, MTU, MSS clamp (mutation-tested) and WAN-deny. `basic` remains the DHCPv4 variant.
**IPv6 + prefix delegation done (single /64)**: `router.wan.ipv6.enable` and `router.lans.<name>.ipv6.pd` (at most one LAN, asserted). The VM test now covers the router's DHCPv6 address and delegated /64, SLAAC on the client, routing without NAT66, and WAN-deny over IPv6 (mutation-tested). Still open: RDNSS, other LANs are IPv4-only, DHCPv6 reservations.
**Phase 1 (MVP) skeleton done**: flake, `router.*` schema (wan, lans, dns.forwarders), networkd, nftables (zone-less for now: WAN/LAN), Kea DHCPv4, Unbound, assertions. `nix build .#checks.x86_64-linux.basic` runs a 3-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP+DNS, NAT, WAN-deny and LAN SSH.
Interactive lab: `nix run .#lab` (x86_64) opens tmux with one console pane per VM plus the test-driver REPL (`router.shell_interact()`, `client.succeed(...)`, `test_script()`). The REPL pane also shows all VM output interleaved.
Learned along the way:
- The default policy now explicitly answers ICMP echo on LANs only; the WAN gets only ICMP error types and ND.
- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check.
- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module).
- `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do.
- Checks: `basic`, `pppoe`, `onestick`, `rollback`, `dns`, `vlans` and `wireguard` (VM tests), `example` (the example system builds), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: NPTv6.
## IPv6 design (confirmed)
- **WAN**: mirrors the existing router's uplink config — networkd `DHCP` (v6), `IPv6AcceptRA = true`, `KeepConfiguration = yes`, `dhcpV6Config = { WithoutRA = "solicit"; UseDelegatedPrefix = true; }`. The ISP sends RAs (default route) without DHCPv6 flags, so the router solicits on its own.
- **Delegation**: the ISP delegates a single **/64**, which cannot be split. Exactly one LAN may claim it via an explicit flag (`router.lans.<name>.ipv6.pd = true`); an assertion rejects more than one.
- **LANs**: SLAAC only (RA, no DHCPv6). All other LANs/VLANs are **IPv4-only** for now (ULA or NPTv6 can come later).
- **Firewall**: inet table covers v6; WAN→LAN forwarding stays denied, no NAT66.
- **Test**: fake ISP on `upstream` = Kea DHCPv6 (IA_NA + IA_PD /64 from a /48 pool) + networkd RA sender (default route only) + a static route for the delegated prefix back to the router.
- **Known gap**: no RDNSS in RAs yet (the router's global LAN address is not known at eval time); clients still get DNS via DHCPv4.
## PPPoE uplink (confirmed)
- **IPv4 via PPPoE** (`router.wan.ipv4.type = "pppoe"`), carried on an optional tagged **VLAN** on the WAN link (`pppoe.vlan`); native DHCPv6/PD stays on the untagged link.
- `wan.interface` is the physical link; the derived `wan.ipv4.interface` (`pppoe-wan` or the link) is where NAT, MSS clamping and the IPv4 forwarding rule apply. IPv6 forwarding keeps using the physical link.
- pppd (`pppoe.so`) runs as `router-pppoe.service`, ordered after the VLAN device. Credentials: `username` inline, password from `pppoe.passwordFile` (type forbids Nix-store paths); the options file is generated into a root-only runtime dir. **Deployment must provide that file** on persistent storage (or via agenix/sops).
- **MTU 1492 + TCP MSS clamp** (1452) in both directions on the PPP device; RFC 4638 not implemented.
## VLANs and zones (confirmed)
- **VLAN devices** are created by the router: `router.lans.<name> = { interface = <trunk>; vlan = <id>; }` makes `<trunk>.<id>` via networkd. A trunk may also carry one untagged LAN. Assertions: duplicate device, device name > 15 chars, WAN interface reused as trunk.
- **Zones** are an attribute on the LAN (`zone`, default = the LAN's own name); LANs sharing a zone may forward to each other, traffic **between zones is denied** unless a rule in `router.firewall.rules` allows it (`from`, `to`, `protocol`, `ports`, optional IPv4 `destination` that must lie inside a LAN of the destination zone). Rules are one-directional; return traffic via conntrack. Assertions reject unknown zones, from == to, ports without tcp/udp, bad/out-of-zone/host-bit destinations.
- **Internet is opt-in per LAN** (`internet`, default false) — this replaces the earlier "LAN allow-out" default; any LAN that should reach the WAN must set it.
- Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`.
- Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases).
## MCP server (confirmed)
- **Endpoint**: `POST /mcp` on the web UI (MCP Streamable HTTP, JSON-RPC 2.0, single request/response; no streaming). It is served on the same LANs and tunnels as the UI, with the UI's certificate (self-signed unless ACME is set up: the client must trust it).
- **Tokens** (System → API access): `rtr_…`, shown once, stored as SHA-256 in `/var/lib/router/webui/tokens.json`. Scopes `read` and `change`; an `apply` scope is reserved. A session cookie is not a token. A wrong token costs a second; a foreign `Origin` is refused (DNS rebinding).
- **Tools**: read — `get_status`, `get_settings` (with an etag), `get_pending_changes`, `list_interfaces`, `list_dhcp_leases`, `get_update_status`, `get_apply_log`, `check_settings`; change — `update_settings` (JSON merge patch, refused when the etag is stale), `discard_changes`. Resources: `router://settings`, and `router://options` (every router option with type, default and description, from `nixosOptionsDoc`, so an assistant knows what settings exist).
- **Editing is safe by construction**: edits only write `settings.json`, like the Advanced page. They stay *unapplied*; a person reviews them (Changes page, a diff against the confirmed settings) and applies them in the web UI, with commit-confirm. Not editable: `system.sshKeys`, `system.stateVersion`, the schema `version`, any other `system` key besides hostName and timeZone; every `…File` path must be under the state directory (so a patch cannot point the PPPoE password file at `/etc/shadow`).
- **`check_settings`** runs `router-check.service` (as root, outside the web UI's sandbox): `nix eval` of the system's `drvPath`, which runs the option types and all assertions without building. The verdict is in `/var/lib/router/check/result.json` with the etag of the settings it evaluated.
- **Audit**: every tool call (token name, tool, arguments, error) is appended to `/var/lib/router/webui/audit.log` (rotated at 1 MB) and shown on the API access page.
- **`get_logs`** (read): `journalctl` with arguments built in `logs.go` (no shell): `unit` (a unit name or `kernel`), `lines` (≤ 1000), `since`/`until`, `priority`, `grep`, `boot`. The output is capped at 256 KB.
- **Not done**: applying or confirming (needs the `apply` scope), metric history.
## REST API (confirmed)
- `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP.
- **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`.
- Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500.
## Interface names (confirmed)
- **Cards**: `router.interfaces.<name>.mac` names the card with that (permanent) MAC address `<name>` from the next boot on, via a systemd `.link` file; `wan.interface` and `lans.*.interface` use the new names, so the web UI, nftables, Kea and the logs show `wan`, `lan`, not `enp2s0`.
- **VLAN devices**: `lans.<l>.deviceName` (and `wan.ipv4.pppoe.vlanName`) name the VLAN device (`dmz` instead of `lan.1002`); the default stays `<interface>.<vlan>`.
- **Only after a reboot**: a device that is up cannot be renamed and the configuration refers to the new name, so `router-apply` compares `/etc/router/interface-names` (cards and VLAN devices with their names) between the running and the new system. If a card gets, loses or changes its name, or an existing VLAN device changes its name, it only builds, sets the system profile and `switch-to-configuration boot` (no commit-confirm, no live switch); the web UI shows "starts at the next reboot". New VLAN devices (also named ones) are created live as before.
- **Names**: lower case letters, digits and dashes, start with a letter, at most 15 characters, and not what the kernel or udev hand out (`eth0`, `wlan0`, `enp2s0`, `ppp0`, ...), checked by an assertion and in the web UI (`chosenName`).
- **Risk**: a wrong MAC leaves the new system without its LAN after the reboot and there is no automatic rollback (the previous generation is in the boot menu). The web UI only offers cards that are present, with their MAC read from the machine.
## Port forwards and NAT (confirmed)
- Filtering and translation are separate. `router.firewall.rules` only filter and may use the reserved source zone `wan` (no LAN/tunnel may use that zone name; `wan` is never a destination). Rule fields: port ranges (`"9000-9002"`), `sources` allow-list (v4/v6 CIDRs), `rateLimit` (`N/second|minute|hour`, per source via nft meter), `log`, `natOnly` (only connections a DNAT rule translated).
- `router.firewall.nat` is the translation table: `type` `dnat` (IPv4 only, one host `toAddress` in the `to` zone, tcp/udp, optional `toPort`, `hairpin` from `wan`: NAT loopback for LANs/tunnels with `internet = true`; masquerades so replies return via the router), `snat` (`toAddress`) and `masquerade`, matching `from`, `sources`, `destination` (default: the router's own addresses), protocol and ports.
- The web UI's NAT table can also add the matching filter rule ("also allow", a `natOnly` rule). Schema 1 (`migrations/1.jq`) converts the old rules with `dnat` into a NAT row plus such a rule.
- Safeguards: a rule from `wan` must name a destination, an explicit protocol and its ports, so a typo cannot expose a whole zone.
- IPv6 has no NAT: it is opened only by explicit pinholes (`from = "wan"`, IPv6 `destination`). The prefix is dynamic, so v6 destinations are not checked against the zone.
- Tested in `basic`/`pppoe` (source preserved, toPort, ranges, sources, rate limit, v6 pinhole, hairpin, log lines) and in `assertions`; mutation-tested (source match, hairpin masquerade, rate limit).
## DNS accounts, dynamic DNS and certificates
- `router.dnsProviders` holds accounts at DNS providers by name (`type`, `username`, `server`, `secretFile`); the token is a private file (web UI: `/var/lib/router/dns-secrets/NAME`), never in settings or the store. `router.ddns.provider` and `router.acme.provider` refer to one. Schema 2 (`migrations/2.jq`) moves the old `ddns.protocol/server/username/passwordFile` into an account named after the protocol.
- `router.acme`: a Let's Encrypt certificate for the web UI through `security.acme` (lego), DNS-01 only (no open port, wildcards work), with providers that have one token: cloudflare, desec, digitalocean, duckdns, hetzner. Staging by default; `acceptTerms` must be set. The web UI serves `/var/lib/acme/<first name>/{fullchain,key}.pem` when present (reloaded when the files change, so renewals need no restart) and its self-signed certificate otherwise.
- Tested: assertions, the web UI forms (Go and VM), the certificate source and the migration. **Not tested: an order against Let's Encrypt** (needs a pebble test server or a real zone).
## Deployment and rollback (confirmed)
- **Router on a stick**: `wan.interface` may also carry LANs, but only for PPPoE on a VLAN with IPv6 disabled (the untagged link is then free for a LAN; native IPv6 needs an untagged uplink). In that mode only the PPP device is an uplink in the firewall, so LAN traffic can never match WAN rules; the WAN VLAN is created from the LAN's/trunk's networkd unit. A LAN VLAN may not reuse the PPPoE VLAN id.
- **Commit-confirm** (`router.rollback`, on by default): `router-arm [time]` starts a transient timer; `router-rollback` re-activates the confirmed generation (`/nix/var/nix/profiles/system`, else `/run/booted-system`) with `switch-to-configuration test`; `router-confirm` stops the timer, sets the profile and the boot default. Deploy with `nix run .#deploy -- <flake-attr> <user@host> [--yes]`: build, arm, `nixos-rebuild test --target-host`, check SSH, confirm. A lost connection means the timer rolls back by itself; a reboot also returns to the confirmed generation.
- `systemd.network.wait-online` waits only for the LAN devices (not the uplink) by default: an unplugged uplink must not block boot or activation, while VLAN devices still exist before Kea starts (anyInterface broke this; caught by `vlans`).
- `examples/desktop` + `nixosConfigurations.example` is the deployable starting point (single NIC `enp2s0` on the new box (antigua calls its NIC `eno1`), PPPoE VLAN 1001); it is built as a flake check. Secrets stay on the device (`/var/lib/router/pppoe-password`).
- Open for real hardware: Nix is still on the device (`router-confirm` uses `nix-env`); no image/read-only root yet.
## Local DNS and DHCP reservations (confirmed)
Needed to replace the user's hand-written router (host `antigua`, dnsmasq). Services such as k3s, Docker and Gitea stay on other hosts, so the router has no input rules for them.
- `router.dns.domain`: local domain, authoritative on the router (`static` zone; unknown names answer NXDOMAIN, never forwarded).
- `router.dns.hosts`: name to IPv4/IPv6 addresses; short names live in the domain, full names override public DNS for that name only (split horizon). Reverse (PTR) records are generated.
- `router.dns.overrides`: like hosts but wildcard (Unbound `redirect` zone).
- `router.dns.forwardZones`: zone to resolvers; these and the local domain are `domain-insecure` (private zones under possibly signed parents).
- `lans.<n>.dhcp.reservations.<hostname> = { mac; address; }`: Kea host reservations; the hostname is published in DNS. Assertions: valid MAC/hostname, address inside the LAN and not the router's, no duplicate MACs/addresses, no clash with `dns.hosts`.
- Tunnels with `dns = true` answer on the LAN addresses too: Unbound's access control admits what the tunnel's peers route (`allowedIPs`), so a site-to-site peer without a transfer address can use the resolver.
- Tests: `dns` (VM), site-to-site DNS in `wireguard`, and assertion cases; mutation-tested.
- Still missing for the `antigua` migration: IPv6 over the PPP session (ipv6cp, DHCPv6-PD on the PPP device), an extra static ULA address with RA on the LAN, tunnels without own addresses/fully trusted tunnels (partly covered), and the PPP device name (`wan` there, `pppoe-wan` here: `ddclient` must follow).
## WireGuard (confirmed)
- **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.<name>`.
- **Firewall**: a tunnel is a zone member like a LAN (`zone`, `internet`, `allowManagement`, plus `dns`, `masquerade`, `killSwitch`), so `router.firewall.rules` cover it. Safer defaults than LANs: management, DNS and internet are off. A tunnel's peers never see each other (no same-device forwarding).
- **Routing**: by destination only — a peer's `allowedIPs` become routes (no default-route tunnels; `/0` is rejected). Provider-style "policy routing" is exactly this; source-based selectors (`ip rule`) are not implemented.
- **Kill switch** (default on): forwarded packets for a tunnel's `allowedIPs` are dropped if they would leave via an uplink (placed before the established-accept, so flows that were in the tunnel are cut too).
- **Addresses** are explicit (`addresses` list, empty for site-to-site); nothing generated. No IPv6 internet for road warriors (the delegated /64 belongs to a LAN; NPTv6 not implemented).
- **Keys**: `privateKeyFile` (and `presharedKeyFile`) outside the Nix store, handed to the unprivileged systemd-networkd via `LoadCredential`, so files can stay root-only 0600. Deployment must provide them.
## Replacing `antigua` (the user's hand-written router)
> `examples/home` (the hand-written Nix port of antigua's router role), its `home` check, `installer-home` and the WireGuard import script were removed once the installer and web UI could configure the router (`settings.json`); the notes below describe that port and stay for the reasoning. The PPPoE username and everything else now live in `settings.json` (Network page).
Source: the user's infra repo (host `antigua`, NixOS, dnsmasq + networkd + pppd). It is also a k3s/Docker/Gitea host today; **those services will stay on antigua / move elsewhere, so the router does not need input rules for them or trusted container interfaces.** The new box is set up next to antigua and tested by swapping the two.
Done: PPPoE on VLAN 1001 over the shared NIC (`eno1`, LAN untagged), DNS records/domain/forward zones/DHCP reservations/DNS for site-to-site peers, WireGuard (freifunk dial-out with masquerade, family site-to-site with PSK, mobile-clients road warrior), MSS clamp.
Open (in rough order):
1. ~~**IPv6 over the PPP session**~~ **done**: `wan.ipv6.transport = "ppp"` (default `native`) enables IPv6CP in pppd, RA + DHCPv6-PD via networkd on the PPP device, PD to the LAN, DHCPv6 replies accepted on the PPP device and the IPv6 MSS clamped to MTU-60 (1432). Allowed on a shared link (single NIC). Tested in `onestick` (ISP with radvd and a minimal DHCPv6-PD responder, since Kea cannot serve point-to-point links); mutation-tested (MSS clamp, DHCPv6 input rule). `ipv6cp-use-ipaddr` is set as on antigua.
2. ~~**ULA on the LAN**~~ **done**: `lans.<n>.ipv6.address` (a ULA /64, e.g. `fd2c:cdef:cde9::1/64`) is assigned, announced via SLAAC (explicit `[IPv6Prefix]`, because networkd announces only the delegated prefix when PD is on) and advertised as RDNSS; Unbound listens on it. Build-time checks: valid IPv6 CIDR, /64, fc00::/7. Tested in `onestick` (client SLAAC ULA, ping and DNS over the ULA, RA captured on the wire) and assertion cases; mutation-tested.
3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard.<n>.sourceAddresses` sets the source (`PreferredSource`) for router-originated traffic into an addressless tunnel (e.g. DNS to Troisdorf; tested, mutation-tested). Antigua's blanket `trustedInterfaces` is spelled out as per-tunnel `allowManagement`/`dns` plus zone rules in `examples/home`. The default device name `wg-<name>` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`).
4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS has since moved into the module (`router.ddns`, a web UI page; tested in `ddns`): `examples/home` can drop its `services.ddclient` for it.
5. **Credentials**: the PPPoE password is assigned by the ISP (cannot be rotated) and is plaintext in antigua's repo/store; never copy it into a repo again. Here it is read from `/var/lib/router/pppoe-password` (delivered with `router-install --secrets`). The PPPoE username in `examples/home` is a placeholder.
6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (a NAT row on the Firewall page); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.**
7. `examples/home` (`nixosConfigurations.home`, `packages.installer-home`) ports antigua's routing role; its toplevel builds as the `home` check. Not VM-testable as a whole (needs the real ISP); its pieces are covered by the component tests.
## Installer and image (in progress)
- **Delivery order**: installer ISO first (set up a second box next to antigua), `image.repart` disk image + A/B updates later.
- `router-install [--yes] [--secrets DIR] <disk>`: GPT with an ESP (label `boot`) and an ext4 root (label `nixos`), `nixos-install --system <prebuilt toplevel>` (nothing is built on the box), secrets copied to `/var/lib/router`. The layout contract is `nixosModules.installed-layout` (labels, systemd-boot without EFI variables so it boots via the removable fallback path, generic initrd modules, redistributable firmware); target configs import it.
- `lib.mkInstaller { configuration }` builds the ISO for a given `nixosSystem`; the live system runs sshd with that system's root keys. `packages.x86_64-linux.installer-example` is the example's ISO.
- Test `installer` (VM): the nixpkgs installer-test pattern (an installer VM writes `target.qcow2`, a second VM boots it with UEFI); checks refusal of bad input/mounted disks, the fallback boot file, router services up, secrets owned by root with 0700/0600. Mutation-tested (secret permissions, mounted-disk guard).
- Status: `nix build .#installer-example` produces a 1.6 GB ISO. **Not yet tested**: booting that ISO on real firmware (the VM test exercises `router-install`, not the live ISO), the NIC name in the example, and installing over an existing system.
- Build: `nix build .#installer-example`, write `result/iso/*.iso` to a USB stick, boot the new box (UEFI), `scp -r secrets root@<box>:` then `router-install --secrets /root/secrets /dev/<disk>`.
## A/B image product: considered, not pursued
Considered 2026-10-30: moving the product to the A/B dm-verity appliance image (`modules/image`) with a
runtime renderer for `settings.json`. Dropped in favour of the current approach (installed NixOS, on-device
builds with `router-apply`, signed Nix-cache updates). Reasons: a read-only store cannot build a new system, so
the choice was a Go reimplementation of ~2300 lines of Nix module logic, or Nix with a writable overlay on the
box, which makes every post-update boot wait for a rebuild and leaves the settings-derived system outside
verity. The image module and its test (`checks.image`) stay as they are for anyone who wants a fixed image.
## Local zone (confirmed)
- **`local` zone**: `firewall.rules` may have `to = "local"`, the router itself (SSH, the web UI, ...): those rules go on the `input` chain. From `wan` they need a protocol and ports (no destination); `natOnly` is refused; `local` cannot be a zone name. A port forward on the same port takes the traffic first, so it never reaches `local`. Checked by `tests/assertions.nix` (`local*`); the generated nftables rules are not covered by a VM test yet.
## Open items
- **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system.
### Still left (2026-11-01)
Release and tests
- Release 0.12.0 (bump `VERSION`, `nix run .#release`): nothing since 0.11.0 is released (restyle and themes, interface names, MCP, REST API, `get_logs`, `local` zone).
- Rerun the VM tests that were not run on this work: `basic`, `onestick`, `setup`, `update`, `dns`, `ddns`, and a VM test for `local` rules (a WAN host reaching SSH or the web UI through `wan -> local`, and not without the rule).
- A pebble-based ACME test (certificate issuance is only checked by evaluation).
Remote access
- MCP and REST: an `apply` scope (build, activate, confirm; a token with it changes the running system); more read tools (metrics history, WireGuard status, certificate status); per-token expiry; a notice in the web UI when a token edited the settings; `get_logs` lets a read token see the whole journal (consider a unit allowlist or a separate `logs` scope).
- REST API: the rest of what the web UI does (apply and confirm, metrics, updates), a typed response schema in the OpenAPI document (responses are untyped JSON now).
Firewall and networking
- Host-based routing on 443/80 (decide by TLS server name or `Host` between the router's web UI and a host behind it): needs an SNI proxy on the router, loses the client address unless the host takes the PROXY protocol. Until then, expose the web UI and SSH on their own ports with `wan -> local` rules.
- WireGuard: live status (handshake, transfer) in the web UI; a tunnel with `dns = true` but no address; the WireGuard page may show a duplicate "New tunnel" heading.
- Installer: name cards (`router.interfaces`) during `router-setup`; dropdowns that list VLAN devices should show their custom names (`lan.1002` as `dmz`).
- Advanced page: its sections are still unstyled next to the other pages' panels.
## Phases
1. **MVP** — flake with `router.*` schema: single WAN (DHCP), single LAN, NAT, basic firewall, DHCP + DNS. NixOS VM tests (`nixosTest`) in CI.
2. **Real-world features** — IPv6 PD, VLANs, zone policy, port forwards, WireGuard.
3. **Images and hardware** — installer ISO, read-only root, aarch64.
4. **Operations** — commit-confirm rollback, monitoring (node exporter, nftables counters), A/B image updates; multi-WAN failover.
5. **Optional** — web UI (stats, then configuration).
+252 -12
View File
@@ -1,17 +1,257 @@
# Specs # nixos-router — Specs
What nixos-router should become, beyond what exists. `PLAN.md` records decisions and what is done; this file Working name: `nixos-router`. A NixOS-based distribution for routers/firewalls, delivered as a flake exporting NixOS modules and bootable images (not a nixpkgs fork).
lists wanted features. The MCP server (read and change scopes) exists; the rest is not started.
## Later This file is the one place for what the product is: decisions, how each part works (sections marked *confirmed* are built and tested), and, at the end, what is not done yet.
### Programmatic access ## Decisions
- **REST API**: done with the MCP's feature set (OpenAPI at `/api/openapi.json`, see `PLAN.md`). Still to do: the rest | Area | Decision |
of what the web UI does (apply/confirm, metrics, updates), versioned; the web UI would use the same handlers. |---|---|
| Audience | Own homelab first; keep structure clean enough to publish later. |
| Config model | Opinionated `router.*` option schema compiled to systemd-networkd + nftables, with build-time assertions. |
| Hardware | x86_64 UEFI. aarch64 stays possible (the flake exposes both systems; avoid x86-only assumptions); a NanoPi R2S SD card image was built on 2026-11-01 and dropped. |
| v1 features | IPv6 + prefix delegation, VLANs / multiple LANs, WireGuard. |
| Deferred | Multi-WAN failover, QoS (CAKE/SQM). (The web UI, an MCP server and a REST API exist.) |
| Updates (v1) | Push from workstation (`nixos-rebuild --target-host` / colmena) + commit-confirm rollback timer. |
| Updates (later) | **A/B image updates** with automatic rollback. |
| Footprint | Minimal closure, read-only root, no Nix daemon/compiler on device, small persistent `/var`. |
## Architecture
1. **`router.*` schema** — users describe intent (wan, lan, vlans, zones, port forwards, wireguard); modules compile it to:
- systemd-networkd (DHCPv4/v6, DHCPv6-PD, VLANs, bridges, PPPoE later)
- nftables zone-based ruleset (generated, not `networking.firewall`)
- DHCP / DNS / RA services
2. **Build-time validation** — Nix assertions reject lock-out or inconsistent configs (no management access rule, overlapping subnets, zone referencing missing interface); generated ruleset checked with `nft -c`.
3. **Images** — x86_64: installer ISO (`router-setup`) and installed NixOS; the read-only A/B image module exists but is not the product (see below).
4. **Deployment** — deploy a prebuilt closure (`nix copy` + `switch-to-configuration`); never evaluate or build on the router. This keeps the device light and leads naturally to A/B updates.
## Implications
- No on-device evaluation: all designs must work from a prebuilt closure.
- Flake exposes `x86_64-linux` and `aarch64-linux` outputs from the start; hardware specifics (bootloader, kernel, NIC naming) live in separate `boards/` modules.
- IPv6 PD fan-out to multiple VLANs needs stable per-LAN subnet-ID allocation; this is the hardest part of the schema.
## Default zone policy (confirmed)
- WAN: deny inbound (except established/related, required ICMPv6, explicit port forwards).
- LAN: allow outbound to WAN.
- Other zones (e.g. IoT): isolated from each other and from LAN unless a rule opens them.
## Management access (confirmed)
- Key-only SSH, reachable from LAN zones (trusted, non-WAN, non-isolated zones). No GUI in v1.
- Build-time assertion fails if no management path exists.
- Isolated zones (e.g. IoT) get no router access beyond DHCP/DNS/NDP.
## Services (confirmed)
- DHCP: Kea (DHCPv4 + DHCPv6 incl. PD).
- DNS: Unbound.
- RA: systemd-networkd (or radvd if networkd falls short).
## Repo layout (confirmed)
```
flake.nix
modules/ # router.* schema and service modules
tests/ # nixosTest VM tests
images/ # image/ISO builds
examples/ # sample configs
```
## Status
**WireGuard done**: `wireguard` VM test (5 VMs: ISP/provider, router, laptop, remote site, LAN host) covers handshakes, road-warrior DNS/SSH over IPv4 and IPv6, zone rules between tunnels and LAN, site-to-site routing without NAT or transfer net, provider tunnel with masquerade and IPv6 transfer net, and the kill switch (kill switch and masquerade mutation-tested). Not covered: preshared keys, `mtu`, tunnels with several peers, key rotation.
**VLANs + zones done**: `vlans` VM test (5 VMs: ISP, router, untagged `main`, VLAN 20 `iot`, VLAN 30 `cams`) covers VLAN devices, per-VLAN DHCP/gateway, opt-in internet, DNS vs SSH per LAN, intra-zone forwarding across VLANs, one-directional inter-zone rules and the destination restriction (each mutation-tested). Not covered yet: IPv6 on VLAN LANs (only the PD LAN has IPv6), port forwards from the WAN.
**PPPoE done**: `pppoe` VM test = the same topology with a fake ISP running `pppoe-server` (VLAN 7, CHAP, password containing a quote) — covers session, NAT, MTU, MSS clamp (mutation-tested) and WAN-deny. `basic` remains the DHCPv4 variant.
**IPv6 + prefix delegation done (single /64)**: `router.wan.ipv6.enable` and `router.lans.<name>.ipv6.pd` (at most one LAN, asserted). The VM test now covers the router's DHCPv6 address and delegated /64, SLAAC on the client, routing without NAT66, and WAN-deny over IPv6 (mutation-tested). Still open: RDNSS, other LANs are IPv4-only, DHCPv6 reservations.
**Phase 1 (MVP) skeleton done**: flake, `router.*` schema (wan, lans, dns.forwarders), networkd, nftables (zone-less for now: WAN/LAN), Kea DHCPv4, Unbound, assertions. `nix build .#checks.x86_64-linux.basic` runs a 3-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP+DNS, NAT, WAN-deny and LAN SSH.
Interactive lab: `nix run .#lab` (x86_64) opens tmux with one console pane per VM plus the test-driver REPL (`router.shell_interact()`, `client.succeed(...)`, `test_script()`). The REPL pane also shows all VM output interleaved.
Learned along the way:
- The default policy now explicitly answers ICMP echo on LANs only; the WAN gets only ICMP error types and ND.
- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check.
- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module).
- `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do.
- Checks: `basic`, `pppoe`, `onestick`, `rollback`, `dns`, `vlans` and `wireguard` (VM tests), `example` (the example system builds), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: NPTv6.
## IPv6 design (confirmed)
- **WAN**: mirrors the existing router's uplink config — networkd `DHCP` (v6), `IPv6AcceptRA = true`, `KeepConfiguration = yes`, `dhcpV6Config = { WithoutRA = "solicit"; UseDelegatedPrefix = true; }`. The ISP sends RAs (default route) without DHCPv6 flags, so the router solicits on its own.
- **Delegation**: the ISP delegates a single **/64**, which cannot be split. Exactly one LAN may claim it via an explicit flag (`router.lans.<name>.ipv6.pd = true`); an assertion rejects more than one.
- **LANs**: SLAAC only (RA, no DHCPv6). All other LANs/VLANs are **IPv4-only** for now (ULA or NPTv6 can come later).
- **Firewall**: inet table covers v6; WAN→LAN forwarding stays denied, no NAT66.
- **Test**: fake ISP on `upstream` = Kea DHCPv6 (IA_NA + IA_PD /64 from a /48 pool) + networkd RA sender (default route only) + a static route for the delegated prefix back to the router.
- **Known gap**: no RDNSS in RAs yet (the router's global LAN address is not known at eval time); clients still get DNS via DHCPv4.
## PPPoE uplink (confirmed)
- **IPv4 via PPPoE** (`router.wan.ipv4.type = "pppoe"`), carried on an optional tagged **VLAN** on the WAN link (`pppoe.vlan`); native DHCPv6/PD stays on the untagged link.
- `wan.interface` is the physical link; the derived `wan.ipv4.interface` (`pppoe-wan` or the link) is where NAT, MSS clamping and the IPv4 forwarding rule apply. IPv6 forwarding keeps using the physical link.
- pppd (`pppoe.so`) runs as `router-pppoe.service`, ordered after the VLAN device. Credentials: `username` inline, password from `pppoe.passwordFile` (type forbids Nix-store paths); the options file is generated into a root-only runtime dir. **Deployment must provide that file** on persistent storage (or via agenix/sops).
- **MTU 1492 + TCP MSS clamp** (1452) in both directions on the PPP device; RFC 4638 not implemented.
## VLANs and zones (confirmed)
- **VLAN devices** are created by the router: `router.lans.<name> = { interface = <trunk>; vlan = <id>; }` makes `<trunk>.<id>` via networkd. A trunk may also carry one untagged LAN. Assertions: duplicate device, device name > 15 chars, WAN interface reused as trunk.
- **Zones** are an attribute on the LAN (`zone`, default = the LAN's own name); LANs sharing a zone may forward to each other, traffic **between zones is denied** unless a rule in `router.firewall.rules` allows it (`from`, `to`, `protocol`, `ports`, optional IPv4 `destination` that must lie inside a LAN of the destination zone). Rules are one-directional; return traffic via conntrack. Assertions reject unknown zones, from == to, ports without tcp/udp, bad/out-of-zone/host-bit destinations.
- **Internet is opt-in per LAN** (`internet`, default false) — this replaces the earlier "LAN allow-out" default; any LAN that should reach the WAN must set it.
- Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`.
- Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases).
## MCP server (confirmed)
- **Endpoint**: `POST /mcp` on the web UI (MCP Streamable HTTP, JSON-RPC 2.0, single request/response; no streaming). It is served on the same LANs and tunnels as the UI, with the UI's certificate (self-signed unless ACME is set up: the client must trust it).
- **Tokens** (System → API access): `rtr_…`, shown once, stored as SHA-256 in `/var/lib/router/webui/tokens.json`. Scopes `read` and `change`; an `apply` scope is reserved. A session cookie is not a token. A wrong token costs a second; a foreign `Origin` is refused (DNS rebinding).
- **Tools**: read — `get_status`, `get_settings` (with an etag), `get_pending_changes`, `list_interfaces`, `list_dhcp_leases`, `get_update_status`, `get_apply_log`, `check_settings`; change — `update_settings` (JSON merge patch, refused when the etag is stale), `discard_changes`. Resources: `router://settings`, and `router://options` (every router option with type, default and description, from `nixosOptionsDoc`, so an assistant knows what settings exist).
- **Editing is safe by construction**: edits only write `settings.json`, like the Advanced page. They stay *unapplied*; a person reviews them (Changes page, a diff against the confirmed settings) and applies them in the web UI, with commit-confirm. Not editable: `system.sshKeys`, `system.stateVersion`, the schema `version`, any other `system` key besides hostName and timeZone; every `…File` path must be under the state directory (so a patch cannot point the PPPoE password file at `/etc/shadow`).
- **`check_settings`** runs `router-check.service` (as root, outside the web UI's sandbox): `nix eval` of the system's `drvPath`, which runs the option types and all assertions without building. The verdict is in `/var/lib/router/check/result.json` with the etag of the settings it evaluated.
- **Audit**: every tool call (token name, tool, arguments, error) is appended to `/var/lib/router/webui/audit.log` (rotated at 1 MB) and shown on the API access page.
- **`get_logs`** (read): `journalctl` with arguments built in `logs.go` (no shell): `unit` (required: `kernel` or one of `router-*`, `kea-dhcp4-server`, `unbound`, `systemd-networkd`, `systemd-resolved`, `systemd-timesyncd`, `nftables`, `ddclient`, `acme-*`, `sshd`; not the journal as a whole), `lines` (≤ 1000), `since`/`until`, `priority`, `grep`, `boot`. The output is capped at 256 KB.
- **Token expiry**: a token is made for 1, 7, 30 (default) or 90 days, or without limit; an expired token is refused like an unknown one. The API access page shows when each ends.
- **Edits through the API are flagged**: after `update_settings` the overview's pending banner and the Changes page say which token made the changes, as long as the saved settings are still that edit (the note holds the settings' etag, so a later edit in the web UI or an apply makes it stale).
- **options.json** (`router://options`) has no `declarations` (the source's store path): the file is the same for every build of a release, so a router that rebuilds itself offline already has it.
- **Not done**: applying or confirming (needs the `apply` scope), metric history.
## REST API (confirmed)
- `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP.
- **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`.
- Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500.
## Interface names (confirmed)
- **Cards**: `router.interfaces.<name>.mac` names the card with that (permanent) MAC address `<name>` from the next boot on, via a systemd `.link` file; `wan.interface` and `lans.*.interface` use the new names, so the web UI, nftables, Kea and the logs show `wan`, `lan`, not `enp2s0`.
- **VLAN devices**: `lans.<l>.deviceName` (and `wan.ipv4.pppoe.vlanName`) name the VLAN device (`dmz` instead of `lan.1002`); the default stays `<interface>.<vlan>`.
- **Only after a reboot**: a device that is up cannot be renamed and the configuration refers to the new name, so `router-apply` compares `/etc/router/interface-names` (cards and VLAN devices with their names) between the running and the new system. If a card gets, loses or changes its name, or an existing VLAN device changes its name, it only builds, sets the system profile and `switch-to-configuration boot` (no commit-confirm, no live switch); the web UI shows "starts at the next reboot". New VLAN devices (also named ones) are created live as before.
- **Names**: lower case letters, digits and dashes, start with a letter, at most 15 characters, and not what the kernel or udev hand out (`eth0`, `wlan0`, `enp2s0`, `ppp0`, ...), checked by an assertion and in the web UI (`chosenName`).
- **Risk**: a wrong MAC leaves the new system without its LAN after the reboot and there is no automatic rollback (the previous generation is in the boot menu). The web UI only offers cards that are present, with their MAC read from the machine.
## Port forwards and NAT (confirmed)
- Filtering and translation are separate. `router.firewall.rules` only filter and may use the reserved source zone `wan` (no LAN/tunnel may use that zone name; `wan` is never a destination). Rule fields: port ranges (`"9000-9002"`), `sources` allow-list (v4/v6 CIDRs), `rateLimit` (`N/second|minute|hour`, per source via nft meter), `log`, `natOnly` (only connections a DNAT rule translated).
- `router.firewall.nat` is the translation table: `type` `dnat` (IPv4 only, one host `toAddress` in the `to` zone, tcp/udp, optional `toPort`, `hairpin` from `wan`: NAT loopback for LANs/tunnels with `internet = true`; masquerades so replies return via the router), `snat` (`toAddress`) and `masquerade`, matching `from`, `sources`, `destination` (default: the router's own addresses), protocol and ports.
- The web UI's NAT table can also add the matching filter rule ("also allow", a `natOnly` rule). Schema 1 (`migrations/1.jq`) converts the old rules with `dnat` into a NAT row plus such a rule.
- Safeguards: a rule from `wan` must name a destination, an explicit protocol and its ports, so a typo cannot expose a whole zone.
- IPv6 has no NAT: it is opened only by explicit pinholes (`from = "wan"`, IPv6 `destination`). The prefix is dynamic, so v6 destinations are not checked against the zone.
- Tested in `basic`/`pppoe` (source preserved, toPort, ranges, sources, rate limit, v6 pinhole, hairpin, log lines) and in `assertions`; mutation-tested (source match, hairpin masquerade, rate limit).
## DNS accounts, dynamic DNS and certificates
- `router.dnsProviders` holds accounts at DNS providers by name (`type`, `username`, `server`, `secretFile`); the token is a private file (web UI: `/var/lib/router/dns-secrets/NAME`), never in settings or the store. `router.ddns.provider` and `router.acme.provider` refer to one. Schema 2 (`migrations/2.jq`) moves the old `ddns.protocol/server/username/passwordFile` into an account named after the protocol.
- `router.acme`: a Let's Encrypt certificate for the web UI through `security.acme` (lego), DNS-01 only (no open port, wildcards work), with providers that have one token: cloudflare, desec, digitalocean, duckdns, hetzner. Staging by default; `acceptTerms` must be set. The web UI serves `/var/lib/acme/<first name>/{fullchain,key}.pem` when present (reloaded when the files change, so renewals need no restart) and its self-signed certificate otherwise.
- Tested: assertions, the web UI forms (Go and VM), the certificate source and the migration. **Not tested: an order against Let's Encrypt** (needs a pebble test server or a real zone).
## Deployment and rollback (confirmed)
- **Router on a stick**: `wan.interface` may also carry LANs, but only for PPPoE on a VLAN with IPv6 disabled (the untagged link is then free for a LAN; native IPv6 needs an untagged uplink). In that mode only the PPP device is an uplink in the firewall, so LAN traffic can never match WAN rules; the WAN VLAN is created from the LAN's/trunk's networkd unit. A LAN VLAN may not reuse the PPPoE VLAN id.
- **Commit-confirm** (`router.rollback`, on by default): `router-arm [time]` starts a transient timer; `router-rollback` re-activates the confirmed generation (`/nix/var/nix/profiles/system`, else `/run/booted-system`) with `switch-to-configuration test`; `router-confirm` stops the timer, sets the profile and the boot default. Deploy with `nix run .#deploy -- <flake-attr> <user@host> [--yes]`: build, arm, `nixos-rebuild test --target-host`, check SSH, confirm. A lost connection means the timer rolls back by itself; a reboot also returns to the confirmed generation.
- `systemd.network.wait-online` waits only for the LAN devices (not the uplink) by default: an unplugged uplink must not block boot or activation, while VLAN devices still exist before Kea starts (anyInterface broke this; caught by `vlans`).
- `examples/desktop` + `nixosConfigurations.example` is the deployable starting point (single NIC `enp2s0` on the new box (antigua calls its NIC `eno1`), PPPoE VLAN 1001); it is built as a flake check. Secrets stay on the device (`/var/lib/router/pppoe-password`).
- Open for real hardware: Nix is still on the device (`router-confirm` uses `nix-env`); no image/read-only root yet.
## Local DNS and DHCP reservations (confirmed)
Needed to replace the user's hand-written router (host `antigua`, dnsmasq). Services such as k3s, Docker and Gitea stay on other hosts, so the router has no input rules for them.
- `router.dns.domain`: local domain, authoritative on the router (`static` zone; unknown names answer NXDOMAIN, never forwarded).
- `router.dns.hosts`: name to IPv4/IPv6 addresses; short names live in the domain, full names override public DNS for that name only (split horizon). Reverse (PTR) records are generated.
- `router.dns.overrides`: like hosts but wildcard (Unbound `redirect` zone).
- `router.dns.forwardZones`: zone to resolvers; these and the local domain are `domain-insecure` (private zones under possibly signed parents).
- `lans.<n>.dhcp.reservations.<hostname> = { mac; address; }`: Kea host reservations; the hostname is published in DNS. Assertions: valid MAC/hostname, address inside the LAN and not the router's, no duplicate MACs/addresses, no clash with `dns.hosts`.
- Tunnels with `dns = true` answer on the LAN addresses too: Unbound's access control admits what the tunnel's peers route (`allowedIPs`), so a site-to-site peer without a transfer address can use the resolver.
- Tests: `dns` (VM), site-to-site DNS in `wireguard`, and assertion cases; mutation-tested.
- Still missing for the `antigua` migration: IPv6 over the PPP session (ipv6cp, DHCPv6-PD on the PPP device), an extra static ULA address with RA on the LAN, tunnels without own addresses/fully trusted tunnels (partly covered), and the PPP device name (`wan` there, `pppoe-wan` here: `ddclient` must follow).
## WireGuard (confirmed)
- **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.<name>`.
- **Firewall**: a tunnel is a zone member like a LAN (`zone`, `internet`, `allowManagement`, plus `dns`, `masquerade`, `killSwitch`), so `router.firewall.rules` cover it. Safer defaults than LANs: management, DNS and internet are off. A tunnel's peers never see each other (no same-device forwarding).
- **Routing**: by destination only — a peer's `allowedIPs` become routes (no default-route tunnels; `/0` is rejected). Provider-style "policy routing" is exactly this; source-based selectors (`ip rule`) are not implemented.
- **Kill switch** (default on): forwarded packets for a tunnel's `allowedIPs` are dropped if they would leave via an uplink (placed before the established-accept, so flows that were in the tunnel are cut too).
- **Addresses** are explicit (`addresses` list, empty for site-to-site); nothing generated. No IPv6 internet for road warriors (the delegated /64 belongs to a LAN; NPTv6 not implemented).
- **Keys**: `privateKeyFile` (and `presharedKeyFile`) outside the Nix store, handed to the unprivileged systemd-networkd via `LoadCredential`, so files can stay root-only 0600. Deployment must provide them.
## Replacing `antigua` (the user's hand-written router)
> `examples/home` (the hand-written Nix port of antigua's router role), its `home` check, `installer-home` and the WireGuard import script were removed once the installer and web UI could configure the router (`settings.json`); the notes below describe that port and stay for the reasoning. The PPPoE username and everything else now live in `settings.json` (Network page).
Source: the user's infra repo (host `antigua`, NixOS, dnsmasq + networkd + pppd). It is also a k3s/Docker/Gitea host today; **those services will stay on antigua / move elsewhere, so the router does not need input rules for them or trusted container interfaces.** The new box is set up next to antigua and tested by swapping the two.
Done: PPPoE on VLAN 1001 over the shared NIC (`eno1`, LAN untagged), DNS records/domain/forward zones/DHCP reservations/DNS for site-to-site peers, WireGuard (freifunk dial-out with masquerade, family site-to-site with PSK, mobile-clients road warrior), MSS clamp.
Open (in rough order):
1. ~~**IPv6 over the PPP session**~~ **done**: `wan.ipv6.transport = "ppp"` (default `native`) enables IPv6CP in pppd, RA + DHCPv6-PD via networkd on the PPP device, PD to the LAN, DHCPv6 replies accepted on the PPP device and the IPv6 MSS clamped to MTU-60 (1432). Allowed on a shared link (single NIC). Tested in `onestick` (ISP with radvd and a minimal DHCPv6-PD responder, since Kea cannot serve point-to-point links); mutation-tested (MSS clamp, DHCPv6 input rule). `ipv6cp-use-ipaddr` is set as on antigua.
2. ~~**ULA on the LAN**~~ **done**: `lans.<n>.ipv6.address` (a ULA /64, e.g. `fd2c:cdef:cde9::1/64`) is assigned, announced via SLAAC (explicit `[IPv6Prefix]`, because networkd announces only the delegated prefix when PD is on) and advertised as RDNSS; Unbound listens on it. Build-time checks: valid IPv6 CIDR, /64, fc00::/7. Tested in `onestick` (client SLAAC ULA, ping and DNS over the ULA, RA captured on the wire) and assertion cases; mutation-tested.
3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard.<n>.sourceAddresses` sets the source (`PreferredSource`) for router-originated traffic into an addressless tunnel (e.g. DNS to Troisdorf; tested, mutation-tested). Antigua's blanket `trustedInterfaces` is spelled out as per-tunnel `allowManagement`/`dns` plus zone rules in `examples/home`. The default device name `wg-<name>` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`).
4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS has since moved into the module (`router.ddns`, a web UI page; tested in `ddns`): `examples/home` can drop its `services.ddclient` for it.
5. **Credentials**: the PPPoE password is assigned by the ISP (cannot be rotated) and is plaintext in antigua's repo/store; never copy it into a repo again. Here it is read from `/var/lib/router/pppoe-password` (delivered with `router-install --secrets`). The PPPoE username in `examples/home` is a placeholder.
6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (a NAT row on the Firewall page); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.**
7. `examples/home` (`nixosConfigurations.home`, `packages.installer-home`) ports antigua's routing role; its toplevel builds as the `home` check. Not VM-testable as a whole (needs the real ISP); its pieces are covered by the component tests.
## Installer and image (in progress)
- **Delivery order**: installer ISO first (set up a second box next to antigua), `image.repart` disk image + A/B updates later.
- `router-install [--yes] [--secrets DIR] <disk>`: GPT with an ESP (label `boot`) and an ext4 root (label `nixos`), `nixos-install --system <prebuilt toplevel>` (nothing is built on the box), secrets copied to `/var/lib/router`. The layout contract is `nixosModules.installed-layout` (labels, systemd-boot without EFI variables so it boots via the removable fallback path, generic initrd modules, redistributable firmware); target configs import it.
- `lib.mkInstaller { configuration }` builds the ISO for a given `nixosSystem`; the live system runs sshd with that system's root keys. `packages.x86_64-linux.installer-example` is the example's ISO.
- Test `installer` (VM): the nixpkgs installer-test pattern (an installer VM writes `target.qcow2`, a second VM boots it with UEFI); checks refusal of bad input/mounted disks, the fallback boot file, router services up, secrets owned by root with 0700/0600. Mutation-tested (secret permissions, mounted-disk guard).
- Status: `nix build .#installer-example` produces a 1.6 GB ISO. **Not yet tested**: booting that ISO on real firmware (the VM test exercises `router-install`, not the live ISO), the NIC name in the example, and installing over an existing system.
- Build: `nix build .#installer-example`, write `result/iso/*.iso` to a USB stick, boot the new box (UEFI), `scp -r secrets root@<box>:` then `router-install --secrets /root/secrets /dev/<disk>`.
## A/B image product: considered, not pursued
Considered 2026-10-30: moving the product to the A/B dm-verity appliance image (`modules/image`) with a
runtime renderer for `settings.json`. Dropped in favour of the current approach (installed NixOS, on-device
builds with `router-apply`, signed Nix-cache updates). Reasons: a read-only store cannot build a new system, so
the choice was a Go reimplementation of ~2300 lines of Nix module logic, or Nix with a writable overlay on the
box, which makes every post-update boot wait for a rebuild and leaves the settings-derived system outside
verity. The image module and its test (`checks.image`) stay as they are for anyone who wants a fixed image.
## Local zone (confirmed)
- **`local` zone**: `firewall.rules` may have `to = "local"`, the router itself (SSH, the web UI, ...): those rules go on the `input` chain. From `wan` they need a protocol and ports (no destination); `natOnly` is refused; `local` cannot be a zone name. A port forward on the same port takes the traffic first, so it never reaches `local`. Checked by `tests/assertions.nix` (`local*`); the generated rules are covered by a subtest of `basic` (the named sender reaches the router's port, others and unopened ports do not, the LAN-only rule works).
## Not done yet (2026-11-01, after 0.12.0)
Remote access
- **MCP and REST: an `apply` scope**: build, activate and confirm. A token with it changes the running system, so it needs its own design (probably a short expiry and a confirmation the token cannot give).
- **More read tools**: metrics history, WireGuard status (handshakes, transfer), certificate status.
- **REST API**: the rest of what the web UI does (apply and confirm, metrics, updates); typed response schemas in the OpenAPI document (responses are untyped JSON now); the web UI could use the same handlers.
- **GraphQL** on top of it, possibly: one query for the overview and for related settings. - **GraphQL** on top of it, possibly: one query for the overview and for related settings.
- **MCP server**: done for reading and editing settings (see `PLAN.md`, "MCP server"). Still to do: an `apply` scope - Rule for all of it: changes go through `settings.json` and `router-apply` (commit-confirm), never straight to the live system; tokens have scopes; every call is logged.
(build, activate, confirm; a token with it can change the running system), more read tools (metrics history; `get_logs` exists),
per-token expiry, and a notice in the web UI when a token edited the settings. Firewall and networking
- Rule for all of it: changes go through `settings.json` and `router-apply` (commit-confirm), never straight to the live - **Host-based routing on 80/443**: decide by TLS server name (or the `Host` header) between the router's web UI and a host behind it. It needs an SNI proxy on the router and loses the client address unless the host takes the PROXY protocol. Until then, expose the web UI and SSH on their own ports with `wan -> local` rules.
system; tokens have scopes; every call is logged. - **WireGuard**: live status (handshake, transfer) in the web UI; a tunnel with `dns = true` but no address.
- **IPv6** for forwards: a pinhole for a host behind the router is possible by hand (`wan -> lan` with an IPv6 `destination`), nothing helps with finding the host's delegated address. Also open: other LANs are IPv4-only (PD serves one LAN), DHCPv6 reservations, NPTv6.
- **Multi-WAN failover**, **QoS** (CAKE/SQM): deferred, not started.
Installer and web UI
- Installer: name the network cards (`router.interfaces`) during `router-setup`; booting the ISO on real firmware, installing over an existing system and the NIC name in the example are untested.
- A pebble-based ACME test (certificate issuance is only checked by evaluation).
- Monitoring beyond the dashboard (node exporter, nftables counters).
- Web UI statistics are read-only history of a few metrics; longer retention and per-LAN traffic are not there.
Hardware
- **aarch64**: no board is supported. The NanoPi R2S image (U-Boot, extlinux, first-boot password on the console) was built and then dropped; booting it was never tried. Open concerns if it comes back: 1 GB of RAM for on-device rebuilds, USB NIC naming, `router-update` and the release tooling for `aarch64-linux`.
On the user's own router (settings and infrastructure, not code)
- Port forwards for antigua's public names exist for 80 and 443 (hairpin on); the list of other public ports of antigua (k3s, Gitea, Syncthing, WoW, Traefik) is still unknown.
- Delete the old AAAA records in Cloudflare (DDNS no longer publishes IPv6).
- The WireGuard tunnel `family` has `allowManagement = true` and open firewall rules to the LAN; narrow them when the intended rules are known.
- `truenas.home.stinnesbeck.com` in `dns.hosts` is a leftover; no time zone is set (UTC); the wifi card is unused.
## Phases
1. **MVP** — flake with `router.*` schema: single WAN (DHCP), single LAN, NAT, basic firewall, DHCP + DNS. VM tests. *Done.*
2. **Real-world features** — IPv6 PD, VLANs, zone policy, port forwards, WireGuard. *Done.*
3. **Images and hardware** — installer ISO, `router-setup`, signed updates. *Done for x86_64;* read-only images were considered and dropped (see above), aarch64 was tried and dropped.
4. **Operations** — commit-confirm rollback, signed updates, backups, DDNS and certificates. *Done.* Monitoring beyond the dashboard and multi-WAN failover: not done.
5. **Web UI and remote access** — pages for every setting, themes, MCP server, REST API. *Done* (0.12.0); see "Not done yet".
+5
View File
@@ -91,6 +91,11 @@ let
inherit (options) router; inherit (options) router;
}; };
warningsAreErrors = false; warningsAreErrors = false;
# Where the options are declared is the source's store path, which differs
# between builds of the same release (an update's test sources, a copy
# into the store): without it the file is the same everywhere, so a router
# that rebuilds itself offline already has it.
transformOptions = opt: opt // { declarations = [ ]; };
}).optionsJSON }).optionsJSON
+ "/share/doc/nixos/options.json"; + "/share/doc/nixos/options.json";
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
) )
@@ -201,8 +202,9 @@ func openAPISpec(version string) map[string]any {
switch rt.method { switch rt.method {
case "GET": case "GET":
props, _ := tool.schema["properties"].(map[string]any) props, _ := tool.schema["properties"].(map[string]any)
required, _ := tool.schema["required"].([]string)
for name, p := range props { for name, p := range props {
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"]}) params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"], "required": slices.Contains(required, name)})
} }
if rt.tool == "get_logs" { if rt.tool == "get_logs" {
op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.") op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.")
+60 -4
View File
@@ -8,6 +8,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
) )
// rest sends a request to the REST API with a token. // rest sends a request to the REST API with a token.
@@ -99,15 +100,19 @@ func TestLogs(t *testing.T) {
want []string want []string
bad bool bad bool
}{ }{
{logQuery{}, []string{"--lines=100"}, false}, {logQuery{Unit: "router-webui"}, []string{"--lines=100"}, false},
{logQuery{}, nil, true},
{logQuery{Unit: "sshd.service"}, []string{"--unit=sshd.service"}, false},
{logQuery{Unit: "systemd-journald"}, nil, true},
{logQuery{Unit: "getty@tty1"}, nil, true},
{logQuery{Unit: "router-pppoe", Lines: 5, Since: "1 hour ago", Priority: "err", Grep: "LCP", Boot: &one}, {logQuery{Unit: "router-pppoe", Lines: 5, Since: "1 hour ago", Priority: "err", Grep: "LCP", Boot: &one},
[]string{"--lines=5", "--unit=router-pppoe", "--since=1 hour ago", "--priority=err", "--grep=LCP", "--boot=0"}, false}, []string{"--lines=5", "--unit=router-pppoe", "--since=1 hour ago", "--priority=err", "--grep=LCP", "--boot=0"}, false},
{logQuery{Unit: "kernel"}, []string{"-k"}, false}, {logQuery{Unit: "kernel"}, []string{"-k"}, false},
{logQuery{Unit: "--vacuum-time=1s"}, nil, true}, {logQuery{Unit: "--vacuum-time=1s"}, nil, true},
{logQuery{Unit: "a b"}, nil, true}, {logQuery{Unit: "a b"}, nil, true},
{logQuery{Lines: 5000}, nil, true}, {logQuery{Unit: "router-webui", Lines: 5000}, nil, true},
{logQuery{Priority: "loud"}, nil, true}, {logQuery{Unit: "router-webui", Priority: "loud"}, nil, true},
{logQuery{Since: "$(reboot)"}, nil, true}, {logQuery{Unit: "router-webui", Since: "$(reboot)"}, nil, true},
} }
for _, c := range cases { for _, c := range cases {
args, err := journalArgs(c.q) args, err := journalArgs(c.q)
@@ -178,3 +183,54 @@ func TestOpenAPI(t *testing.T) {
} }
} }
} }
func TestTokenExpiry(t *testing.T) {
a, _ := newTestApp(t)
h := a.routes()
tok, secret, err := a.tokens.CreateFor("short", "read", time.Hour)
if err != nil || tok.Expires.IsZero() {
t.Fatalf("create: %v %+v", err, tok)
}
if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != 200 {
t.Fatalf("valid token: %d", rec.Code)
}
// Move its end into the past.
toks := a.tokens.load()
toks[0].Expires = time.Now().Add(-time.Minute)
if err := a.tokens.save(toks); err != nil {
t.Fatal(err)
}
if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("expired token: %d", rec.Code)
}
if rec, _ := rpc(h, secret, "ping", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("expired token on MCP: %d", rec.Code)
}
_, forever, _ := a.tokens.Create("long", "read")
if rec := rest(h, forever, "GET", "/api/v1/status", "", nil); rec.Code != 200 {
t.Fatalf("token without expiry: %d", rec.Code)
}
}
func TestTokenEditNote(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
data, _ := os.ReadFile(sys.settingsPath())
os.WriteFile(sys.confirmedPath(), data, 0o600)
_, tok, _ := a.tokens.Create("assistant", "change")
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
e := rec.Header().Get("ETag")
if rec := rest(h, tok, "PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": e}); rec.Code != 200 {
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
}
cur, _ := os.ReadFile(sys.settingsPath())
if note := sys.tokenEditNote(cur); !strings.Contains(note, "assistant") {
t.Fatalf("no note: %q", note)
}
// A later edit by a person makes the note stale.
other := append([]byte(nil), cur...)
other = append(other, '\n', ' ')
if note := sys.tokenEditNote(other); note != "" {
t.Fatalf("stale note: %q", note)
}
}
+11
View File
@@ -216,3 +216,14 @@ func (s Settings) duplicateName() string {
} }
return "" return ""
} }
// devLabel is how a device is shown in a list: a VLAN with a chosen name as
// "dmz (lan.1002)", anything else as it is.
func devLabel(devices []Device, name string) string {
for _, d := range devices {
if d.Name == name && d.Custom != "" {
return d.Custom + " (" + name + ")"
}
}
return name
}
+9
View File
@@ -129,3 +129,12 @@ func TestRenameVLAN(t *testing.T) {
t.Error("a card and a VLAN with the same name") t.Error("a card and a VLAN with the same name")
} }
} }
func TestDevLabel(t *testing.T) {
devs := []Device{{Name: "lan.30", Kind: "VLAN", Custom: "cams"}, {Name: "lan.40", Kind: "VLAN"}, {Name: "lan", Kind: "physical"}}
for name, want := range map[string]string{"lan.30": "cams (lan.30)", "lan.40": "lan.40", "lan": "lan", "other": "other"} {
if got := devLabel(devs, name); got != want {
t.Errorf("%s: %q, want %q", name, got, want)
}
}
}
+33 -9
View File
@@ -13,9 +13,15 @@ import (
// an option. // an option.
var ( var (
unitRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@:._-]{0,79}$`) unitRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@:._-]{0,79}$`)
timeRe = regexp.MustCompile(`^[0-9A-Za-z :.+/_,-]{1,40}$`) timeRe = regexp.MustCompile(`^[0-9A-Za-z :.+/_,-]{1,40}$`)
prioRe = regexp.MustCompile(`^([0-7]|emerg|alert|crit|err|warning|notice|info|debug)$`) prioRe = regexp.MustCompile(`^([0-7]|emerg|alert|crit|err|warning|notice|info|debug)$`)
// The journal as a whole is not offered, only the units that run the router
// (and the kernel): a read token should not read everything on the box.
logUnits = []string{
"router-*", "kea-dhcp4-server", "unbound", "systemd-networkd", "systemd-resolved",
"systemd-timesyncd", "nftables", "ddclient", "acme-*", "sshd",
}
maxLogs = 1000 maxLogs = 1000
logSize = 256 << 10 logSize = 256 << 10
) )
@@ -45,10 +51,13 @@ func journalArgs(q logQuery) ([]string, error) {
switch { switch {
case q.Unit == "kernel": case q.Unit == "kernel":
args = append(args, "-k") args = append(args, "-k")
case q.Unit != "": case q.Unit == "":
if !unitRe.MatchString(q.Unit) { return nil, argError("unit is required: kernel or one of " + strings.Join(logUnits, ", "))
return nil, argError("unit: a unit name like router-webui or router-pppoe.service, or kernel") case !unitRe.MatchString(q.Unit):
} return nil, argError("unit: a unit name like router-webui or router-pppoe.service, or kernel")
case !logUnitAllowed(q.Unit):
return nil, argError("unit " + q.Unit + " is not offered; use kernel or one of " + strings.Join(logUnits, ", "))
default:
args = append(args, "--unit="+q.Unit) args = append(args, "--unit="+q.Unit)
} }
for _, t := range []struct{ flag, v string }{{"--since=", q.Since}, {"--until=", q.Until}} { for _, t := range []struct{ flag, v string }{{"--since=", q.Since}, {"--until=", q.Until}} {
@@ -81,13 +90,27 @@ func journalArgs(q logQuery) ([]string, error) {
return args, nil return args, nil
} }
func logUnitAllowed(unit string) bool {
unit = strings.TrimSuffix(unit, ".service")
for _, p := range logUnits {
if prefix, ok := strings.CutSuffix(p, "*"); ok {
if strings.HasPrefix(unit, prefix) {
return true
}
} else if unit == p {
return true
}
}
return false
}
var logsTool = mcpTool{ var logsTool = mcpTool{
name: "get_logs", scope: scopeRead, name: "get_logs", scope: scopeRead,
description: "Lines of the system journal, oldest first (the last `lines` that match). Without `unit` all units. Useful units: router-pppoe (the PPPoE connection: connects and disconnects), router-webui, router-apply, systemd-networkd, kea-dhcp4-server, nftables, wireguard-*; `kernel` for the kernel log. Search a text with `grep`, narrow by `since` (\"1 hour ago\", \"2026-10-31 10:00:00\"), `priority` (err = errors and worse) or `boot` (0 this boot, -1 the previous).", description: "Lines of the system journal, oldest first (the last `lines` that match). The unit is required: `kernel` or one of router-* (router-pppoe is the PPPoE connection: connects and disconnects; router-webui, router-apply, router-update, ...), kea-dhcp4-server, unbound, systemd-networkd, systemd-resolved, systemd-timesyncd, nftables, ddclient, acme-*, sshd. Search a text with `grep`, narrow by `since` (\"1 hour ago\", \"2026-10-31 10:00:00\"), `priority` (err = errors and worse) or `boot` (0 this boot, -1 the previous).",
schema: map[string]any{ schema: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"unit": map[string]any{"type": "string", "description": "A systemd unit (router-pppoe, router-webui.service, ...) or `kernel`."}, "unit": map[string]any{"type": "string", "description": "Required: `kernel` or a unit: router-pppoe, router-webui.service, kea-dhcp4-server, unbound, systemd-networkd, nftables, ddclient, ..."},
"lines": map[string]any{"type": "integer", "minimum": 1, "maximum": maxLogs, "default": 100, "description": "How many lines at most."}, "lines": map[string]any{"type": "integer", "minimum": 1, "maximum": maxLogs, "default": 100, "description": "How many lines at most."},
"since": map[string]any{"type": "string", "description": "Only entries since then: \"1 hour ago\", \"yesterday\", \"2026-10-31 10:00:00\"."}, "since": map[string]any{"type": "string", "description": "Only entries since then: \"1 hour ago\", \"yesterday\", \"2026-10-31 10:00:00\"."},
"until": map[string]any{"type": "string", "description": "Only entries until then."}, "until": map[string]any{"type": "string", "description": "Only entries until then."},
@@ -95,6 +118,7 @@ var logsTool = mcpTool{
"grep": map[string]any{"type": "string", "description": "Only entries matching this regular expression (case insensitive)."}, "grep": map[string]any{"type": "string", "description": "Only entries matching this regular expression (case insensitive)."},
"boot": map[string]any{"type": "integer", "maximum": 0, "description": "0 is the current boot, -1 the one before, ... Without it all boots the journal holds."}, "boot": map[string]any{"type": "integer", "maximum": 0, "description": "0 is the current boot, -1 the one before, ... Without it all boots the journal holds."},
}, },
"required": []string{"unit"},
}, },
run: func(a *app, raw json.RawMessage) (any, error) { run: func(a *app, raw json.RawMessage) (any, error) {
var q logQuery var q logQuery
+1
View File
@@ -252,6 +252,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
"chartH": func() float64 { return chartH }, "chartH": func() float64 { return chartH },
"join": strings.Join, "join": strings.Join,
"choices": choices, "choices": choices,
"devLabel": devLabel,
"tform": func(t WGTunnel, csrf string, isNew bool) map[string]any { "tform": func(t WGTunnel, csrf string, isNew bool) map[string]any {
return map[string]any{"T": t, "CSRF": csrf, "New": isNew} return map[string]any{"T": t, "CSRF": csrf, "New": isNew}
}, },
+7
View File
@@ -246,6 +246,13 @@ func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, erro
entry.Error = err.Error() entry.Error = err.Error()
} }
a.sys.audit(entry) a.sys.audit(entry)
if err == nil && tool.name == "update_settings" {
if m, ok := res.(map[string]any); ok {
if e, ok := m["etag"].(string); ok {
a.sys.noteTokenEdit(tok.Name, e)
}
}
}
return res, err return res, err
} }
+23 -9
View File
@@ -47,7 +47,13 @@ nav .group > span { display: block; padding: 0 .7rem .25rem; font-size: .7rem; t
nav form { margin-top: 1rem; } nav form { margin-top: 1rem; }
nav button.link { background: none; border: 0; padding: .38rem .7rem; font-weight: 400; cursor: pointer; } nav button.link { background: none; border: 0; padding: .38rem .7rem; font-weight: 400; cursor: pointer; }
main { max-width: 74rem; padding: 1.5rem 2rem 3rem; } main { padding: 1.5rem clamp(1rem, 3vw, 3rem) 3rem; }
/* Pages of independent panels flow into up to three columns on wide screens */
.cols { columns: 38rem 3; column-gap: 1.5rem; }
.cols > section, .cols > fieldset, .cols > form > fieldset { break-inside: avoid; }
.cols > section.busy, .cols > section.pending { column-span: all; }
.menu-toggle { position: absolute; opacity: 0; pointer-events: none; }
.menu-button { display: none; }
h1 { font-size: 1.45rem; font-weight: 600; letter-spacing: -.01em; margin: .25rem 0 1.25rem; } h1 { font-size: 1.45rem; font-weight: 600; letter-spacing: -.01em; margin: .25rem 0 1.25rem; }
h2 { font-size: .95rem; font-weight: 600; margin: 0 0 .6rem; } h2 { font-size: .95rem; font-weight: 600; margin: 0 0 .6rem; }
section { margin: 0 0 1.5rem; } section { margin: 0 0 1.5rem; }
@@ -66,6 +72,10 @@ dd { margin: 0; overflow-wrap: anywhere; }
fieldset { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); margin: 0 0 1rem; padding: 0 0 1rem; min-width: 0; overflow: hidden; } fieldset { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); margin: 0 0 1rem; padding: 0 0 1rem; min-width: 0; overflow: hidden; }
legend { float: left; width: 100%; margin: 0 0 1rem; padding: .6rem 1rem; font-size: .95rem; font-weight: 600; background: var(--head); border-bottom: 1px solid var(--line); } legend { float: left; width: 100%; margin: 0 0 1rem; padding: .6rem 1rem; font-size: .95rem; font-weight: 600; background: var(--head); border-bottom: 1px solid var(--line); }
legend ~ * { clear: both; } legend ~ * { clear: both; }
/* A section as a panel (its h2 is the header bar), for pages made of sections */
section.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; padding: 0 0 1rem; }
section.panel > h2 { margin: 0 0 1rem; padding: .6rem 1rem; font-size: .95rem; font-weight: 600; background: var(--head); border-bottom: 1px solid var(--line); }
section.panel > :not(h2) { margin-left: 1rem; margin-right: 1rem; }
fieldset > :not(legend):not(input[type=hidden]) { margin-left: 1rem; margin-right: 1rem; } fieldset > :not(legend):not(input[type=hidden]) { margin-left: 1rem; margin-right: 1rem; }
fieldset > .scroll { margin-left: 0; margin-right: 0; padding: 0 1rem; overflow-x: auto; } fieldset > .scroll { margin-left: 0; margin-right: 0; padding: 0 1rem; overflow-x: auto; }
@@ -75,6 +85,7 @@ input, select, textarea, button { font: inherit; color: var(--fg); background: v
input:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--line)); } input:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--line)); }
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); } input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); }
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 65%, transparent); } input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 65%, transparent); }
textarea[rows] { height: min(calc(1.45em * var(--rows, 30)), 65vh); }
textarea { width: 100%; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; } textarea { width: 100%; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; }
.narrow { max-width: 24rem; display: flex; flex-direction: column; gap: .8rem; } .narrow { max-width: 24rem; display: flex; flex-direction: column; gap: .8rem; }
.narrow label { margin: 0; } .narrow label { margin: 0; }
@@ -116,17 +127,20 @@ pre code { background: none; padding: 0; }
body.bare { padding-left: 0; } body.bare { padding-left: 0; }
.bare header { position: static; width: auto; flex-direction: row; background: none; padding: 1rem 1.25rem; } .bare header { position: static; width: auto; flex-direction: row; background: none; padding: 1rem 1.25rem; }
.bare header > strong { color: var(--fg); } .bare header > strong { color: var(--fg); }
.bare main { margin: 0 auto; } .bare main { margin: 0 auto; max-width: 30rem; }
@media (max-width: 50rem) { @media (max-width: 50rem) {
body { padding-left: 0; } body { padding-left: 0; }
header { position: static; width: auto; flex-direction: row; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; padding: .6rem 1rem; } header { position: sticky; top: 0; bottom: auto; width: auto; flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .25rem 1rem; padding: .5rem .75rem; max-height: 100vh; }
nav { flex-direction: row; flex-wrap: wrap; align-items: center; gap: .1rem .25rem; } .menu-button { display: inline-block; margin: 0; padding: .3rem .85rem; border: 1px solid var(--side-muted); border-radius: var(--radius); color: var(--side-fg); font-size: .9rem; font-weight: 500; cursor: pointer; }
nav > a, nav .group a, nav button.link { width: auto; padding: .3rem .55rem; } header nav { display: none; width: 100%; max-height: calc(100vh - 4rem); overflow-y: auto; }
nav .group { display: contents; } .menu-toggle:checked ~ nav { display: flex; }
nav .group > span, nav form { display: none; } .menu-toggle:focus-visible ~ .menu-button { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent); }
nav form:has(button) { display: block; margin: 0; } main { padding: .75rem .6rem 2rem; }
main { padding: 1rem; } h1 { font-size: 1.25rem; margin-bottom: .75rem; }
fieldset > :not(legend):not(input[type=hidden]), section.panel > :not(h2) { margin-left: .75rem; margin-right: .75rem; }
fieldset > .scroll { padding: 0 .75rem; }
.cols { columns: auto; }
} }
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; } .ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
.ranges a { padding: .25rem .8rem; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; color: var(--fg); background: var(--surface); } .ranges a { padding: .25rem .8rem; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; color: var(--fg); background: var(--surface); }
+4
View File
@@ -59,6 +59,7 @@ type Status struct {
Log string Log string
RollbackIn time.Duration // > 0 while a change awaits confirmation RollbackIn time.Duration // > 0 while a change awaits confirmation
Unapplied bool // settings.json differs from the confirmed settings Unapplied bool // settings.json differs from the confirmed settings
TokenEdit string // who edited them through the API, when the saved settings are still that edit
ConfirmRunning bool ConfirmRunning bool
} }
@@ -81,6 +82,9 @@ func (s System) Status() Status {
cur, err1 := os.ReadFile(s.settingsPath()) cur, err1 := os.ReadFile(s.settingsPath())
conf, err2 := os.ReadFile(s.confirmedPath()) conf, err2 := os.ReadFile(s.confirmedPath())
st.Unapplied = err1 == nil && err2 == nil && !sameJSON(cur, conf) st.Unapplied = err1 == nil && err2 == nil && !sameJSON(cur, conf)
if st.Unapplied {
st.TokenEdit = s.tokenEditNote(cur)
}
return st return st
} }
+9 -6
View File
@@ -1,5 +1,5 @@
{{define "content"}} {{define "content"}}
<section> <section class="panel">
<h2>Settings</h2> <h2>Settings</h2>
<p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>. <p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>.
They are checked when applied; nothing changes until then.</p> They are checked when applied; nothing changes until then.</p>
@@ -10,7 +10,9 @@
</form> </form>
</section> </section>
<section> <div class="cols">
<section class="panel">
<h2>Appearance</h2> <h2>Appearance</h2>
<form method="post" action="/prefs" class="narrow"> <form method="post" action="/prefs" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
@@ -26,7 +28,7 @@
</form> </form>
</section> </section>
<section> <section class="panel">
<h2>Dashboard</h2> <h2>Dashboard</h2>
<form method="post" action="/prefs" class="narrow"> <form method="post" action="/prefs" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
@@ -37,7 +39,7 @@
</form> </form>
</section> </section>
<section> <section class="panel">
<h2>Password</h2> <h2>Password</h2>
<p>One password for this web UI and for root on the console and over SSH.</p> <p>One password for this web UI and for root on the console and over SSH.</p>
<form method="post" action="/password" class="narrow"> <form method="post" action="/password" class="narrow">
@@ -49,17 +51,18 @@
</form> </form>
</section> </section>
<section> <section class="panel">
<h2>Reboot</h2> <h2>Reboot</h2>
<p>Restarts the router; routing stops for a minute or two. A new kernel from an update only runs after this.</p> <p>Restarts the router; routing stops for a minute or two. A new kernel from an update only runs after this.</p>
<form method="post" action="/reboot" data-confirm="Reboot the router now?"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Reboot</button></form> <form method="post" action="/reboot" data-confirm="Reboot the router now?"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Reboot</button></form>
</section> </section>
<section> <section class="panel">
<h2>Backup</h2> <h2>Backup</h2>
<p>Everything on <code>/var/lib/router</code>: settings, PPPoE password, WireGuard keys, passwords, the web UI's <p>Everything on <code>/var/lib/router</code>: settings, PPPoE password, WireGuard keys, passwords, the web UI's
certificate and the dashboard history. Keep the file safe. To restore, put it on a USB stick and run certificate and the dashboard history. Keep the file safe. To restore, put it on a USB stick and run
<code>router-setup</code> from the installer: it offers to install from it.</p> <code>router-setup</code> from the installer: it offers to install from it.</p>
<form method="post" action="/backup"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Download backup</button></form> <form method="post" action="/backup"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Download backup</button></form>
</section> </section>
</div>
{{end}} {{end}}
@@ -1,4 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="cols">
{{with .Data}} {{with .Data}}
{{$e := .Errors}}{{$f := .Form}} {{$e := .Errors}}{{$f := .Form}}
<section> <section>
@@ -42,4 +43,5 @@
{{else}}<p class="muted">Nothing yet. After you save and apply, the router orders the certificate (a minute or two); renewals follow 30 days before it expires and need no restart.</p>{{end}} {{else}}<p class="muted">Nothing yet. After you save and apply, the router orders the certificate (a minute or two); renewals follow 30 days before it expires and need no restart.</p>{{end}}
</section> </section>
{{end}} {{end}}
</div>
{{end}} {{end}}
+1
View File
@@ -3,6 +3,7 @@
{{if .Diff}} {{if .Diff}}
<p>The saved settings differ from the confirmed (running) ones: <code>-</code> lines go away, <code>+</code> lines are new. <p>The saved settings differ from the confirmed (running) ones: <code>-</code> lines go away, <code>+</code> lines are new.
Nothing is applied until you press Apply.</p> Nothing is applied until you press Apply.</p>
{{with .TokenEdit}}<p class="error">{{.}}</p>{{end}}
<pre class="log">{{.Diff}}</pre> <pre class="log">{{.Diff}}</pre>
<div class="actions"> <div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button>Apply</button></form> <form method="post" action="/apply"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button>Apply</button></form>
+2
View File
@@ -1,4 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="cols">
{{with .Data}} {{with .Data}}
{{$e := .Errors}}{{$f := .Form}} {{$e := .Errors}}{{$f := .Form}}
<p class="hint">Keeps DNS records at your provider pointing at the router's internet addresses: it checks regularly and <p class="hint">Keeps DNS records at your provider pointing at the router's internet addresses: it checks regularly and
@@ -41,4 +42,5 @@
{{else}}<p class="muted">Nothing yet. Once applied, the log of ddclient shows up here.</p>{{end}} {{else}}<p class="muted">Nothing yet. Once applied, the log of ddclient shows up here.</p>{{end}}
</section> </section>
{{end}} {{end}}
</div>
{{end}} {{end}}
+2
View File
@@ -11,6 +11,8 @@
<header> <header>
<strong>router</strong> <strong>router</strong>
{{if .LoggedIn}} {{if .LoggedIn}}
<input type="checkbox" id="menu" class="menu-toggle" aria-label="Menu">
<label for="menu" class="menu-button">Menu</label>
<nav> <nav>
<a href="/"{{if eq .Title "Overview"}} aria-current="page"{{end}}>Overview</a> <a href="/"{{if eq .Title "Overview"}} aria-current="page"{{end}}>Overview</a>
<div class="group"><span>Network</span> <div class="group"><span>Network</span>
+2 -2
View File
@@ -63,7 +63,7 @@
<legend>Uplink (WAN)</legend> <legend>Uplink (WAN)</legend>
<label>Interface <label>Interface
<select name="wan.interface">{{$wan := .Form.WAN.Interface}} <select name="wan.interface">{{$wan := .Form.WAN.Interface}}
{{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{.}}</option>{{end}} {{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{devLabel $.Data.Devices .}}</option>{{end}}
</select> </select>
</label> </label>
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}} {{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
@@ -95,7 +95,7 @@
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td> <td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
<td><select name="lan.{{$i}}.interface">{{$cur := $l.Interface}} <td><select name="lan.{{$i}}.interface">{{$cur := $l.Interface}}
{{if not $cur}}<option value="" selected></option>{{end}} {{if not $cur}}<option value="" selected></option>{{end}}
{{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{.}}</option>{{end}} {{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{devLabel $.Data.Devices .}}</option>{{end}}
</select></td> </select></td>
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td> <td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td> <td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
@@ -1,4 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="cols">
{{$s := .Data.Status}} {{$s := .Data.Status}}
{{if $s.Applying}} {{if $s.Applying}}
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section> <section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
@@ -14,6 +15,7 @@
{{else if $s.Unapplied}} {{else if $s.Unapplied}}
<section class="pending"> <section class="pending">
<p>The settings have changes that are not part of the confirmed system. <a href="/changes">Review the changes</a>.</p> <p>The settings have changes that are not part of the confirmed system. <a href="/changes">Review the changes</a>.</p>
{{with $s.TokenEdit}}<p><strong>{{.}}</strong></p>{{end}}
<div class="actions"> <div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form> <form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form> <form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>
@@ -44,4 +46,5 @@
</section> </section>
{{end}} {{end}}
<script src="/static/dashboard.js" defer></script> <script src="/static/dashboard.js" defer></script>
</div>
{{end}} {{end}}
+11 -1
View File
@@ -23,8 +23,9 @@
{{if .Tokens}} {{if .Tokens}}
<div class="scroll"> <div class="scroll">
<table> <table>
<tr><th>Name</th><th>Access</th><th>Created</th><th>Last used</th><th></th></tr> <tr><th>Name</th><th>Access</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
{{range .Tokens}}<tr><td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created.Format "2006-01-02"}}</td> {{range .Tokens}}<tr><td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created.Format "2006-01-02"}}</td>
<td>{{if .Expires.IsZero}}<span class="hint">never</span>{{else if .Expired}}<span class="bad">expired {{.Expires.Format "2006-01-02"}}</span>{{else}}{{.Expires.Format "2006-01-02"}}{{end}}</td>
<td>{{if .LastUsed.IsZero}}<span class="hint">never</span>{{else}}{{.LastUsed.Format "2006-01-02 15:04"}}{{end}}</td> <td>{{if .LastUsed.IsZero}}<span class="hint">never</span>{{else}}{{.LastUsed.Format "2006-01-02 15:04"}}{{end}}</td>
<td><form method="post" action="/tokens/revoke" data-confirm="Revoke this token?"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><button class="link">revoke</button></form></td></tr>{{end}} <td><form method="post" action="/tokens/revoke" data-confirm="Revoke this token?"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><button class="link">revoke</button></form></td></tr>{{end}}
</table> </table>
@@ -43,6 +44,15 @@
<option value="change">change: also edit the settings</option> <option value="change">change: also edit the settings</option>
</select> </select>
</label> </label>
<label>Valid for
<select name="valid">
<option value="1d">1 day</option>
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
<option value="never">no limit</option>
</select>
</label>
<button>Create token</button> <button>Create token</button>
</fieldset> </fieldset>
</form> </form>
+2
View File
@@ -1,4 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="cols">
{{$d := .Data}} {{$d := .Data}}
<section> <section>
<h2>Installed</h2> <h2>Installed</h2>
@@ -60,4 +61,5 @@
<pre class="log">{{$d.Status.Log}}</pre> <pre class="log">{{$d.Status.Log}}</pre>
</section> </section>
{{end}} {{end}}
</div>
{{end}} {{end}}
@@ -132,7 +132,6 @@
</section> </section>
<section> <section>
<h2>New tunnel</h2>
{{template "tunnelform" (tform .New $.CSRF true)}} {{template "tunnelform" (tform .New $.CSRF true)}}
</section> </section>
{{end}} {{end}}
+47 -2
View File
@@ -40,8 +40,12 @@ type Token struct {
Hash string `json:"hash"` // hex SHA-256 of the secret Hash string `json:"hash"` // hex SHA-256 of the secret
Created time.Time `json:"created"` Created time.Time `json:"created"`
LastUsed time.Time `json:"lastUsed,omitzero"` LastUsed time.Time `json:"lastUsed,omitzero"`
Expires time.Time `json:"expires,omitzero"` // zero: never
} }
// Expired tells whether the token's time is up.
func (k Token) Expired() bool { return !k.Expires.IsZero() && time.Now().After(k.Expires) }
func (s System) tokensPath() string { return filepath.Join(s.StateDir, "webui", "tokens.json") } func (s System) tokensPath() string { return filepath.Join(s.StateDir, "webui", "tokens.json") }
func (s System) auditPath() string { return filepath.Join(s.StateDir, "webui", "audit.log") } func (s System) auditPath() string { return filepath.Join(s.StateDir, "webui", "audit.log") }
@@ -91,8 +95,14 @@ func (t *tokenStore) List() []Token {
return t.load() return t.load()
} }
// Create makes a token and returns its secret, which is not stored. // Create makes a token that does not expire and returns its secret, which is
// not stored.
func (t *tokenStore) Create(name, scope string) (Token, string, error) { func (t *tokenStore) Create(name, scope string) (Token, string, error) {
return t.CreateFor(name, scope, 0)
}
// CreateFor makes a token that stops working after valid (0: never).
func (t *tokenStore) CreateFor(name, scope string, valid time.Duration) (Token, string, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
switch { switch {
case name == "" || len(name) > 40: case name == "" || len(name) > 40:
@@ -116,6 +126,9 @@ func (t *tokenStore) Create(name, scope string) (Token, string, error) {
} }
secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(raw) secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
tok := Token{ID: hex.EncodeToString(id), Name: name, Scope: scope, Hash: hashSecret(secret), Created: time.Now().UTC().Truncate(time.Second)} tok := Token{ID: hex.EncodeToString(id), Name: name, Scope: scope, Hash: hashSecret(secret), Created: time.Now().UTC().Truncate(time.Second)}
if valid > 0 {
tok.Expires = tok.Created.Add(valid)
}
return tok, secret, t.save(append(toks, tok)) return tok, secret, t.save(append(toks, tok))
} }
@@ -146,7 +159,7 @@ func (t *tokenStore) Lookup(secret string) (Token, bool) {
found = i found = i
} }
} }
if found < 0 { if found < 0 || toks[found].Expired() {
return Token{}, false return Token{}, false
} }
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
@@ -224,3 +237,35 @@ func (s System) RecentAudit(n int) []AuditEntry {
} }
return all return all
} }
// A note of the last settings edit made through the API, so that the web UI
// can tell the person who reviews it where it came from. It only counts while
// the saved settings are still what that edit left (etag): a later edit in
// the web UI or an apply makes it stale.
type tokenEdit struct {
Token string `json:"token"`
Time time.Time `json:"time"`
Etag string `json:"etag"`
}
func (s System) tokenEditPath() string { return filepath.Join(s.StateDir, "webui", "token-edit.json") }
func (s System) noteTokenEdit(token, etag string) {
data, _ := json.Marshal(tokenEdit{Token: token, Time: time.Now().UTC().Truncate(time.Second), Etag: etag})
_ = os.MkdirAll(filepath.Dir(s.tokenEditPath()), 0o700)
_ = writeFileAtomic(s.tokenEditPath(), append(data, '\n'), 0o600)
}
// tokenEditNote says who made the unapplied changes, or "" when the saved
// settings (cur) are not the API's edit.
func (s System) tokenEditNote(cur []byte) string {
data, err := os.ReadFile(s.tokenEditPath())
if err != nil {
return ""
}
var e tokenEdit
if json.Unmarshal(data, &e) != nil || e.Etag != etag(cur) {
return ""
}
return fmt.Sprintf("These changes were made through the API with the token %q at %s.", e.Token, e.Time.Format("2006-01-02 15:04"))
}
+12 -2
View File
@@ -4,8 +4,14 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
) )
// How long a new token works (the form's choices; 0 is never).
var tokenLifetimes = map[string]time.Duration{
"1d": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, "90d": 90 * 24 * time.Hour, "never": 0,
}
type tokensData struct { type tokensData struct {
Tokens []Token Tokens []Token
Audit []AuditEntry Audit []AuditEntry
@@ -24,7 +30,11 @@ func (a *app) tokensPage(w http.ResponseWriter, r *http.Request, sess session) {
} }
func (a *app) tokenCreate(w http.ResponseWriter, r *http.Request, sess session) { func (a *app) tokenCreate(w http.ResponseWriter, r *http.Request, sess session) {
tok, secret, err := a.tokens.Create(r.PostFormValue("name"), r.PostFormValue("scope")) valid, ok := tokenLifetimes[r.PostFormValue("valid")]
if !ok {
valid = tokenLifetimes["30d"]
}
tok, secret, err := a.tokens.CreateFor(r.PostFormValue("name"), r.PostFormValue("scope"), valid)
p := page{Title: "API access", CSRF: sess.csrf, LoggedIn: true} p := page{Title: "API access", CSRF: sess.csrf, LoggedIn: true}
d := a.tokensData(r) d := a.tokensData(r)
if err != nil { if err != nil {
@@ -50,6 +60,6 @@ func (a *app) tokenRevoke(w http.ResponseWriter, r *http.Request, _ session) {
// ones, for a person to review before applying. // ones, for a person to review before applying.
func (a *app) changesPage(w http.ResponseWriter, r *http.Request, sess session) { func (a *app) changesPage(w http.ResponseWriter, r *http.Request, sess session) {
a.render(w, "changes", page{Title: "Changes", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{ a.render(w, "changes", page{Title: "Changes", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{
"Diff": a.sys.pendingChanges(), "Unapplied": a.sys.Status().Unapplied, "Diff": a.sys.pendingChanges(), "Unapplied": a.sys.Status().Unapplied, "TokenEdit": a.sys.Status().TokenEdit,
}}) }})
} }
+35 -1
View File
@@ -228,7 +228,7 @@ pkgs.testers.runNixOSTest {
}) })
]; ];
router = { lib, ... }: { router = { lib, pkgs, ... }: {
imports = [ self.nixosModules.router ]; imports = [ self.nixosModules.router ];
virtualisation.vlans = [ virtualisation.vlans = [
1 1
@@ -348,6 +348,27 @@ pkgs.testers.runNixOSTest {
protocol = "tcp"; protocol = "tcp";
ports = [ 8081 ]; ports = [ 8081 ];
} }
# The router itself: no destination, only these senders and ports.
{
from = "wan";
to = "local";
protocol = "tcp";
ports = [ 8022 ];
sources = [ "${ispV4}/32" ];
}
{
from = "wan";
to = "local";
protocol = "tcp";
ports = [ 8023 ];
sources = [ "192.0.2.0/24" ];
}
{
from = "main";
to = "local";
protocol = "tcp";
ports = [ 8024 ];
}
]; ];
}; };
}; };
@@ -355,6 +376,7 @@ pkgs.testers.runNixOSTest {
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC # Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
# against a fake ISP resolver that cannot provide it; neither applies to # against a fake ISP resolver that cannot provide it; neither applies to
# a real forwarder, so relax both for the made-up test domain only. # a real forwarder, so relax both for the made-up test domain only.
environment.systemPackages = [ pkgs.netcat ];
services.unbound.settings.server = { services.unbound.settings.server = {
local-zone = [ "test. transparent" ]; local-zone = [ "test. transparent" ];
domain-insecure = [ "example.test" ]; domain-insecure = [ "example.test" ];
@@ -465,6 +487,18 @@ pkgs.testers.runNixOSTest {
client_ip = client.succeed("ip -4 -o addr show dev eth1 | awk '{print $4}' | cut -d/ -f1").strip() client_ip = client.succeed("ip -4 -o addr show dev eth1 | awk '{print $4}' | cut -d/ -f1").strip()
upstream.fail(f"ping -c 1 -W 2 {client_ip}") upstream.fail(f"ping -c 1 -W 2 {client_ip}")
with subtest("a wan -> local rule opens the router's port to the named sender only"):
for port in (8022, 8023, 8024, 8025):
router.succeed(f"systemd-run --unit=probe{port} ${pkgs.bash}/bin/bash -c 'while true; do ${pkgs.netcat}/bin/nc -l {port} < /dev/null; done'")
router.wait_until_succeeds("ss -ltn | grep -c ':8025 ' >/dev/null", timeout=30)
upstream.succeed("nc -z -w 2 ${routerV4} 8022")
# Open for another sender only, for the LAN only, and not at all.
upstream.fail("nc -z -w 2 ${routerV4} 8023")
upstream.fail("nc -z -w 2 ${routerV4} 8024")
upstream.fail("nc -z -w 2 ${routerV4} 8025")
client.succeed("nc -z -w 2 192.168.10.1 8024")
client.fail("nc -z -w 2 192.168.10.1 8022")
with subtest("router gets an IPv6 address, the delegated /64 and a default route"): with subtest("router gets an IPv6 address, the delegated /64 and a default route"):
router.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:1::100") router.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:1::100")
router.wait_until_succeeds("ip -6 addr show dev eth2 scope global | grep -q 2001:db8:2:") router.wait_until_succeeds("ip -6 addr show dev eth2 scope global | grep -q 2001:db8:2:")