Compare commits
66
Commits
main
...
installed-webui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ee28b812b | ||
|
|
b37d8a0961 | ||
|
|
1efa068443 | ||
|
|
3d5ec77f57 | ||
|
|
f4a192d416 | ||
|
|
dd03127a24 | ||
|
|
4ac8c6ea6f | ||
|
|
56d6e153ae | ||
|
|
af614ea501 | ||
|
|
bda07ae163 | ||
|
|
5572172904 | ||
|
|
879b618981 | ||
|
|
98b0ebc130 | ||
|
|
e3209569bd | ||
|
|
876080d3f5 | ||
|
|
64e08fb485 | ||
|
|
42ceb78d2b | ||
|
|
6d8e9d4166 | ||
|
|
9646e67337 | ||
|
|
4e935d4f04 | ||
|
|
c7b5368ba3 | ||
|
|
b1fa7b1396 | ||
|
|
586274af4e | ||
|
|
95ab7223b9 | ||
|
|
0900a46f1d | ||
|
|
caf2c6b26d | ||
|
|
a32bf42777 | ||
|
|
a58600823f | ||
|
|
3e9c2e4a7a | ||
|
|
cfa07634e8 | ||
|
|
a36cebc827 | ||
|
|
c198ffbe37 | ||
|
|
d3e97caff1 | ||
|
|
ec2a10658f | ||
|
|
9f9628eecc | ||
|
|
eda7080d94 | ||
|
|
e2986f1418 | ||
|
|
605753b20e | ||
|
|
e597349fa2 | ||
|
|
a01214707a | ||
|
|
2eab735f59 | ||
|
|
84f6002d05 | ||
|
|
54040a1402 | ||
|
|
f839e7fffb | ||
|
|
ca05f3ab36 | ||
|
|
574748806f | ||
|
|
5983eadae2 | ||
|
|
b9c63273e4 | ||
|
|
2a111d012b | ||
|
|
e684ddb06c | ||
|
|
8f65587f99 | ||
|
|
b12bb5aa51 | ||
|
|
f01d4c14fb | ||
|
|
f5909fbdcb | ||
|
|
5ddff44a35 | ||
|
|
753459f5d7 | ||
|
|
2c8d5562f9 | ||
|
|
a4f4ec12a3 | ||
|
|
daa70fe7ac | ||
|
|
406196adce | ||
|
|
c395a43a78 | ||
|
|
70dddad206 | ||
|
|
b5429ad876 | ||
|
|
0653b5cf2f | ||
|
|
3f8f2f4199 | ||
|
|
30ab1a0ed6 |
@@ -1,3 +1,8 @@
|
||||
result
|
||||
result-*
|
||||
.nixos-test-history
|
||||
/image
|
||||
/installer
|
||||
/dist
|
||||
pkgs/router-webui/router-webui
|
||||
pkgs/router-webui/static/redoc.standalone.js
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Developing
|
||||
|
||||
Three tools, from instant to slow. Run them from the repository root; the
|
||||
flake only sees files git knows about, so `git add` new files first.
|
||||
|
||||
## `nix run .#webui-dev`: the web UI on your machine
|
||||
|
||||
Sample settings, sample DHCP leases and your real interface counters, on
|
||||
<https://127.0.0.1:8443> (password `devpassword1`). Templates and CSS are
|
||||
re-read on every request: edit, reload the browser. A template with a typo
|
||||
keeps the last good version and logs the error. Pings need root and show "no
|
||||
answer"; Apply does nothing useful here.
|
||||
|
||||
## `nix run .#fast`: no VMs
|
||||
|
||||
Go formatting, vet and tests, then builds the installer, a router built from
|
||||
settings (through the release) and the home gateway. That runs shellcheck over
|
||||
the scripts and evaluates the modules: most failures of the VM tests
|
||||
(typos, type errors, a script that does not build) show up here, in about a
|
||||
minute (half of that when little changed).
|
||||
|
||||
nix run .#fast -- --drivers also builds every VM test's driver: type-checks
|
||||
the test scripts, builds the VMs' systems
|
||||
nix run .#fast -- --all also the checks that need no VM and `nix flake check`
|
||||
|
||||
## `nix run .#test -- NAME...`: VM tests
|
||||
|
||||
One at a time (each needs gigabytes of memory), a line per test with its time,
|
||||
and for a failure the step that failed and what it said; the full log is in
|
||||
`${TMPDIR:-/tmp}/router-test-logs/NAME.log`.
|
||||
|
||||
nix run .#test -- update setup
|
||||
nix run .#test -- --all
|
||||
nix run .#test -- --list
|
||||
|
||||
`nix run .#test -- -i update` builds the test's interactive driver and gives a
|
||||
Python prompt: `start_all()`, `target.succeed("...")`, `test_script()`. Boot
|
||||
the VMs once and try a step again instead of running the whole test.
|
||||
|
||||
### Where the time goes
|
||||
|
||||
A VM test spends most of its time in the system under test, not in building:
|
||||
`router-install` (about a minute), booting (20 s each), and everything that
|
||||
runs `router-apply` on the router (building a NixOS system there takes about a
|
||||
minute) or waits for a rollback timer. A source change rebuilds the systems
|
||||
again on the host. So:
|
||||
|
||||
Measured (one at a time): `webui` 45 s, `ddns` 43 s, `basic` 107 s, `onestick` 77 s; the
|
||||
ones that install and rebuild on the device are the slow ones: `apply` and
|
||||
`update` several minutes, `setup` 11 minutes (three installs).
|
||||
|
||||
- Try a change with `webui-dev` or `fast` first; run a VM test only to confirm.
|
||||
- A change to the web UI only needs `webui` (a single node, no install).
|
||||
`apply`, `setup` and `update` are for what an installed router does:
|
||||
on-device builds, rollback, `router-setup`, `router-update`. Run them
|
||||
before a release or when you touched those, not after every UI edit.
|
||||
- Nix does not rerun a test whose inputs did not change, so `--all` after a
|
||||
small change is cheaper than it looks, except that the web UI is part of
|
||||
every installed system: any change to it invalidates `apply`, `setup`
|
||||
and `update`.
|
||||
- Use `-i` to iterate on a test script.
|
||||
@@ -1,116 +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
|
||||
|
||||
**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` and `vlans` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: WireGuard, port forwards, WAN-originated rules.
|
||||
|
||||
## 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).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,279 @@
|
||||
# nixos-router — Specs
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
## 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. 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`.
|
||||
- **Settings areas** (`/api/v1/settings/router/firewall`, `.../router/lans/<name>`, `.../system`, down to a single option): `GET` reads, `PUT` replaces, `PATCH` merges (objects), `DELETE` removes, with the settings' `ETag`/`If-Match` and the same limits as the whole-document edit (`editSettings`). Values are checked against a JSON Schema made from the option documentation (`schema.go` reads the types nixosOptionsDoc describes in words, e.g. "null or integer between 1 and 4094"; a type it does not know becomes "anything", so it never refuses a valid setting): a wrong type, a value out of range or a key that is not an option is refused with its place. The OpenAPI document has these paths (three levels deep) with their schemas; lists are replaced whole (elements have no paths). Tested with the real option file (`ROUTER_OPTIONS_JSON`, `LIVE_SETTINGS_JSON` for a router's own settings) and in `apply`.
|
||||
- **Typed responses**: every tool returns a Go struct (`apitypes.go`); `typeschema.go` derives the OpenAPI response schemas from those types by reflection (`json` tags, `doc` and `enum` tags), so the document cannot drift from what is sent. `TestResponsesMatchTheirSchemas` checks real responses against the generated schemas. The settings in answers use the typed settings schema when the option documentation is present. Hand-written reflection, not a library: the web UI is stdlib-only (`vendorHash = null`).
|
||||
- **API reference** page in the web UI (`/apireference`, linked from API access): Redoc (pinned `redoc@2.5.2` bundle, fetched by `pkgs/router-webui/package.nix`, served from the UI's own address) rendering the generated `/api/openapi.json`. Only that page gets a nonce for Redoc's styles and `worker-src blob:` in its CSP. The spec's own text explains authentication, saved-not-applied, ETags/`If-Match`/409 and errors; every operation has a curl sample. Established tooling instead of a hand-built page (swaggo rejected: spec is generated at runtime from the MCP tools and the option docs). No "try it" form.
|
||||
- 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.
|
||||
- **Endpoint names**: networkd resolves a hostname in a peer's `endpoint` when it creates the device and retries (0.2 s doubling to 25.6 s) until it resolves, so boot order is fine (seen on the real router: four failures, then success once PPPoE was up). It never resolves again, so a peer's new address (dynamic DNS) is not noticed. `router.wireguardEndpointRefresh` (default `1min`, null = off) runs `router-wireguard-reresolve` (a timer), which sets the endpoint again with `wg set` for named peers that have no handshake in the last 135 s (a working tunnel is not touched). Tested in `wireguard` (a peer that never answers, a changing /etc/hosts, a working tunnel left alone).
|
||||
|
||||
## 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).
|
||||
|
||||
## Package upgrades (`router-upgrade`, 2026-11-08)
|
||||
|
||||
Upgrading nixpkgs (the kernel and every package) on the router, without a new release of nixos-router. Step 1 (CLI and setting) and step 2 (web UI page) are done; the notice of a new NixOS release follows.
|
||||
|
||||
- **Setting** `router.update.channel`: `unstable` or a release like `26.05` (branch `nixos-26.05`); null: the packages that came with the installed release. Read from the saved `settings.json` when upgrading; applying settings does not change nixpkgs.
|
||||
- **`router-upgrade check`**: the branch's newest revision from `<channels.nixos.org>/<branch>/git-revision` (a redirect to releases.nixos.org, so curl follows it; the last revision Hydra's tests passed), against the running one (`nixos-version --revision`). Writes `/var/lib/router/upgrade/check.json`.
|
||||
- **`preview [--force] [--downgrade]`**: locks a copy of the configuration flake to that revision (`nix flake lock --override-input nixos-router/nixpkgs`), evaluates it and lists the packages of the running system that have another version in the new system's derivations, how much `nix build --dry-run` would download, and how many derivations would be built here. Nothing is downloaded: evaluation fetches the nixpkgs source only. Approximate: a rebuild without a new version is not listed. Refuses a NixOS release older than the running one (unstable calls itself 26.11, so following 26.05 from unstable is one) unless `--downgrade` (the page's "downgrade anyway") is given; it then warns that state written by the newer release may not work. Not exercised in a VM test: the test's nixpkgs copy has the same release. A changed `linux` sets `rebootNeeded`. Result in `preview.json`.
|
||||
- **`download`**: fetches and builds it (needs 3 GiB free), so a flaky uplink does not matter at `apply`.
|
||||
- **`apply [timeout]`**: takes the previewed lock into the configuration directory and runs `router-apply` (commit-confirm). The directory as it was is kept in `config.pre-update` like for updates: a rollback or a failed build puts the lock back. Refused if the configuration flake changed since the preview.
|
||||
- **Pin**: `nixpkgs-pin.json` in the configuration directory (channel, revision, flake reference with `narHash`). `router-update` locks it again after it installs a new release of nixos-router, so a release does not undo an upgrade; the reference is found in the store by its hash, offline.
|
||||
- **Web UI page** `/upgrade` ("Packages", under System): the channel as a select (none, `unstable`, and the stable releases in `stableChannels` in `upgrade.go`, 26.05 for now, until the router can ask the channel server which exist: the running release says nothing, since nixos-unstable already calls itself the next one; a configured channel that is none of these stays listed; saved to `settings.json` and the confirmed settings, so it never shows as unapplied), buttons Check, Preview (with "look anyway" when up to date), Download now and Upgrade. The preview shows the version table, download size, local builds (a warning above 50) and a reboot note for a new kernel. The page asks for a step in `upgrade/request.json` and starts `router-upgrade.service`, which runs `router-upgrade --request` (not restarted or stopped by a switch, like `router-apply`); it shows `check.json`/`preview.json` (not those of another channel, nor a check from before the last upgrade) and `upgrade/last.log`, which the unit tees (a finished one-shot unit has no journal to find). Download and Upgrade need a preview and no change waiting for confirmation; a request without a `channel` field is refused rather than clearing it.
|
||||
- **Module options** (for tests): `routerSettings.upgrade.channelsUrl`, `routerSettings.upgrade.nixpkgsRef` (`%rev%`).
|
||||
- **Test** (`apply`): the channel is a small HTTP server on the client that redirects like channels.nixos.org, and the "newer" nixpkgs is the installed one under a `path:` reference with the same `lastModified` and `rev`: a really different nixpkgs cannot be built offline (any version change rebuilds the NixOS tools). Covers check, preview (including the package-list rules), download, an unconfirmed upgrade rolled back with its lock and pin, a confirmed one, and a failing build.
|
||||
|
||||
## 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); the web UI could use the same handlers.
|
||||
- **GraphQL** on top of it, possibly: one query for the overview and for related settings.
|
||||
- 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.
|
||||
|
||||
Firewall and networking
|
||||
- **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.
|
||||
- **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.
|
||||
- The WireGuard tunnel `family` has `allowManagement = true` and open firewall rules to the LAN; narrow them when the intended rules are known.
|
||||
- No time zone is set (UTC); the wifi card is unused. (`truenas.home.stinnesbeck.com` in `dns.hosts` stays: old things in the cluster still refer to it.)
|
||||
|
||||
## 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".
|
||||
|
||||
Package upgrades (see above)
|
||||
- **New NixOS release**: look for newer `nixos-YY.MM` branches and say so; mark releases the installed nixos-router was not tested with.
|
||||
- **Tested revisions**: a signed manifest in the release naming the nixpkgs revisions the tests passed on, as a gate in front of the channel's tip.
|
||||
- **Reset**: go back to the nixpkgs that came with the release (`channel = null` and remove the pin).
|
||||
@@ -0,0 +1,61 @@
|
||||
# A single-NIC desktop PC as the router ("router on a stick").
|
||||
#
|
||||
# ISP -- ONT/modem -- switch/port --+-- enp2s0 (untagged) LAN 192.168.10.0/24
|
||||
# +-- enp2s0.1001 PPPoE uplink (IPv4)
|
||||
#
|
||||
# The switch port the PC is plugged into must carry VLAN 1001 tagged (towards
|
||||
# the ISP) and the LAN untagged. IPv6 is off: without a second link there is no
|
||||
# untagged uplink for DHCPv6 to run on.
|
||||
#
|
||||
# Before the first deployment:
|
||||
# 1. Confirm the NIC name (`enp2s0`) with `ip link` on the target box. Disk layout and
|
||||
# drivers come from the installer layout, so no hardware scan is needed.
|
||||
# 2. Put your SSH public key below (otherwise nobody can log in over the network).
|
||||
# 3. Put the PPPoE username below and the password in /var/lib/router/pppoe-password
|
||||
# (mode 0600, root); it is read at runtime and never enters the Nix store.
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
imports = [ ../../modules/installer/layout.nix ];
|
||||
|
||||
# --- Hardware: generic UEFI PC. Disk layout and drivers come from the
|
||||
# installer's layout module; adjust only if you do not use `router-install`.
|
||||
|
||||
networking.hostName = "router";
|
||||
system.stateVersion = "25.11";
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHro2zo4L13P2ukShg3JFga5/LYOkKeoOrUiFRJN3j82 nstinnesbeck nils.stinnesbeck@t-systems.com"
|
||||
];
|
||||
warnings = lib.optional (lib.any (lib.hasInfix "REPLACE") config.users.users.root.openssh.authorizedKeys.keys) ''
|
||||
examples/desktop: the SSH key is still the placeholder; remote deployment will not work.
|
||||
'';
|
||||
|
||||
router = {
|
||||
enable = true;
|
||||
|
||||
wan = {
|
||||
interface = "enp2s0";
|
||||
ipv4 = {
|
||||
type = "pppoe";
|
||||
pppoe = {
|
||||
vlan = 1001;
|
||||
username = "CHANGE-ME@isp.example";
|
||||
passwordFile = "/var/lib/router/pppoe-password";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
lans.main = {
|
||||
interface = "enp2s0";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
|
||||
# A deployment that is not confirmed within this time is rolled back.
|
||||
rollback.timeout = "5min";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# A bootable appliance image for trying the verity/read-only layout on real
|
||||
# hardware (`nix build .#image`, then write image.raw to a disk). Same network
|
||||
# shape as examples/desktop: one NIC, LAN untagged, PPPoE on VLAN 1001.
|
||||
{ ... }:
|
||||
{
|
||||
imports = [ ../../modules/image ];
|
||||
|
||||
networking.hostName = "router";
|
||||
# Bump for every build that is meant to be installed as an update.
|
||||
system.image.version = "1";
|
||||
system.stateVersion = "26.11";
|
||||
|
||||
# Generic UEFI PC: storage, USB keyboard and NIC drivers in the initrd.
|
||||
boot.initrd.availableKernelModules = [
|
||||
"ahci"
|
||||
"nvme"
|
||||
"xhci_pci"
|
||||
"usb_storage"
|
||||
"sd_mod"
|
||||
"virtio_blk"
|
||||
"virtio_pci"
|
||||
"virtio_scsi"
|
||||
];
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHqyMLgAhEdnrRrd2CeYbphGDMPq/cBDjFJ4q4ItZusn nils@Nilss-Mac-mini.mch.stinnesbeck.com"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBFxbNNk87tvMxruuKvrryK7iAdf4xgS4xFqjKejNAI6 nils@nixtop"
|
||||
];
|
||||
|
||||
router = {
|
||||
enable = true;
|
||||
wan = {
|
||||
interface = "enp2s0";
|
||||
ipv4 = {
|
||||
type = "pppoe";
|
||||
pppoe = {
|
||||
vlan = 1001;
|
||||
username = "CHANGE-ME@isp.example";
|
||||
passwordFile = "/var/lib/router/pppoe-password";
|
||||
};
|
||||
};
|
||||
};
|
||||
lans.main = {
|
||||
interface = "enp2s0";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792773339,
|
||||
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
||||
"lastModified": 1793110635,
|
||||
"narHash": "sha256-dhoW6VgFWlaodSY5jrBn+CF2FLmaUvDVgM7+C7T2+NM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
|
||||
"rev": "31ecbaca1bdefa45dffc409f0250bd8039ea0010",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -3,22 +3,358 @@
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
|
||||
# What a release is: shown by the web UI, compared by router-update
|
||||
# (the serial never goes down: the commit time) and checked against the
|
||||
# settings (the schema is the number of migrations/N.jq).
|
||||
migrations = builtins.filter (n: nixpkgs.lib.hasSuffix ".jq" n) (
|
||||
builtins.attrNames (builtins.readDir ./migrations)
|
||||
);
|
||||
thisRelease = {
|
||||
version = nixpkgs.lib.fileContents ./VERSION;
|
||||
serial = self.lastModified or 0;
|
||||
rev = self.rev or self.dirtyRev or "unknown";
|
||||
schema = builtins.length migrations;
|
||||
};
|
||||
|
||||
devPkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
# The development helpers (see DEVELOPING.md) as apps.
|
||||
devScript =
|
||||
name: inputs: file:
|
||||
let
|
||||
p = devPkgs.writeShellApplication {
|
||||
inherit name;
|
||||
runtimeInputs = inputs;
|
||||
text = builtins.readFile file;
|
||||
# Interactive helpers: the word splitting of $@ and the like is intended.
|
||||
excludeShellChecks = [ "SC2086" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
type = "app";
|
||||
program = "${p}/bin/${name}";
|
||||
};
|
||||
|
||||
# Public keys of the update signing key (see `nix run .#release`).
|
||||
defaultUpdateKeys = nixpkgs.lib.optional (builtins.pathExists ./keys/update.pub) (
|
||||
nixpkgs.lib.fileContents ./keys/update.pub
|
||||
);
|
||||
in
|
||||
{
|
||||
nixosModules = {
|
||||
router = ./modules/router;
|
||||
default = self.nixosModules.router;
|
||||
# Disk layout contract shared by router-install and target configs.
|
||||
installed-layout = ./modules/installer/layout.nix;
|
||||
# The installer pieces; `lib.mkInstaller` wires them up.
|
||||
installer = ./modules/installer/install-script.nix;
|
||||
installer-iso = ./modules/installer/iso.nix;
|
||||
# The generic installer's `router-setup` (see lib.setupModule).
|
||||
setup = ./modules/installer/setup.nix;
|
||||
# Appliance disk image: dm-verity store, volatile root, state on /var.
|
||||
image = ./modules/image;
|
||||
# Configuration from a settings file (JSON), rebuilt on the device.
|
||||
settings = ./modules/settings;
|
||||
};
|
||||
|
||||
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
|
||||
checks.x86_64-linux = import ./tests {
|
||||
inherit self;
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
# A router built from settings.json; this is what the device's own
|
||||
# configuration flake (see mkConfigDir) evaluates.
|
||||
lib.mkRouterSystem =
|
||||
{
|
||||
settings,
|
||||
modules ? [ ],
|
||||
updateKeys ? defaultUpdateKeys,
|
||||
# The configuration flake passes it on: a `path:` flake has no commit.
|
||||
release ? thisRelease,
|
||||
}:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
self.nixosModules.router
|
||||
self.nixosModules.settings
|
||||
self.nixosModules.installed-layout
|
||||
{
|
||||
routerSettings.file = settings;
|
||||
routerSettings.release = release;
|
||||
routerUpdate.publicKeys = updateKeys;
|
||||
# The device rebuilds itself, offline if need be: it needs the
|
||||
# sources its configuration flake points to.
|
||||
system.extraDependencies = [
|
||||
self.outPath
|
||||
nixpkgs.outPath
|
||||
];
|
||||
}
|
||||
]
|
||||
++ modules;
|
||||
};
|
||||
|
||||
# The configuration directory installed to /var/lib/router/config: a
|
||||
# flake pinned to exactly this router source, the settings and an
|
||||
# optional local.nix with extra NixOS configuration.
|
||||
lib.mkConfigDir =
|
||||
{
|
||||
pkgs,
|
||||
settings,
|
||||
local ? null,
|
||||
}:
|
||||
pkgs.runCommand "router-config" { } ''
|
||||
mkdir -p $out
|
||||
cp ${self.lib.configFlake pkgs} $out/flake.nix
|
||||
cp ${settings} $out/settings.json
|
||||
${pkgs.lib.optionalString (local != null) "cp ${local} $out/local.nix"}
|
||||
'';
|
||||
|
||||
# The configuration directory's flake.nix (shared by mkConfigDir and
|
||||
# router-setup, so both build exactly the same system).
|
||||
lib.configFlake =
|
||||
pkgs:
|
||||
pkgs.writeText "flake.nix" ''
|
||||
{
|
||||
inputs.nixos-router.url = "path:${self.outPath}";
|
||||
outputs =
|
||||
{ nixos-router, ... }:
|
||||
{
|
||||
nixosConfigurations.router = nixos-router.lib.mkRouterSystem {
|
||||
release = {
|
||||
version = "${thisRelease.version}";
|
||||
serial = ${toString thisRelease.serial};
|
||||
rev = "${thisRelease.rev}";
|
||||
schema = ${toString thisRelease.schema};
|
||||
};
|
||||
settings = ./settings.json;
|
||||
modules = if builtins.pathExists ./local.nix then [ ./local.nix ] else [ ];
|
||||
};
|
||||
};
|
||||
}
|
||||
'';
|
||||
|
||||
# The generic installer: `router-setup` asks for the basics, builds
|
||||
# the router offline and installs it; the rest is done in the web UI.
|
||||
# nix build .#installer
|
||||
# router-setup's configuration: the flake template and the reference
|
||||
# system whose closure makes the offline build possible. `local` is an
|
||||
# extra module that the installed systems will import as well.
|
||||
lib.setupModule =
|
||||
{
|
||||
local ? null,
|
||||
}:
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.setup ];
|
||||
router.setup = {
|
||||
flake = self.lib.configFlake pkgs;
|
||||
reference = self.lib.referenceSystem { inherit pkgs local; };
|
||||
schemaVersion = thisRelease.schema;
|
||||
release = self.lib.mkRelease {
|
||||
inherit pkgs;
|
||||
includeReference = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# A router built the way installed ones are: its closure carries what
|
||||
# building any other settings needs, so it can be done offline.
|
||||
lib.referenceSystem =
|
||||
{
|
||||
pkgs,
|
||||
local ? null,
|
||||
}:
|
||||
(self.lib.mkRouterSystem {
|
||||
settings = builtins.toFile "settings.json" (
|
||||
builtins.toJSON {
|
||||
version = thisRelease.schema;
|
||||
router = {
|
||||
wan.interface = "eth0";
|
||||
lans.lan = {
|
||||
interface = "eth1";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.1.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.1.100";
|
||||
end = "192.168.1.200";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
# A path, not a derivation: the module system would read its attributes.
|
||||
modules = pkgs.lib.optional (local != null) "${local}";
|
||||
}).config.system.build.toplevel;
|
||||
|
||||
# An update: this source, its nixpkgs, the configuration flake that
|
||||
# points at them and (unless the router has them already) the reference
|
||||
# system with everything its offline builds need. router-update takes
|
||||
# this store path from a signed binary cache (`nix run .#release`).
|
||||
lib.mkRelease =
|
||||
{
|
||||
pkgs,
|
||||
includeReference ? true,
|
||||
}:
|
||||
pkgs.runCommand "router-release-${thisRelease.version}" { } ''
|
||||
mkdir -p $out/migrations
|
||||
printf %s ${thisRelease.version} > $out/version
|
||||
printf %s ${toString thisRelease.serial} > $out/serial
|
||||
printf %s ${toString thisRelease.schema} > $out/schema
|
||||
printf %s ${thisRelease.rev} > $out/rev
|
||||
cp ${self.lib.configFlake pkgs} $out/flake.nix
|
||||
for f in ${nixpkgs.lib.concatMapStringsSep " " (n: "${./migrations}/${n}") migrations}; do
|
||||
cp "$f" $out/migrations/
|
||||
done
|
||||
# These references are what make the closure.
|
||||
echo ${self.outPath} ${nixpkgs.outPath} \
|
||||
${nixpkgs.lib.optionalString includeReference (self.lib.referenceSystem { inherit pkgs; })} \
|
||||
> $out/closure
|
||||
'';
|
||||
|
||||
# `nix run .#release -- --key KEY [--out DIR]`: signs a release as a
|
||||
# binary cache and writes the bundle and the pull-server layout.
|
||||
lib.mkReleaseTool =
|
||||
{ pkgs, release }:
|
||||
pkgs.writeShellApplication {
|
||||
name = "router-release";
|
||||
runtimeInputs = with pkgs; [
|
||||
nix
|
||||
gnutar
|
||||
coreutils
|
||||
jq
|
||||
];
|
||||
text = ''
|
||||
release=${release}
|
||||
${builtins.readFile ./scripts/release.sh}
|
||||
'';
|
||||
};
|
||||
|
||||
packages.x86_64-linux.release = self.lib.mkRelease { pkgs = nixpkgs.legacyPackages.x86_64-linux; };
|
||||
apps.x86_64-linux.release =
|
||||
let
|
||||
tool = self.lib.mkReleaseTool {
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
release = self.packages.x86_64-linux.release;
|
||||
};
|
||||
in
|
||||
{
|
||||
type = "app";
|
||||
program = "${tool}/bin/router-release";
|
||||
};
|
||||
|
||||
# The generic installer ISO:
|
||||
# nix build .#installer
|
||||
nixosConfigurations.installer = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
self.nixosModules.installer-iso
|
||||
(self.lib.setupModule { })
|
||||
];
|
||||
};
|
||||
packages.x86_64-linux.installer = self.nixosConfigurations.installer.config.system.build.isoImage;
|
||||
|
||||
# Installer ISO for a given nixosSystem:
|
||||
# nix build .#installer-example (see packages)
|
||||
lib.mkInstaller =
|
||||
{ configuration }:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
inherit (configuration.pkgs.stdenv.hostPlatform) system;
|
||||
modules = [
|
||||
self.nixosModules.installer-iso
|
||||
{
|
||||
router.installer.system = configuration.config.system.build.toplevel;
|
||||
# Reach the live system with the keys that will reach the router.
|
||||
users.users.root.openssh.authorizedKeys.keys =
|
||||
configuration.config.users.users.root.openssh.authorizedKeys.keys;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
packages.x86_64-linux.installer-example =
|
||||
(self.lib.mkInstaller { configuration = self.nixosConfigurations.example; })
|
||||
.config.system.build.isoImage;
|
||||
|
||||
# A deployable starting point; copy examples/desktop and adapt it.
|
||||
nixosConfigurations.example = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
self.nixosModules.router
|
||||
./examples/desktop
|
||||
];
|
||||
};
|
||||
|
||||
# Appliance image (verity store, volatile root) for trying on hardware:
|
||||
# nix build .#image -> result/*.raw
|
||||
nixosConfigurations.image = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
self.nixosModules.router
|
||||
self.nixosModules.image
|
||||
./examples/image
|
||||
];
|
||||
};
|
||||
|
||||
packages.x86_64-linux.image = self.nixosConfigurations.image.config.system.build.image;
|
||||
|
||||
# `nix run .#deploy -- <flake-attr> <user@host>`: activate a new
|
||||
# configuration with commit-confirm; see scripts/deploy.sh.
|
||||
apps.x86_64-linux.deploy =
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
deploy = pkgs.writeShellApplication {
|
||||
name = "router-deploy";
|
||||
runtimeInputs = with pkgs; [
|
||||
nixos-rebuild
|
||||
openssh
|
||||
coreutils
|
||||
];
|
||||
text = builtins.readFile ./scripts/deploy.sh;
|
||||
};
|
||||
in
|
||||
{
|
||||
type = "app";
|
||||
program = "${deploy}/bin/router-deploy";
|
||||
};
|
||||
|
||||
# Development loop (see DEVELOPING.md):
|
||||
# nix run .#fast quick checks, no VMs
|
||||
# nix run .#test -- update VM tests, one at a time, with a summary
|
||||
# nix run .#webui-dev the web UI locally, templates hot-reload
|
||||
apps.x86_64-linux.fast = devScript "router-fast" (with devPkgs; [
|
||||
git
|
||||
go_1_27
|
||||
nix
|
||||
coreutils
|
||||
gnugrep
|
||||
]) ./scripts/fast.sh;
|
||||
apps.x86_64-linux.test = devScript "router-test" (with devPkgs; [
|
||||
nix
|
||||
coreutils
|
||||
gnugrep
|
||||
gnused
|
||||
]) ./scripts/test.sh;
|
||||
apps.x86_64-linux.webui-dev = devScript "router-webui-dev" (with devPkgs; [
|
||||
git
|
||||
go_1_27
|
||||
iproute2
|
||||
jq
|
||||
curl
|
||||
coreutils
|
||||
]) ./scripts/webui-dev.sh;
|
||||
|
||||
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
|
||||
checks.x86_64-linux =
|
||||
import ./tests {
|
||||
inherit self;
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
}
|
||||
// {
|
||||
# The example must keep building as a complete system.
|
||||
example = self.nixosConfigurations.example.config.system.build.toplevel;
|
||||
};
|
||||
|
||||
# `nix run .#lab`: boot the basic test topology in tmux, one console pane
|
||||
# per VM plus the interactive test-driver REPL.
|
||||
@@ -28,7 +364,12 @@
|
||||
test = self.checks.x86_64-linux.basic;
|
||||
lab = pkgs.writeShellApplication {
|
||||
name = "router-lab";
|
||||
runtimeInputs = with pkgs; [ tmux util-linux gnugrep coreutils ];
|
||||
runtimeInputs = with pkgs; [
|
||||
tmux
|
||||
util-linux
|
||||
gnugrep
|
||||
coreutils
|
||||
];
|
||||
text = import ./tests/lab.nix {
|
||||
inherit (pkgs) lib;
|
||||
driver = test.driverInteractive;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
nixos-router-update-1:pdzUo8vb5GtdQFd1SGVin45qAW25AqERczbcsKGW0M4=
|
||||
@@ -0,0 +1,21 @@
|
||||
# Port forwards move from `firewall.rules` (dnat, toPort, hairpin) to
|
||||
# `firewall.nat`; the allow rule that went with them stays as a rule of its
|
||||
# own, limited to translated connections (natOnly). The rest of a rule
|
||||
# (rateLimit, log, sources) stays on the allow rule.
|
||||
def natRow:
|
||||
{type: "dnat", from: .from, to: .to, protocol: .protocol, ports: .ports, toAddress: .destination}
|
||||
+ (if .toPort != null then {toPort: .toPort} else {} end)
|
||||
+ (if ((.sources // []) | length) > 0 then {sources: .sources} else {} end)
|
||||
+ (if .hairpin == true then {hairpin: true} else {} end);
|
||||
|
||||
def allowRule:
|
||||
del(.dnat, .hairpin, .toPort)
|
||||
+ {natOnly: true}
|
||||
+ (if .toPort != null then {ports: [.toPort]} else {} end);
|
||||
|
||||
if ((.router.firewall.rules // []) | any(.dnat == true or .hairpin != null or .toPort != null)) then
|
||||
.router.firewall.nat = ((.router.firewall.nat // []) + [.router.firewall.rules[] | select(.dnat == true) | natRow])
|
||||
| .router.firewall.rules |= map(if .dnat == true then allowRule else del(.dnat, .hairpin, .toPort) end)
|
||||
else
|
||||
.
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# The provider account of dynamic DNS (protocol, server, username,
|
||||
# passwordFile) moves to `dnsProviders`, named after the protocol, so that
|
||||
# certificates can use the same account. The secret file stays where it is.
|
||||
if (.router.ddns // null) == null
|
||||
or (.router.ddns.provider // null) != null
|
||||
or ((.router.ddns | keys) - ["enable", "zone", "domains", "ipv4", "ipv6", "ssl", "interval"] | length) == 0
|
||||
then .
|
||||
else
|
||||
(.router.ddns.protocol // "cloudflare") as $name
|
||||
| .router.dnsProviders = ((.router.dnsProviders // {}) + {
|
||||
($name): (
|
||||
{type: $name}
|
||||
+ (if .router.ddns.username != null then {username: .router.ddns.username} else {} end)
|
||||
+ (if .router.ddns.server != null then {server: .router.ddns.server} else {} end)
|
||||
+ (if .router.ddns.passwordFile != null then {secretFile: .router.ddns.passwordFile} else {} end)
|
||||
)})
|
||||
| .router.ddns.provider = $name
|
||||
| del(.router.ddns.protocol, .router.ddns.server, .router.ddns.username, .router.ddns.passwordFile)
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
# settings.json migrations
|
||||
|
||||
`settings.json` carries a top-level `"version"` (absent means 0). When a
|
||||
release changes what settings.json may contain (a renamed or removed option, a
|
||||
new default that must be written down), add `N.jq` here, where N is the next
|
||||
number after the highest one. The file is a jq program that turns a version
|
||||
N-1 document into a version N document; keep unknown keys as they are.
|
||||
|
||||
`router-update` runs the missing ones, in order, on the router's settings.json
|
||||
before it rebuilds, and sets `"version"` after each. A release's schema version
|
||||
is the number of `N.jq` files; the build refuses settings that are not at that
|
||||
version, so a router never builds settings its release does not understand.
|
||||
|
||||
Example `1.jq`, renaming `router.wan.interface` to `router.wan.device`:
|
||||
|
||||
.router.wan |= (.device = .interface | del(.interface))
|
||||
@@ -0,0 +1,360 @@
|
||||
# Appliance image: a GPT disk image with a dm-verity protected, read-only
|
||||
# /nix/store partition, a volatile (tmpfs) root and a small persistent state
|
||||
# partition for /var. Built with systemd-repart; the unified kernel image on the
|
||||
# ESP carries the hash of the store partition, so a modified store does not boot.
|
||||
#
|
||||
# The system cannot be rebuilt on the device (no Nix); it is replaced as a whole
|
||||
# (A/B): the store comes in two slots, systemd-sysupdate writes a new version
|
||||
# into the slot not in use and adds its UKI with boot counting. A version that
|
||||
# does not reach a healthy state within its tries is skipped by systemd-boot,
|
||||
# which then boots the previous one again.
|
||||
#
|
||||
# ESP (systemd-boot, UKIs) | store-verity_A | store_A | store-verity_B | store_B | state (/var)
|
||||
#
|
||||
# Importing this module turns a configuration into such an image
|
||||
# (`config.system.build.image`) and its update (`config.system.build.update`).
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
modulesPath,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router.image;
|
||||
inherit (config.image.repart.verityStore) partitionIds;
|
||||
inherit (config.system.boot.loader) ukiFile;
|
||||
efiArch = pkgs.stdenv.hostPlatform.efiArch;
|
||||
version = toString config.system.image.version;
|
||||
name = config.boot.uki.name;
|
||||
usrVerityType = config.image.repart.partitions.${partitionIds.store-verity}.repartConfig.Type;
|
||||
|
||||
# Both slots have the same fixed size, so any version fits either slot.
|
||||
fixedSize = size: {
|
||||
SizeMinBytes = size;
|
||||
SizeMaxBytes = size;
|
||||
};
|
||||
emptySlot = type: size: {
|
||||
repartConfig = {
|
||||
Type = type;
|
||||
Label = "_empty";
|
||||
SplitName = "-";
|
||||
}
|
||||
// fixedSize size;
|
||||
};
|
||||
|
||||
sysupdate = "${config.systemd.package}/lib/systemd/systemd-sysupdate";
|
||||
source = pattern: {
|
||||
Type = "url-file";
|
||||
Path = cfg.update.url;
|
||||
MatchPattern = pattern;
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/image/repart.nix")
|
||||
(modulesPath + "/profiles/image-based-appliance.nix")
|
||||
(modulesPath + "/profiles/perlless.nix")
|
||||
];
|
||||
|
||||
options.router.image = {
|
||||
espSize = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "256M";
|
||||
description = "Size of the EFI system partition (systemd-boot and the UKIs of both slots).";
|
||||
};
|
||||
storeSize = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "2G";
|
||||
description = ''
|
||||
Size of each store slot. Every future version must fit; it cannot be
|
||||
changed on an installed device.
|
||||
'';
|
||||
};
|
||||
storeVeritySize = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "128M";
|
||||
description = "Size of each slot's dm-verity hash partition.";
|
||||
};
|
||||
stateSize = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "512M";
|
||||
description = "Minimum size of the persistent state partition mounted on /var.";
|
||||
};
|
||||
tries = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 3;
|
||||
description = ''
|
||||
Boots a freshly installed version gets to become healthy before
|
||||
systemd-boot falls back to the previous one.
|
||||
'';
|
||||
};
|
||||
healthChecks = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Units that must be active for a new version to count as good. The
|
||||
router's own services are always included.
|
||||
'';
|
||||
};
|
||||
update = {
|
||||
url = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://updates.example.com/router/";
|
||||
description = ''
|
||||
Where `router-update` looks for new versions: a directory with the
|
||||
files of `system.build.update`, a SHA256SUMS and, when
|
||||
`publicKey` is set, its detached signature SHA256SUMS.gpg.
|
||||
'';
|
||||
};
|
||||
publicKey = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
OpenPGP public key (armored) that signs SHA256SUMS. Without it updates
|
||||
are not verified.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
{
|
||||
assertion = config.system.image.version != null;
|
||||
message = "router.image: set system.image.version; updates are told apart by it.";
|
||||
}
|
||||
];
|
||||
warnings = lib.optional (cfg.update.url != null && cfg.update.publicKey == null) ''
|
||||
router.image.update.publicKey is not set: updates are installed without verifying them.
|
||||
'';
|
||||
|
||||
system.image.id = lib.mkDefault "nixos-router";
|
||||
|
||||
# No Nix on the device: the rollback helpers need it, and boot counting
|
||||
# takes over their job in images.
|
||||
router.rollback.enable = false;
|
||||
|
||||
boot.loader.grub.enable = false;
|
||||
|
||||
fileSystems = {
|
||||
"/" = {
|
||||
fsType = "tmpfs";
|
||||
options = [
|
||||
"mode=0755"
|
||||
"size=50%"
|
||||
];
|
||||
};
|
||||
# The only persistent, writable place: DHCP leases, SSH host keys,
|
||||
# journal, secrets (/var/lib/router).
|
||||
"/var" = {
|
||||
device = "/dev/disk/by-partlabel/state";
|
||||
fsType = "ext4";
|
||||
options = [
|
||||
"nosuid"
|
||||
"nodev"
|
||||
];
|
||||
};
|
||||
# UKIs are installed and blessed here.
|
||||
"/boot" = {
|
||||
device = "/dev/disk/by-partlabel/esp";
|
||||
fsType = "vfat";
|
||||
options = [ "umask=0077" ];
|
||||
};
|
||||
};
|
||||
|
||||
# /etc is a read-only overlay; users are created by userborn, which keeps
|
||||
# its password files on the state partition.
|
||||
system.etc.overlay.mutable = false;
|
||||
services.userborn.passwordFilesLocation = "/var/lib/nixos";
|
||||
|
||||
# Host keys live on the state partition as well.
|
||||
services.openssh.hostKeys = [
|
||||
{
|
||||
path = "/var/lib/ssh/ssh_host_ed25519_key";
|
||||
type = "ed25519";
|
||||
}
|
||||
];
|
||||
|
||||
# /usr is the read-only verity mount; there is nothing to create there.
|
||||
system.activationScripts.usrbinenv = lib.mkForce "";
|
||||
|
||||
image.repart = {
|
||||
enable = true;
|
||||
split = true;
|
||||
|
||||
# systemd-repart passes the image's sector size (512) to mkfs.erofs as the
|
||||
# block size; libblkid cannot verify the superblock checksum of an erofs
|
||||
# with blocks <= 1 KiB, so udev never reports the store's file system and
|
||||
# systemd waits forever for /dev/mapper/usr. Appended options win.
|
||||
mkfsOptions.erofs = [ "-b4096" ];
|
||||
|
||||
verityStore.enable = true;
|
||||
|
||||
partitions = {
|
||||
${partitionIds.esp} = {
|
||||
contents = {
|
||||
"/EFI/BOOT/BOOT${lib.toUpper efiArch}.EFI".source =
|
||||
"${config.systemd.package}/lib/systemd/boot/efi/systemd-boot${efiArch}.efi";
|
||||
"/loader/loader.conf".source = pkgs.writeText "loader.conf" ''
|
||||
timeout 0
|
||||
'';
|
||||
};
|
||||
repartConfig = {
|
||||
Type = "esp";
|
||||
Format = "vfat";
|
||||
SplitName = "-";
|
||||
}
|
||||
// fixedSize cfg.espSize;
|
||||
};
|
||||
# Slot A holds this version. The labels carry the version, the UUIDs
|
||||
# come from the verity root hash (that is how the UKI finds its slot),
|
||||
# and the split files named after both are the update.
|
||||
${partitionIds.store-verity}.repartConfig = {
|
||||
Label = "store-verity_${version}";
|
||||
Minimize = "off";
|
||||
SplitName = "store-verity-%U";
|
||||
}
|
||||
// fixedSize cfg.storeVeritySize;
|
||||
${partitionIds.store}.repartConfig = {
|
||||
Label = "store_${version}";
|
||||
Minimize = "off";
|
||||
Compression = "lz4hc";
|
||||
SplitName = "store-%U";
|
||||
}
|
||||
// fixedSize cfg.storeSize;
|
||||
# Slot B starts empty; systemd-sysupdate fills it.
|
||||
"30-store-verity-empty" = emptySlot "usr-verity" cfg.storeVeritySize;
|
||||
"40-store-empty" = emptySlot "usr" cfg.storeSize;
|
||||
"50-state".repartConfig = {
|
||||
Type = "var";
|
||||
Format = "ext4";
|
||||
Label = "state";
|
||||
SplitName = "-";
|
||||
SizeMinBytes = cfg.stateSize;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Upstream reads the usrhash from every usr-verity partition; the empty
|
||||
# slot B has none (its roothash is null), so pick the one that has one.
|
||||
system.build.uki = lib.mkOverride 98 (
|
||||
pkgs.runCommand ukiFile
|
||||
{
|
||||
nativeBuildInputs = [
|
||||
pkgs.buildPackages.jq
|
||||
pkgs.buildPackages.systemdUkify
|
||||
];
|
||||
}
|
||||
''
|
||||
mkdir -p $out
|
||||
usrhash=$(jq -r \
|
||||
'.[] | select(.type=="${usrVerityType}" and .roothash != null) | .roothash' \
|
||||
${config.system.build.intermediateImage}/repart-output.json
|
||||
)
|
||||
ukify build \
|
||||
--config=${config.boot.uki.configFile} \
|
||||
--cmdline="init=${config.system.build.toplevel}/init ${toString config.boot.kernelParams} usrhash=$usrhash" \
|
||||
--output="$out/${ukiFile}"
|
||||
''
|
||||
);
|
||||
|
||||
# What a device needs to move to this version: both store partitions
|
||||
# (compressed; mostly empty space) and the UKI.
|
||||
system.build.update =
|
||||
pkgs.runCommand "${config.image.baseName}-update" { nativeBuildInputs = [ pkgs.zstd ]; }
|
||||
''
|
||||
mkdir -p $out
|
||||
for f in ${config.system.build.intermediateImage}/${config.image.baseName}.store*.raw; do
|
||||
zstd -q -T0 -o $out/$(basename $f).zst $f
|
||||
done
|
||||
cp ${config.system.build.uki}/${ukiFile} $out/
|
||||
(cd $out && sha256sum * > SHA256SUMS)
|
||||
'';
|
||||
|
||||
router.image.healthChecks = [
|
||||
"systemd-networkd.service"
|
||||
"nftables.service"
|
||||
]
|
||||
++ lib.optional config.services.unbound.enable "unbound.service"
|
||||
++ lib.optional config.services.kea.dhcp4.enable "kea-dhcp4-server.service";
|
||||
|
||||
# Boot assessment: only boots that are being counted pull in
|
||||
# boot-complete.target (via systemd-bless-boot). If the router does not
|
||||
# come up, reboot; once the tries are used up systemd-boot falls back to
|
||||
# the previous version.
|
||||
systemd.services.router-healthy = {
|
||||
description = "Check that the router came up";
|
||||
requiredBy = [ "boot-complete.target" ];
|
||||
before = [ "boot-complete.target" ];
|
||||
after = cfg.healthChecks;
|
||||
unitConfig.FailureAction = "reboot";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# One per unit: `systemctl is-active a b` succeeds if any one is active.
|
||||
ExecStart = map (
|
||||
unit: "${lib.getExe' config.systemd.package "systemctl"} is-active --quiet ${unit}"
|
||||
) cfg.healthChecks;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.sysupdate = lib.mkIf (cfg.update.url != null) {
|
||||
enable = true;
|
||||
transfers =
|
||||
let
|
||||
common = {
|
||||
Transfer = {
|
||||
ProtectVersion = "%A";
|
||||
Verify = cfg.update.publicKey != null;
|
||||
};
|
||||
};
|
||||
partition = label: type: split: {
|
||||
Source = source "${name}_@v.${split}-@u.raw.zst";
|
||||
Target = {
|
||||
Type = "partition";
|
||||
Path = "auto";
|
||||
MatchPattern = "${label}_@v";
|
||||
MatchPartitionType = type;
|
||||
ReadOnly = true;
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
# Applied in this order: the UKI last, so a half-written update can
|
||||
# never be booted.
|
||||
"10-store-verity" = common // partition "store-verity" "usr-verity" "store-verity";
|
||||
"20-store" = common // partition "store" "usr" "store";
|
||||
"90-uki" = common // {
|
||||
Source = source "${name}_@v.efi";
|
||||
Target = {
|
||||
Type = "regular-file";
|
||||
Path = "/EFI/Linux";
|
||||
PathRelativeTo = "boot";
|
||||
MatchPattern = [
|
||||
"${name}_@v+@l-@d.efi"
|
||||
"${name}_@v+@l.efi"
|
||||
"${name}_@v.efi"
|
||||
];
|
||||
Mode = "0444";
|
||||
TriesLeft = cfg.tries;
|
||||
TriesDone = 0;
|
||||
InstancesMax = 2;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
environment.etc."systemd/import-pubring.gpg" = lib.mkIf (cfg.update.publicKey != null) {
|
||||
source = cfg.update.publicKey;
|
||||
};
|
||||
|
||||
# `router-update list`, `router-update update`, then reboot.
|
||||
environment.systemPackages = [
|
||||
# systemd-pull runs gpg to check SHA256SUMS.gpg.
|
||||
(pkgs.writeShellScriptBin "router-update" ''
|
||||
PATH=${lib.makeBinPath [ pkgs.gnupgMinimal ]}:$PATH exec ${sysupdate} "$@"
|
||||
'')
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# `router-install`: write a prebuilt router system to a disk. Nothing is built
|
||||
# on the box; the system closure travels with the installer.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router.installer;
|
||||
|
||||
installScript = pkgs.writeShellApplication {
|
||||
name = "router-install";
|
||||
runtimeInputs = [
|
||||
config.system.build.nixos-install
|
||||
pkgs.coreutils
|
||||
pkgs.dosfstools
|
||||
pkgs.e2fsprogs
|
||||
pkgs.gptfdisk
|
||||
pkgs.util-linux
|
||||
];
|
||||
text = ''
|
||||
usage() {
|
||||
echo "usage: router-install [--yes] [--secrets DIR] [--system PATH] <disk>" >&2
|
||||
echo " Wipes <disk> (e.g. /dev/nvme0n1) and installs a router system." >&2
|
||||
echo " --secrets DIR copied to /var/lib/router (PPPoE password, WireGuard keys, ...)" >&2
|
||||
echo " --system PATH the system to install${lib.optionalString (cfg.system != null) " (default: ${cfg.system})"}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
yes=0
|
||||
secrets=
|
||||
system=${if cfg.system != null then toString cfg.system else ""}
|
||||
disk=
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--yes) yes=1 ;;
|
||||
--secrets) shift; [ $# -gt 0 ] || usage; secrets=$1 ;;
|
||||
--system) shift; [ $# -gt 0 ] || usage; system=$1 ;;
|
||||
-*) usage ;;
|
||||
*) [ -z "$disk" ] || usage; disk=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$disk" ] || usage
|
||||
[ -n "$system" ] || usage
|
||||
[ -x "$system/bin/switch-to-configuration" ] || { echo "router-install: $system is not a NixOS system" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "router-install: must run as root" >&2; exit 1; }
|
||||
[ -b "$disk" ] || { echo "router-install: $disk is not a block device" >&2; exit 1; }
|
||||
if [ -n "$secrets" ] && [ ! -d "$secrets" ]; then
|
||||
echo "router-install: $secrets is not a directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
if lsblk -nr -o MOUNTPOINTS "$disk" | grep -q .; then
|
||||
echo "router-install: $disk or one of its partitions is mounted; refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -d /sys/firmware/efi ] || echo "warning: not booted in UEFI mode; the box itself must boot UEFI" >&2
|
||||
|
||||
echo "About to ERASE $disk:"
|
||||
lsblk -o NAME,SIZE,MODEL,FSTYPE,LABEL "$disk"
|
||||
if [ "$yes" -ne 1 ]; then
|
||||
printf 'Type the device name (%s) to continue: ' "$disk"
|
||||
read -r answer
|
||||
[ "$answer" = "$disk" ] || { echo "aborted" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
part() {
|
||||
case $disk in
|
||||
*[0-9]) echo "''${disk}p$1" ;;
|
||||
*) echo "$disk$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo ">> partitioning"
|
||||
sgdisk --zap-all "$disk" >/dev/null
|
||||
sgdisk -n 1:0:+512M -t 1:ef00 -c 1:boot -n 2:0:0 -t 2:8300 -c 2:nixos "$disk" >/dev/null
|
||||
partprobe "$disk" || true
|
||||
udevadm settle
|
||||
|
||||
echo ">> formatting"
|
||||
mkfs.fat -F 32 -n boot "$(part 1)" >/dev/null
|
||||
mkfs.ext4 -q -F -L nixos "$(part 2)"
|
||||
|
||||
echo ">> mounting"
|
||||
mkdir -p /mnt
|
||||
mount "$(part 2)" /mnt
|
||||
mkdir -p /mnt/boot
|
||||
mount -o umask=0077 "$(part 1)" /mnt/boot
|
||||
|
||||
if [ -n "$secrets" ]; then
|
||||
echo ">> copying secrets"
|
||||
install -d -m 0700 /mnt/var/lib/router
|
||||
cp -rT "$secrets" /mnt/var/lib/router
|
||||
chown -R root:root /mnt/var/lib/router
|
||||
chmod -R go-rwx /mnt/var/lib/router
|
||||
else
|
||||
echo "note: no --secrets given; put the PPPoE password etc. in /var/lib/router later" >&2
|
||||
fi
|
||||
|
||||
echo ">> installing $system"
|
||||
nixos-install --system "$system" --root /mnt --no-root-passwd --no-channel-copy
|
||||
|
||||
echo ">> unmounting"
|
||||
umount -R /mnt
|
||||
sync
|
||||
echo "Done. Remove the installer and reboot."
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.router.installer.system = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
default = null;
|
||||
description = ''
|
||||
The system (a `system.build.toplevel`) `router-install` writes to disk;
|
||||
null if it is always given with `--system` (as `router-setup` does).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.systemPackages = [ installScript ];
|
||||
# The closure must be on the installer itself.
|
||||
system.extraDependencies = lib.optional (cfg.system != null) cfg.system;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Bootable installer ISO: the live system, `router-install` and the target
|
||||
# system's closure. SSH is open to the target's root keys so secrets can be
|
||||
# copied over (`scp -r secrets root@<installer>:`) before installing.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
modulesPath,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/installer/cd-dvd/installation-cd-minimal.nix")
|
||||
./install-script.nix
|
||||
];
|
||||
|
||||
isoImage.volumeID = "ROUTER_INSTALL";
|
||||
isoImage.squashfsCompression = "zstd -Xcompression-level 6";
|
||||
|
||||
# Installing on a laptop must survive closing its lid.
|
||||
services.logind.settings.Login = {
|
||||
HandleLidSwitch = "ignore";
|
||||
HandleLidSwitchExternalPower = "ignore";
|
||||
HandleLidSwitchDocked = "ignore";
|
||||
};
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings.PermitRootLogin = "prohibit-password";
|
||||
};
|
||||
|
||||
users.motd =
|
||||
if config.router.installer.system != null then
|
||||
''
|
||||
|
||||
nixos-router installer
|
||||
1. (optional) scp -r secrets root@<this-host>:secrets
|
||||
2. router-install --secrets /root/secrets /dev/<disk>
|
||||
The disk is erased after confirmation.
|
||||
''
|
||||
else
|
||||
''
|
||||
|
||||
nixos-router installer
|
||||
Run `sudo router-setup`: it asks a few questions, erases the chosen disk
|
||||
and installs the router. Everything else is set up in the web UI.
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# The disk layout `router-install` creates, as a NixOS module: an ESP labelled
|
||||
# `boot` and an ext4 root labelled `nixos`. Target configurations import this so
|
||||
# the installed system finds its disks on any hardware.
|
||||
{ lib, ... }:
|
||||
{
|
||||
fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/nixos";
|
||||
fsType = "ext4";
|
||||
};
|
||||
fileSystems."/boot" = {
|
||||
device = "/dev/disk/by-label/boot";
|
||||
fsType = "vfat";
|
||||
options = [ "umask=0077" ];
|
||||
};
|
||||
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
# The installer may run anywhere (even a BIOS-booted stick) and the firmware
|
||||
# may be reset: boot through the removable fallback path, not NVRAM entries.
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkDefault false;
|
||||
|
||||
# Storage and USB controllers of common hardware, so the root is found on
|
||||
# an arbitrary box (and in VMs).
|
||||
boot.initrd.availableKernelModules = [
|
||||
"ahci"
|
||||
"nvme"
|
||||
"sd_mod"
|
||||
"sdhci_pci"
|
||||
"usb_storage"
|
||||
"xhci_pci"
|
||||
"virtio_blk"
|
||||
"virtio_pci"
|
||||
];
|
||||
hardware.enableRedistributableFirmware = lib.mkDefault true;
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
# `router-setup`: the generic installer. Asks for the basics on the console
|
||||
# (disk, uplink and LAN interfaces, PPPoE credentials, LAN address, web UI
|
||||
# password), writes them as settings.json, builds that router on the spot
|
||||
# (offline: the closure of `reference` brings the sources and everything the
|
||||
# build needs) and installs it with `router-install`. Everything else is done
|
||||
# in the web UI afterwards.
|
||||
#
|
||||
# An existing router (an installed disk, or a backup from the web UI on any
|
||||
# disk or USB stick) can be reinstalled instead: its whole /var/lib/router
|
||||
# (settings, secrets, passwords, certificate, dashboard history) carries over
|
||||
# and only interfaces this machine lacks are asked for.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router.setup;
|
||||
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
|
||||
migrate = pkgs.callPackage ../../pkgs/router-migrate.nix { };
|
||||
|
||||
setup = pkgs.writeShellApplication {
|
||||
name = "router-setup";
|
||||
runtimeInputs = [
|
||||
config.nix.package
|
||||
webui
|
||||
migrate
|
||||
pkgs.coreutils
|
||||
pkgs.findutils
|
||||
pkgs.gawk
|
||||
pkgs.gnugrep
|
||||
pkgs.gnutar
|
||||
pkgs.gzip
|
||||
pkgs.jq
|
||||
pkgs.mkpasswd
|
||||
pkgs.util-linux
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
text = ''
|
||||
usage() {
|
||||
echo "usage: router-setup [--local FILE] [--config-only DIR]" >&2
|
||||
echo " Asks a few questions, then ERASES the chosen disk and installs the router." >&2
|
||||
echo " --local FILE extra NixOS module for this box (installed as config/local.nix)" >&2
|
||||
echo " --config-only DIR only write the settings and secrets to DIR; install nothing" >&2
|
||||
exit 2
|
||||
}
|
||||
local_nix="" config_only=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--local) shift; [ $# -gt 0 ] || usage; local_nix=$1 ;;
|
||||
--config-only) shift; [ $# -gt 0 ] || usage; config_only=$1 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ "$(id -u)" -eq 0 ] || { echo "router-setup: must run as root" >&2; exit 1; }
|
||||
|
||||
# ask VAR PROMPT [DEFAULT]
|
||||
# (Their locals must not share a name with the caller's variable.)
|
||||
ask() {
|
||||
local reply
|
||||
read -r -p "$2''${3:+ [$3]}: " reply || { echo; echo "aborted" >&2; exit 1; }
|
||||
printf -v "$1" '%s' "''${reply:-''${3:-}}"
|
||||
}
|
||||
ask_secret() {
|
||||
local reply
|
||||
read -r -s -p "$2: " reply || { echo; echo "aborted" >&2; exit 1; }
|
||||
echo >&2
|
||||
printf -v "$1" '%s' "$reply"
|
||||
}
|
||||
# choose VAR PROMPT ITEM...: by number or by name
|
||||
choose() {
|
||||
local var=$1 prompt=$2 pick="" i
|
||||
shift 2
|
||||
while :; do
|
||||
i=1
|
||||
for item in "$@"; do echo " $i) $item" >&2; i=$((i + 1)); done
|
||||
ask pick "$prompt"
|
||||
if [[ $pick =~ ^[0-9]+$ ]] && [ "$pick" -ge 1 ] && [ "$pick" -le $# ]; then
|
||||
pick=''${!pick}
|
||||
pick=''${pick%% *}
|
||||
fi
|
||||
for item in "$@"; do
|
||||
if [ "''${item%% *}" = "$pick" ]; then
|
||||
printf -v "$var" '%s' "$pick"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo " please pick one of the above" >&2
|
||||
done
|
||||
}
|
||||
yes_no() { # yes_no VAR PROMPT DEFAULT(y|n)
|
||||
local yn
|
||||
while :; do
|
||||
ask yn "$2 (y/n)" "$3"
|
||||
case $yn in
|
||||
y|Y|yes) printf -v "$1" '%s' y; return ;;
|
||||
n|N|no) printf -v "$1" '%s' n; return ;;
|
||||
esac
|
||||
echo " y or n" >&2
|
||||
done
|
||||
}
|
||||
ipv4_ok() {
|
||||
[[ $1 =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
|
||||
for o in "''${BASH_REMATCH[@]:1}"; do [ "$o" -le 255 ] || return 1; done
|
||||
}
|
||||
|
||||
# summary DIR: one line about the router whose state is in DIR.
|
||||
summary() {
|
||||
jq -r '"\(.system.hostName // "router"): WAN \(.router.wan.interface) (\(.router.wan.ipv4.type // "dhcp")), LAN \([.router.lans[]? | "\(.interface) \(.ipv4.address)"] | join(", "))"' \
|
||||
"$1/config/settings.json" 2>/dev/null
|
||||
}
|
||||
|
||||
# Set by ask/choose (printf -v).
|
||||
disk="" domain="" wan="" lan="" wan_type="" want_vlans="" want_v6="" vlan_parent="" vlan_id="" host="" lan_address="" ssh_key="" answer=""
|
||||
webui_password="" webui_password2="" reuse="" reuse_label="" new=""
|
||||
|
||||
work=$(mktemp -d /run/router-setup.XXXXXX)
|
||||
chmod 0700 "$work"
|
||||
cleanup() {
|
||||
umount "$work/mnt" 2>/dev/null || true
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo
|
||||
echo "nixos-router setup"
|
||||
echo "=================="
|
||||
echo "A few questions; everything else can be changed later in the web UI."
|
||||
echo
|
||||
|
||||
# Disks, without the medium this installer runs from.
|
||||
boot_disk=""
|
||||
if src=$(findmnt -no SOURCE /iso 2>/dev/null) && [ -n "$src" ]; then
|
||||
boot_disk=/dev/$(lsblk -no PKNAME "$src" | head -n1)
|
||||
fi
|
||||
|
||||
# Existing routers: installed systems and web UI backups, on any
|
||||
# filesystem but the installer's own. Each found state is copied to
|
||||
# $work/found/N (the disk may be the one about to be erased).
|
||||
found=() found_labels=()
|
||||
mkdir -p "$work/mnt" "$work/found"
|
||||
# A stick plugged in a moment ago may not be probed yet.
|
||||
udevadm settle --timeout=10 || true
|
||||
while read -r dev fstype; do
|
||||
case $fstype in ext2|ext3|ext4|vfat|exfat|ntfs|ntfs3|btrfs|xfs) ;; *) continue ;; esac
|
||||
parent=$(lsblk -no PKNAME "$dev" 2>/dev/null | head -n1)
|
||||
[ -n "$boot_disk" ] && [ "/dev/$parent" = "$boot_disk" ] && continue
|
||||
[ -n "$boot_disk" ] && [ "$dev" = "$boot_disk" ] && continue
|
||||
opts=ro
|
||||
case $fstype in ext*) opts=ro,noload ;; esac
|
||||
mount -o "$opts" "$dev" "$work/mnt" >/dev/null 2>&1 || continue
|
||||
if [ -f "$work/mnt/var/lib/router/config/settings.json" ]; then
|
||||
d=$work/found/''${#found[@]}
|
||||
if cp -a "$work/mnt/var/lib/router" "$d" 2>/dev/null && line=$(summary "$d"); then
|
||||
found+=("$d") found_labels+=("installed on $dev: $line")
|
||||
else
|
||||
rm -rf "$d"
|
||||
fi
|
||||
fi
|
||||
while IFS= read -r -d "" backup; do
|
||||
d=$work/found/''${#found[@]}
|
||||
mkdir -p "$d.x"
|
||||
if tar -xzf "$backup" -C "$d.x" --no-same-owner 2>/dev/null && [ -d "$d.x/router" ] \
|
||||
&& mv "$d.x/router" "$d" && line=$(summary "$d"); then
|
||||
found+=("$d") found_labels+=("backup ''${backup#"$work/mnt/"} on $dev: $line")
|
||||
else
|
||||
rm -rf "$d"
|
||||
fi
|
||||
rm -rf "$d.x"
|
||||
done < <(find "$work/mnt" -maxdepth 2 -type f -name 'router-backup-*.tar.gz' -print0 2>/dev/null)
|
||||
umount "$work/mnt"
|
||||
done < <(lsblk -rnpo NAME,FSTYPE | awk 'NF == 2')
|
||||
|
||||
if [ ''${#found[@]} -gt 0 ]; then
|
||||
echo "Existing router configurations:"
|
||||
i=1
|
||||
for l in "''${found_labels[@]}"; do echo " $i) $l"; i=$((i + 1)); done
|
||||
echo " n) none, set up a new router"
|
||||
while :; do
|
||||
ask answer "Reinstall one of them (everything carries over: settings, passwords, keys)" "1"
|
||||
[ "$answer" = n ] && break
|
||||
if [[ $answer =~ ^[0-9]+$ ]] && [ "$answer" -ge 1 ] && [ "$answer" -le ''${#found[@]} ]; then
|
||||
reuse=''${found[$((answer - 1))]}
|
||||
reuse_label=''${found_labels[$((answer - 1))]}
|
||||
break
|
||||
fi
|
||||
echo " a number from the list, or n" >&2
|
||||
done
|
||||
echo
|
||||
fi
|
||||
|
||||
mapfile -t disks < <(lsblk -dnpo NAME,SIZE,MODEL,TYPE | awk '$NF == "disk" { $NF = ""; print }' \
|
||||
| grep -v "^$boot_disk " || true)
|
||||
[ ''${#disks[@]} -gt 0 ] || { echo "router-setup: no disk to install to" >&2; exit 1; }
|
||||
echo "Disk to install to (it will be ERASED):"
|
||||
choose disk "Disk" "''${disks[@]}"
|
||||
|
||||
nics=()
|
||||
for dev in /sys/class/net/*; do
|
||||
n=$(basename "$dev")
|
||||
[ -e "$dev/device" ] || continue
|
||||
state=$(cat "$dev/operstate" 2>/dev/null || echo "?")
|
||||
nics+=("$n $(cat "$dev/address") link $state")
|
||||
done
|
||||
[ ''${#nics[@]} -gt 0 ] || { echo "router-setup: no network interfaces found" >&2; exit 1; }
|
||||
|
||||
if [ -n "$reuse" ]; then
|
||||
settings=$reuse/config/settings.json
|
||||
# Interfaces this machine does not have (other hardware, other names).
|
||||
# (The list comes in on fd 3: stdin is for the answers.)
|
||||
while IFS= read -r old <&3; do
|
||||
[ -e "/sys/class/net/$old" ] && continue
|
||||
echo
|
||||
echo "This machine has no interface $old ($(jq -r --arg i "$old" '
|
||||
[(if .router.wan.interface == $i then "WAN" else empty end),
|
||||
(.router.lans // {} | to_entries[] | select(.value.interface == $i) | "LAN \(.key)")] | join(", ")' "$settings")). Which one takes its place?"
|
||||
choose new "Interface" "''${nics[@]}"
|
||||
jq --arg o "$old" --arg n "$new" '
|
||||
.router.wan |= (if .interface == $o then .interface = $n else . end)
|
||||
| .router.lans |= ((. // {}) | map_values(if .interface == $o then .interface = $n else . end))' \
|
||||
"$settings" > "$settings.new"
|
||||
mv "$settings.new" "$settings"
|
||||
done 3< <(jq -r '[.router.wan.interface] + [.router.lans[]?.interface] | unique | .[]' "$settings")
|
||||
|
||||
# Older installs may lack the password files.
|
||||
if [ ! -s "$reuse/webui-password" ] || [ ! -s "$reuse/root-password" ]; then
|
||||
echo
|
||||
echo "The configuration has no password for the web UI and root; choose one (at least 8 characters):"
|
||||
while :; do
|
||||
ask_secret webui_password "Password"
|
||||
ask_secret webui_password2 "Again"
|
||||
[ "$webui_password" = "$webui_password2" ] && [ ''${#webui_password} -ge 8 ] && break
|
||||
echo " the passwords differ or are too short" >&2
|
||||
done
|
||||
fi
|
||||
lan_address=$(jq -r '[.router.lans[]?.ipv4.address][0] // empty' "$settings")
|
||||
|
||||
echo
|
||||
echo "Summary"
|
||||
echo " disk: $disk (will be erased)"
|
||||
echo " reinstall: $reuse_label"
|
||||
echo " now: $(summary "$reuse")"
|
||||
ask answer "Type the disk name ($disk) to install"
|
||||
[ "$answer" = "$disk" ] || { echo "aborted" >&2; exit 1; }
|
||||
|
||||
# The state as it was, with this installer's flake (the old one pins a
|
||||
# nixos-router store path that will not exist on the new system).
|
||||
mv "$reuse" "$work/secrets"
|
||||
config=$work/secrets/config
|
||||
install -m 0644 ${cfg.flake} "$config/flake.nix"
|
||||
rm -f "$config/flake.lock" "$config/result"
|
||||
if [ -n "$local_nix" ]; then cp "$local_nix" "$config/local.nix"; fi
|
||||
${lib.optionalString (cfg.release != null) ''
|
||||
# An older configuration may need its settings brought up to date.
|
||||
router-migrate-settings ${cfg.release} "$config/settings.json"
|
||||
''}
|
||||
# The new system is confirmed from settings.json as installed.
|
||||
rm -f "$work/secrets/settings.confirmed.json"
|
||||
rm -rf "$work/found"
|
||||
if [ -n "$webui_password" ]; then
|
||||
printf '%s\n' "$webui_password" | router-webui passwd "$work/secrets/webui-password"
|
||||
(umask 077; printf '%s\n' "$webui_password" | mkpasswd -s > "$work/secrets/root-password")
|
||||
fi
|
||||
else # a new router
|
||||
|
||||
# Devices WAN and LAN can be assigned to: the NICs, plus VLANs on them.
|
||||
devices=("''${nics[@]}")
|
||||
echo
|
||||
echo "VLANs are separate networks sharing one cable: some providers deliver the internet"
|
||||
echo "tagged with a VLAN ID, and one NIC can then also serve the LAN untagged."
|
||||
yes_no want_vlans "Create VLANs now?" n
|
||||
while [ "$want_vlans" = y ]; do
|
||||
choose vlan_parent "VLAN on which interface" "''${nics[@]}"
|
||||
while :; do
|
||||
ask vlan_id "VLAN ID (1-4094)"
|
||||
[[ $vlan_id =~ ^[0-9]+$ ]] && [ "$vlan_id" -ge 1 ] && [ "$vlan_id" -le 4094 ] && break
|
||||
echo " a number from 1 to 4094" >&2
|
||||
done
|
||||
devices+=("$vlan_parent.$vlan_id VLAN $vlan_id on $vlan_parent")
|
||||
yes_no want_vlans "Another VLAN?" n
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Interface towards the internet (WAN):"
|
||||
choose wan "WAN interface" "''${devices[@]}"
|
||||
echo
|
||||
echo "Interface for the local network (LAN):"
|
||||
choose lan "LAN interface" "''${devices[@]}"
|
||||
|
||||
# A VLAN device is its NIC plus an ID in the settings.
|
||||
wan_dev=''${wan%.*} wan_vlan=""
|
||||
[ "$wan_dev" = "$wan" ] || wan_vlan=''${wan##*.}
|
||||
lan_dev=''${lan%.*} lan_vlan=""
|
||||
[ "$lan_dev" = "$lan" ] || lan_vlan=''${lan##*.}
|
||||
if [ "$wan" = "$lan" ]; then
|
||||
echo "router-setup: WAN and LAN cannot be the same interface" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Internet connection:"
|
||||
choose wan_type "Connection" \
|
||||
"dhcp (behind a cable modem, an ONT or another router)" \
|
||||
"pppoe (DSL or fibre with a username and password)"
|
||||
pppoe_user="" pppoe_password=""
|
||||
if [ "$wan_type" = pppoe ]; then
|
||||
while [ -z "$pppoe_user" ]; do ask pppoe_user "PPPoE username"; done
|
||||
while [ -z "$pppoe_password" ]; do ask_secret pppoe_password "PPPoE password"; done
|
||||
fi
|
||||
if [ -n "$wan_vlan" ] && [ "$wan_type" != pppoe ]; then
|
||||
echo "router-setup: an uplink on a VLAN is only supported with PPPoE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$wan_dev" = "$lan_dev" ] && [ -z "$wan_vlan$lan_vlan" ]; then
|
||||
echo "router-setup: WAN and LAN cannot share an interface without a VLAN" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$wan_dev" = "$lan_dev" ] && [ -z "$wan_vlan" ]; then
|
||||
echo "router-setup: with WAN and LAN on one interface, the WAN must be the VLAN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
yes_no want_v6 "Enable IPv6 (the provider delegates a prefix to the LAN)?" y
|
||||
echo
|
||||
ask host "Host name" "router"
|
||||
while :; do
|
||||
ask domain "Local domain for DNS and DHCP, like home.example.org (optional)" ""
|
||||
[ -z "$domain" ] && break
|
||||
[[ $domain =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] && break
|
||||
echo " lower case letters, digits, dashes and dots" >&2
|
||||
done
|
||||
while :; do
|
||||
ask lan_address "Router address on the LAN (a /24 network)" "192.168.1.1"
|
||||
ipv4_ok "$lan_address" && break
|
||||
echo " an IPv4 address like 192.168.1.1" >&2
|
||||
done
|
||||
net=''${lan_address%.*}
|
||||
|
||||
echo
|
||||
echo "Password for the web UI and for root on the console (at least 8 characters):"
|
||||
while :; do
|
||||
ask_secret webui_password "Password"
|
||||
ask_secret webui_password2 "Again"
|
||||
[ "$webui_password" = "$webui_password2" ] && [ ''${#webui_password} -ge 8 ] && break
|
||||
echo " the passwords differ or are too short" >&2
|
||||
done
|
||||
ask ssh_key "SSH public key for root (optional, one line)" ""
|
||||
|
||||
echo
|
||||
echo "Summary"
|
||||
echo " disk: $disk (will be erased)"
|
||||
echo " WAN: $wan, $wan_type''${pppoe_user:+ as $pppoe_user}, IPv6 $([ "$want_v6" = y ] && echo on || echo off)"
|
||||
echo " LAN: $lan, $lan_address/24, DHCP $net.100 - $net.200"
|
||||
echo " host name: $host''${domain:+ (domain $domain)}"
|
||||
echo " web UI: https://$lan_address"
|
||||
ask answer "Type the disk name ($disk) to install"
|
||||
[ "$answer" = "$disk" ] || { echo "aborted" >&2; exit 1; }
|
||||
|
||||
rm -rf "$work/found"
|
||||
mkdir -p "$work/secrets/config"
|
||||
config=$work/secrets/config
|
||||
cp ${cfg.flake} "$config/flake.nix"
|
||||
chmod 0644 "$config/flake.nix"
|
||||
if [ -n "$local_nix" ]; then cp "$local_nix" "$config/local.nix"; fi
|
||||
|
||||
keys=$( [ -n "$ssh_key" ] && jq -n --arg k "$ssh_key" '[$k]' || echo '[]')
|
||||
jq -n \
|
||||
--arg host "$host" --arg domain "$domain" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \
|
||||
--argjson schema ${toString cfg.schemaVersion} \
|
||||
--arg wan "$wan_dev" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$wan_vlan" \
|
||||
--arg lan "$lan_dev" --arg lanvlan "$lan_vlan" --arg v6 "$want_v6" \
|
||||
--arg addr "$lan_address" --arg net "$net" '
|
||||
{
|
||||
version: $schema,
|
||||
system: { hostName: $host, stateVersion: $state, sshKeys: $keys },
|
||||
router: ({
|
||||
wan: ({ interface: $wan, ipv4: { type: $type } }
|
||||
| if $type == "pppoe" then .ipv4.pppoe = ({ username: $user, passwordFile: "/var/lib/router/pppoe-password" }
|
||||
+ (if $vlan == "" then {} else { vlan: ($vlan | tonumber) } end)) else . end
|
||||
| if $v6 == "y" then .ipv6 = { enable: true, transport: (if $type == "pppoe" then "ppp" else "native" end) } else . end),
|
||||
lans: {
|
||||
lan: ({
|
||||
interface: $lan, internet: true,
|
||||
ipv4: { address: $addr },
|
||||
dhcp: { pool: { start: ($net + ".100"), end: ($net + ".200") } }
|
||||
}
|
||||
+ (if $lanvlan == "" then {} else { vlan: ($lanvlan | tonumber) } end)
|
||||
+ (if $v6 == "y" then { ipv6: { pd: true } } else {} end))
|
||||
}
|
||||
}
|
||||
+ (if $domain == "" then {} else { dns: { domain: $domain, dynamicLeases: true } } end))
|
||||
}' > "$config/settings.json"
|
||||
|
||||
if [ "$wan_type" = pppoe ]; then
|
||||
printf '%s' "$pppoe_password" > "$work/secrets/pppoe-password"
|
||||
fi
|
||||
printf '%s\n' "$webui_password" | router-webui passwd "$work/secrets/webui-password"
|
||||
(umask 077; printf '%s\n' "$webui_password" | mkpasswd -s > "$work/secrets/root-password")
|
||||
fi # new router
|
||||
|
||||
if [ -n "$config_only" ]; then
|
||||
mkdir -p "$config_only"
|
||||
cp -a "$work/secrets/." "$config_only/"
|
||||
echo "router-setup: wrote the configuration to $config_only"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo ">> building the router system (no internet needed; takes a few minutes)"
|
||||
system=$(nix build --no-link --print-out-paths --out-link "$work/system" \
|
||||
"path:$config#nixosConfigurations.router.config.system.build.toplevel")
|
||||
router-install --yes --secrets "$work/secrets" --system "$system" "$disk"
|
||||
echo
|
||||
echo "After rebooting, open https://''${lan_address:-the LAN address} from the LAN."
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [ ./install-script.nix ];
|
||||
|
||||
options.router.setup = {
|
||||
flake = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "The configuration flake (flake.nix) installed systems are built from.";
|
||||
};
|
||||
reference = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = ''
|
||||
A router system built the same way (`lib.mkRouterSystem`). Its closure
|
||||
is what lets the installer build the chosen configuration offline.
|
||||
'';
|
||||
};
|
||||
release = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
default = null;
|
||||
description = "The release (lib.mkRelease) these installs run; its migrations update reused settings.";
|
||||
};
|
||||
schemaVersion = lib.mkOption {
|
||||
type = lib.types.ints.unsigned;
|
||||
default = 0;
|
||||
description = "The settings schema of the nixos-router these installs run (settings.json's \"version\").";
|
||||
};
|
||||
stateVersion = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = config.system.nixos.release;
|
||||
description = "system.stateVersion of installed routers.";
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.systemPackages = [ setup ];
|
||||
system.extraDependencies = [ cfg.reference ];
|
||||
nix.settings.experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# A Let's Encrypt certificate for the web UI, proven with a DNS record (DNS-01):
|
||||
# no port needs to be open to the internet, and wildcards work. NixOS's
|
||||
# security.acme (lego) orders and renews it; the web UI picks up the files.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router;
|
||||
acme = cfg.acme;
|
||||
|
||||
# Shared with the web UI: the providers lego knows, and those with a single
|
||||
# token (the variable names the file holding it). The others take an
|
||||
# environment file with whatever variables they need.
|
||||
providers = builtins.fromJSON (builtins.readFile ../../pkgs/router-webui/providers.json);
|
||||
tokenVariables = providers.acmeToken;
|
||||
|
||||
prov =
|
||||
cfg.dnsProviders.${if acme.provider == null then "" else acme.provider} or {
|
||||
type = "none";
|
||||
secretFile = null;
|
||||
environmentFile = null;
|
||||
};
|
||||
token = tokenVariables ? ${prov.type};
|
||||
secret = if token then prov.secretFile else prov.environmentFile;
|
||||
has = acme.provider != null && cfg.dnsProviders ? ${acme.provider};
|
||||
name = if acme.domains == [ ] then "unset" else lib.head acme.domains;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && acme.enable) {
|
||||
assertions = [
|
||||
{
|
||||
assertion = acme.domains != [ ];
|
||||
message = "router.acme: no domains for the certificate.";
|
||||
}
|
||||
{
|
||||
assertion = has;
|
||||
message = "router.acme.provider must name a connection in router.dnsProviders.";
|
||||
}
|
||||
{
|
||||
assertion = !has || lib.elem prov.type providers.acme;
|
||||
message = "router.acme: lego has no DNS provider ${prov.type}.";
|
||||
}
|
||||
{
|
||||
assertion = !has || !lib.elem prov.type providers.acme || (secret != null && !lib.hasPrefix "/nix/store" secret);
|
||||
message = "router.dnsProviders.${toString acme.provider} needs ${if token then "secretFile" else "environmentFile"} for certificates, a file outside the Nix store (the secret must not be copied into it).";
|
||||
}
|
||||
{
|
||||
assertion = acme.email != null;
|
||||
message = "router.acme.email: Let's Encrypt needs a contact address.";
|
||||
}
|
||||
{
|
||||
assertion = acme.acceptTerms;
|
||||
message = "router.acme.acceptTerms: the Let's Encrypt subscriber agreement must be accepted.";
|
||||
}
|
||||
];
|
||||
|
||||
security.acme = {
|
||||
# Ours is the assertion above.
|
||||
acceptTerms = true;
|
||||
defaults.email = acme.email;
|
||||
certs.${name} = {
|
||||
extraDomainNames = lib.tail acme.domains;
|
||||
dnsProvider = prov.type;
|
||||
inherit (acme) dnsResolver;
|
||||
credentialFiles = lib.optionalAttrs token { ${tokenVariables.${prov.type}} = prov.secretFile; };
|
||||
environmentFile = lib.mkIf (!token) prov.environmentFile;
|
||||
server = lib.mkIf acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
+487
-21
@@ -6,7 +6,26 @@ let
|
||||
lansByName = cfg.lans;
|
||||
lans = lib.attrValues lansByName;
|
||||
|
||||
# A name chosen for a network card or a VLAN device: short, plain, and not
|
||||
# one the kernel or udev hands out itself (a rename could then collide).
|
||||
# Keep in step with reservedName in the web UI (pkgs/router-webui/settings.go).
|
||||
chosenNameOk =
|
||||
s:
|
||||
builtins.match "[a-z][a-z0-9-]{0,14}" s != null
|
||||
&& builtins.match "(eth|wlan|ppp|wg|veth|bond|tun|tap|dummy|ifb|docker|virbr|br|lo)[0-9]*|(en|wl|ww|sl)[opsx][0-9a-f].*" s == null;
|
||||
chosenNames =
|
||||
lib.attrNames cfg.interfaces
|
||||
++ lib.concatMap (l: lib.optional (l.deviceName != null) l.deviceName) lans
|
||||
++ lib.optional (cfg.wan.ipv4.pppoe.vlanName != null) cfg.wan.ipv4.pppoe.vlanName;
|
||||
macs = lib.mapAttrsToList (_: i: i.mac) cfg.interfaces;
|
||||
|
||||
validIp = s: ipv4.parse s != null;
|
||||
hostnameOk = s: builtins.match "[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" s != null;
|
||||
# A DNS name: labels of letters, digits, dashes and underscores.
|
||||
dnsNameOk =
|
||||
s:
|
||||
builtins.match "[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?(\\.[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?)*" s
|
||||
!= null;
|
||||
|
||||
# Assertions about a single LAN; skipped (reported once) when its addresses
|
||||
# do not even parse, so we never feed null into the integer helpers.
|
||||
@@ -31,12 +50,37 @@ let
|
||||
assertion = addrOk;
|
||||
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
|
||||
}
|
||||
{
|
||||
assertion = lan.deviceName == null || lan.vlan != null;
|
||||
message = "router: ${where}.deviceName needs a vlan.";
|
||||
}
|
||||
{
|
||||
# IFNAMSIZ is 16 including the terminating NUL.
|
||||
assertion = lib.stringLength lan.device <= 15;
|
||||
message = "router: ${where}: device name `${lan.device}` is longer than 15 characters.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (lan.ipv6.address != null) (
|
||||
let
|
||||
m = builtins.match "([0-9a-fA-F:]+)/([0-9]{1,3})" lan.ipv6.address;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = isV6 lan.ipv6.address && v6Cidr lan.ipv6.address && m != null;
|
||||
message = "router: ${where}.ipv6.address `${lan.ipv6.address}` is not an IPv6 address in CIDR notation.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (m != null) [
|
||||
{
|
||||
assertion = builtins.elemAt m 1 == "64";
|
||||
message = "router: ${where}.ipv6.address must be a /64 (SLAAC needs one).";
|
||||
}
|
||||
{
|
||||
assertion = builtins.match "[fF][cCdD][0-9a-fA-F]{2}:.*" (builtins.elemAt m 0) != null;
|
||||
message = "router: ${where}.ipv6.address must be a unique-local address (fc00::/7).";
|
||||
}
|
||||
]
|
||||
)
|
||||
++ lib.optionals lan.dhcp.enable (
|
||||
[
|
||||
{
|
||||
@@ -58,35 +102,139 @@ let
|
||||
message = "router: ${where}.dhcp.pool contains the router's own address ${lan.ipv4.address}.";
|
||||
}
|
||||
]
|
||||
)
|
||||
++ lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: r:
|
||||
let
|
||||
rwhere = "${where}.dhcp.reservations.${host}";
|
||||
rok = validIp r.address;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = hostnameOk host;
|
||||
message = "router: ${rwhere}: `${host}` is not a valid hostname (letters, digits and dashes).";
|
||||
}
|
||||
{
|
||||
assertion = builtins.match "([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}" r.mac != null;
|
||||
message = "router: ${rwhere}.mac `${r.mac}` is not a MAC address like aa:bb:cc:dd:ee:ff.";
|
||||
}
|
||||
{
|
||||
assertion = rok;
|
||||
message = "router: ${rwhere}.address `${r.address}` is not a valid IPv4 address.";
|
||||
}
|
||||
{
|
||||
assertion = lan.dhcp.enable;
|
||||
message = "router: ${rwhere}: reservations need dhcp.enable on the LAN.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (rok && addrOk) [
|
||||
{
|
||||
assertion = ipv4.inSubnet net (ipv4.parse r.address);
|
||||
message = "router: ${rwhere}.address ${r.address} is not inside ${lan.ipv4.subnet}.";
|
||||
}
|
||||
{
|
||||
assertion = r.address != lan.ipv4.address;
|
||||
message = "router: ${rwhere}.address is the router's own address.";
|
||||
}
|
||||
]
|
||||
) lan.dhcp.reservations
|
||||
)
|
||||
++ (
|
||||
let
|
||||
rs = lib.attrValues lan.dhcp.reservations;
|
||||
macs = map (r: lib.toLower r.mac) rs;
|
||||
addrs = map (r: r.address) rs;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.length macs == lib.length (lib.unique macs);
|
||||
message = "router: ${where}.dhcp.reservations: two reservations share a MAC address.";
|
||||
}
|
||||
{
|
||||
assertion = lib.length addrs == lib.length (lib.unique addrs);
|
||||
message = "router: ${where}.dhcp.reservations: two reservations share an address.";
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
zoneNames = lib.unique (map (l: l.zone) lans);
|
||||
wgs = lib.attrValues cfg.wireguard;
|
||||
zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs ++ cfg.firewall.zones);
|
||||
|
||||
isV6 = lib.hasInfix ":";
|
||||
v4Cidr = s: lib.hasInfix "/" s && ipv4.parseNet s != null;
|
||||
v6Cidr =
|
||||
s:
|
||||
let
|
||||
m = builtins.match "([0-9a-fA-F:.]+)/([0-9]{1,3})" s;
|
||||
in
|
||||
isV6 s && m != null && lib.toInt (builtins.elemAt m 1) <= 128;
|
||||
validCidr = s: v4Cidr s || v6Cidr s;
|
||||
|
||||
lanNets = map (l: {
|
||||
address = ipv4.parse l.ipv4.address;
|
||||
inherit (l.ipv4) prefixLength;
|
||||
}) (lib.filter (l: validIp l.ipv4.address) lans);
|
||||
|
||||
# IPv4 networks a tunnel brings into its zone (own addresses and what peers may send).
|
||||
wgV4Nets =
|
||||
w:
|
||||
lib.filter (n: n != null) (
|
||||
map ipv4.parseNet (
|
||||
lib.filter (n: !isV6 n && v4Cidr n) (
|
||||
w.addresses ++ lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)
|
||||
)
|
||||
)
|
||||
);
|
||||
zoneV4Nets =
|
||||
zone:
|
||||
map (l: {
|
||||
address = ipv4.parse l.ipv4.address;
|
||||
inherit (l.ipv4) prefixLength;
|
||||
}) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans)
|
||||
++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs);
|
||||
|
||||
portOk =
|
||||
p:
|
||||
if builtins.isInt p then
|
||||
p >= 1 && p <= 65535
|
||||
else
|
||||
let
|
||||
m = builtins.match "([0-9]+)-([0-9]+)" p;
|
||||
lo = lib.toInt (builtins.elemAt m 0);
|
||||
hi = lib.toInt (builtins.elemAt m 1);
|
||||
in
|
||||
lo >= 1 && hi <= 65535 && lo < hi;
|
||||
|
||||
ruleAssertions =
|
||||
r:
|
||||
let
|
||||
where = "router.firewall.rules (${r.from} -> ${r.to})";
|
||||
dest = if r.destination == null then null else ipv4.parseNet r.destination;
|
||||
destLans = lib.filter (l: l.zone == r.to && validIp l.ipv4.address) lans;
|
||||
fromWan = r.from == "wan";
|
||||
v6Dest = r.destination != null && isV6 r.destination;
|
||||
dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination;
|
||||
inZone =
|
||||
dest != null
|
||||
&& lib.any (
|
||||
l:
|
||||
dest.prefixLength >= l.ipv4.prefixLength
|
||||
&& ipv4.inSubnet {
|
||||
address = ipv4.parse l.ipv4.address;
|
||||
inherit (l.ipv4) prefixLength;
|
||||
} dest.address
|
||||
) destLans;
|
||||
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
|
||||
zoneV4Nets r.to
|
||||
);
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.elem r.from zoneNames;
|
||||
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
assertion = fromWan || lib.elem r.from zoneNames;
|
||||
message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = lib.elem r.to zoneNames;
|
||||
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
assertion = r.to == "local" || lib.elem r.to zoneNames;
|
||||
message =
|
||||
if r.to == "wan" then
|
||||
"router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel."
|
||||
else
|
||||
"router: ${where}: unknown destination zone `${r.to}` (zones: local, ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = !(r.to == "local" && r.natOnly);
|
||||
message = "router: ${where}: natOnly does not apply to the router itself (`local`); a port forward leads to a host in a zone.";
|
||||
}
|
||||
{
|
||||
assertion = r.from != r.to;
|
||||
@@ -96,20 +244,274 @@ let
|
||||
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
||||
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
assertion = r.sourcePorts == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
||||
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all portOk (r.ports ++ r.sourcePorts);
|
||||
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
||||
}
|
||||
{
|
||||
assertion = lib.all validCidr (
|
||||
map (
|
||||
x:
|
||||
if lib.hasInfix "/" x then
|
||||
x
|
||||
else if isV6 x then
|
||||
"${x}/128"
|
||||
else
|
||||
"${x}/32"
|
||||
) r.sources
|
||||
);
|
||||
message = "router: ${where}: every source must be an IPv4/IPv6 address or CIDR.";
|
||||
}
|
||||
]
|
||||
# Rules from the outside must not be able to expose a whole zone by accident.
|
||||
++ lib.optionals fromWan [
|
||||
{
|
||||
assertion = r.destination != null || r.to == "local";
|
||||
message = "router: ${where}: rules from `wan` must name a destination host or network.";
|
||||
}
|
||||
{
|
||||
assertion = r.protocol != "any";
|
||||
message = "router: ${where}: rules from `wan` must name a protocol (tcp, udp or icmp).";
|
||||
}
|
||||
{
|
||||
assertion = r.protocol == "icmp" || r.protocol == "any" || r.ports != [ ];
|
||||
message = "router: ${where}: rules from `wan` must list the ports they open.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (r.destination != null) [
|
||||
{
|
||||
assertion = dest != null;
|
||||
assertion = v6Dest || dest != null;
|
||||
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
|
||||
}
|
||||
{
|
||||
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
|
||||
message = "router: ${where}: destination `${r.destination}` is not inside any LAN of zone `${r.to}`.";
|
||||
assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone;
|
||||
message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`.";
|
||||
}
|
||||
{
|
||||
assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
||||
assertion = v6Dest || dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
||||
message = "router: ${where}: destination `${r.destination}` has host bits set.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
!v6Dest
|
||||
|| v6Cidr (if lib.hasInfix "/" r.destination then r.destination else "${r.destination}/128");
|
||||
message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR.";
|
||||
}
|
||||
]
|
||||
;
|
||||
|
||||
natAssertions =
|
||||
n:
|
||||
let
|
||||
where = "router.firewall.nat (${n.type} ${toString n.from} -> ${n.to})";
|
||||
isDnat = n.type == "dnat";
|
||||
isSnat = n.type == "snat";
|
||||
v4Ok = a: (v4Cidr (if lib.hasInfix "/" a then a else "${a}/32"));
|
||||
toAddr = if n.toAddress != null && validIp n.toAddress then ipv4.parseNet "${n.toAddress}/32" else null;
|
||||
inZone =
|
||||
toAddr != null
|
||||
&& lib.any (z: toAddr.prefixLength >= z.prefixLength && ipv4.inSubnet z toAddr.address) (zoneV4Nets n.to);
|
||||
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
|
||||
plainPorts = lib.all builtins.isInt n.ports;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = n.from == null || n.from == "wan" || lib.elem n.from zoneNames;
|
||||
message = "router: ${where}: unknown source zone `${toString n.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = lib.elem n.to zoneNames || (!isDnat && n.to == "wan");
|
||||
message = "router: ${where}: unknown zone `${n.to}` (zones: ${lib.concatStringsSep ", " zoneNames}${lib.optionalString (!isDnat) ", wan"}).";
|
||||
}
|
||||
{
|
||||
assertion = !isDnat || n.from != null;
|
||||
message = "router: ${where}: a port forward needs a source zone (`from`).";
|
||||
}
|
||||
{
|
||||
assertion = (n.ports == [ ] && n.sourcePorts == [ ]) || n.protocol == "tcp" || n.protocol == "udp";
|
||||
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all portOk (n.ports ++ n.sourcePorts);
|
||||
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
||||
}
|
||||
{
|
||||
assertion = lib.all v4Ok n.sources;
|
||||
message = "router: ${where}: every source must be an IPv4 address or CIDR (translation is IPv4 only).";
|
||||
}
|
||||
{
|
||||
assertion = n.destination == null || v4Ok n.destination;
|
||||
message = "router: ${where}: destination `${toString n.destination}` is not an IPv4 address or CIDR.";
|
||||
}
|
||||
{
|
||||
assertion = isDnat || (n.toPort == null && !n.hairpin);
|
||||
message = "router: ${where}: toPort and hairpin only apply to port forwards (dnat).";
|
||||
}
|
||||
{
|
||||
assertion = (isDnat || isSnat) == (n.toAddress != null);
|
||||
message = "router: ${where}: dnat and snat need a toAddress; masquerade has none.";
|
||||
}
|
||||
{
|
||||
assertion = n.toAddress == null || validIp n.toAddress;
|
||||
message = "router: ${where}: toAddress `${toString n.toAddress}` is not a valid IPv4 address.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals isDnat [
|
||||
{
|
||||
assertion = n.protocol == "tcp" || n.protocol == "udp";
|
||||
message = "router: ${where}: a port forward needs protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
assertion = n.ports != [ ];
|
||||
message = "router: ${where}: a port forward must list the ports it forwards.";
|
||||
}
|
||||
{
|
||||
assertion = toAddr == null || !(lib.elem n.to zoneNames) || inZone;
|
||||
message = "router: ${where}: toAddress `${toString n.toAddress}` is not inside any network of zone `${n.to}`.";
|
||||
}
|
||||
{
|
||||
assertion = n.toPort == null || (lib.length n.ports == 1 && plainPorts);
|
||||
message = "router: ${where}: toPort needs exactly one (non-range) port.";
|
||||
}
|
||||
{
|
||||
assertion = !n.hairpin || n.from == "wan";
|
||||
message = "router: ${where}: hairpin only applies to forwards from `wan`.";
|
||||
}
|
||||
{
|
||||
assertion = !n.hairpin || internetMember;
|
||||
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (!isDnat) [
|
||||
{
|
||||
assertion = n.from == null || n.from != n.to;
|
||||
message = "router: ${where}: source and destination are the same zone.";
|
||||
}
|
||||
];
|
||||
|
||||
dnsCfg = cfg.dns;
|
||||
anyIp = s: validIp s || v6Cidr "${s}/128";
|
||||
dnsAssertions =
|
||||
let
|
||||
names =
|
||||
lib.attrNames dnsCfg.hosts ++ lib.attrNames dnsCfg.overrides ++ lib.attrNames dnsCfg.forwardZones;
|
||||
recordSets = dnsCfg.hosts // dnsCfg.overrides;
|
||||
shortNames = lib.filter (n: !(lib.hasInfix "." n)) (lib.attrNames dnsCfg.hosts);
|
||||
reservationNames = lib.concatMap (l: lib.attrNames l.dhcp.reservations) lans;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.all dnsNameOk names && (dnsCfg.domain == null || dnsNameOk dnsCfg.domain);
|
||||
message = "router.dns: a name in domain/hosts/overrides/forwardZones is not a valid DNS name.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues recordSets);
|
||||
message = "router.dns: every host/override needs at least one valid IPv4 or IPv6 address.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues dnsCfg.forwardZones);
|
||||
message = "router.dns.forwardZones: every zone needs at least one valid resolver address.";
|
||||
}
|
||||
{
|
||||
assertion = dnsCfg.domain != null || shortNames == [ ];
|
||||
message = "router.dns.hosts: `${lib.concatStringsSep ", " shortNames}` has no dot, so router.dns.domain must be set.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
lib.length (lib.attrNames dnsCfg.hosts) == lib.length (
|
||||
lib.unique (
|
||||
map (n: if lib.hasInfix "." n || dnsCfg.domain == null then n else "${n}.${dnsCfg.domain}") (
|
||||
lib.attrNames dnsCfg.hosts
|
||||
)
|
||||
)
|
||||
);
|
||||
message = "router.dns.hosts: two names are the same once placed in the domain.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (n: !(lib.elem n (lib.attrNames dnsCfg.hosts))) reservationNames;
|
||||
message = "router.dns.hosts: a name is also a DHCP reservation, which already publishes it.";
|
||||
}
|
||||
];
|
||||
|
||||
overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets;
|
||||
|
||||
wgAssertions =
|
||||
name: w:
|
||||
let
|
||||
where = "router.wireguard.${name}";
|
||||
peers = lib.attrValues w.peers;
|
||||
allowed = lib.concatMap (p: p.allowedIPs) peers;
|
||||
v4Allowed = lib.filter (n: v4Cidr n) allowed;
|
||||
v4Own = lib.filter v4Cidr w.addresses;
|
||||
asNet = n: ipv4.parseNet n;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = w.peers != { };
|
||||
message = "router: ${where} has no peers.";
|
||||
}
|
||||
{
|
||||
assertion = lib.stringLength w.interface <= 15;
|
||||
message = "router: ${where}: device name `${w.interface}` is longer than 15 characters.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all anyIp w.sourceAddresses;
|
||||
message = "router: ${where}.sourceAddresses must be IPv4/IPv6 addresses.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
lib.length (lib.filter isV6 w.sourceAddresses) <= 1
|
||||
&& lib.length (lib.filter (s: !isV6 s) w.sourceAddresses) <= 1;
|
||||
message = "router: ${where}.sourceAddresses may hold at most one address per family.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (
|
||||
s:
|
||||
isV6 s
|
||||
|| !(validIp s)
|
||||
|| lib.elem s (
|
||||
map (l: l.ipv4.address) lans
|
||||
++ map (a: builtins.head (lib.splitString "/" a)) (lib.concatMap (x: x.addresses) wgs)
|
||||
)
|
||||
) w.sourceAddresses;
|
||||
message = "router: ${where}.sourceAddresses: an IPv4 source must be the router's address on a LAN or a tunnel.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all validCidr w.addresses;
|
||||
message = "router: ${where}.addresses must be IPv4/IPv6 addresses in CIDR notation.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all validCidr allowed;
|
||||
message = "router: ${where}: every peer allowedIPs entry must be an IPv4/IPv6 CIDR.";
|
||||
}
|
||||
{
|
||||
assertion = !(lib.any (n: lib.hasSuffix "/0" n) allowed);
|
||||
message = "router: ${where}: default-route tunnels (allowedIPs ending in /0) are not supported; list the destinations instead.";
|
||||
}
|
||||
{
|
||||
assertion = lib.length allowed == lib.length (lib.unique allowed);
|
||||
message = "router: ${where}: an allowedIPs entry appears more than once (WireGuard would give it to one peer only).";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (n: !(overlapsLan (asNet n))) (
|
||||
lib.filter (n: !lib.hasSuffix "/0" n) (v4Own ++ v4Allowed)
|
||||
);
|
||||
message = "router: ${where}: a tunnel address or allowedIPs entry overlaps one of the LAN subnets.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (p: builtins.match "[A-Za-z0-9+/]{43}=" p.publicKey != null) peers;
|
||||
message = "router: ${where}: a peer publicKey is not a valid WireGuard public key.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (
|
||||
p: p.endpoint == null || builtins.match ".+:[0-9]{1,5}" p.endpoint != null
|
||||
) peers;
|
||||
message = "router: ${where}: a peer endpoint must look like host:port or [ipv6]:port.";
|
||||
}
|
||||
];
|
||||
|
||||
overlapPairs = lib.concatLists (
|
||||
@@ -128,13 +530,45 @@ in
|
||||
assertion = lans != [ ];
|
||||
message = "router: at least one LAN must be defined.";
|
||||
}
|
||||
{
|
||||
assertion = lib.length macs == lib.length (lib.unique macs);
|
||||
message = "router.interfaces: two names are given to the same MAC address.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all chosenNameOk chosenNames;
|
||||
message = "router: names for interfaces and VLAN devices are lower case letters, digits and dashes, start with a letter, have at most 15 characters and do not look like a kernel name (eth0, wlan0, enp2s0, ...): ${lib.concatStringsSep ", " (lib.filter (n: !chosenNameOk n) chosenNames)}.";
|
||||
}
|
||||
{
|
||||
assertion = lib.length chosenNames == lib.length (lib.unique chosenNames);
|
||||
message = "router: a name is used twice among `interfaces` and the VLAN devices.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.wan.ipv4.pppoe.vlanName == null || cfg.wan.ipv4.pppoe.vlan != null;
|
||||
message = "router.wan.ipv4.pppoe.vlanName needs pppoe.vlan.";
|
||||
}
|
||||
{
|
||||
assertion = lib.any (l: l.allowManagement) lans;
|
||||
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
|
||||
}
|
||||
{
|
||||
assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans));
|
||||
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN.";
|
||||
assertion =
|
||||
!(lib.elem cfg.wan.interface (map (l: l.interface) lans))
|
||||
|| (
|
||||
cfg.wan.ipv4.type == "pppoe"
|
||||
&& cfg.wan.ipv4.pppoe.vlan != null
|
||||
&& (!cfg.wan.ipv6.enable || cfg.wan.ipv6.transport == "ppp")
|
||||
);
|
||||
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN (only allowed for PPPoE on a VLAN, with IPv6 disabled or inside the PPP session).";
|
||||
}
|
||||
{
|
||||
assertion = cfg.wan.ipv6.transport == "native" || cfg.wan.ipv4.type == "pppoe";
|
||||
message = "router: wan.ipv6.transport = \"ppp\" needs a PPPoE uplink (wan.ipv4.type = \"pppoe\").";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
cfg.wan.ipv4.pppoe.vlan == null
|
||||
|| !(lib.any (l: l.interface == cfg.wan.interface && l.vlan == cfg.wan.ipv4.pppoe.vlan) lans);
|
||||
message = "router: VLAN ${toString cfg.wan.ipv4.pppoe.vlan} on `${cfg.wan.interface}` is used by both the PPPoE uplink and a LAN.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
|
||||
@@ -162,7 +596,39 @@ in
|
||||
}
|
||||
]
|
||||
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
||||
++ dnsAssertions
|
||||
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
||||
++ lib.concatMap natAssertions cfg.firewall.nat
|
||||
++ [
|
||||
{
|
||||
assertion = !(lib.elem "wan" zoneNames);
|
||||
message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`.";
|
||||
}
|
||||
{
|
||||
assertion = !(lib.elem "local" zoneNames);
|
||||
message = "router: `local` is a reserved zone name (the router itself); give the LAN or tunnel a different `zone`.";
|
||||
}
|
||||
]
|
||||
++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard)
|
||||
++ [
|
||||
{
|
||||
assertion =
|
||||
let
|
||||
ports = lib.filter (p: p != null) (map (w: w.listenPort) wgs);
|
||||
in
|
||||
lib.length ports == lib.length (lib.unique ports);
|
||||
message = "router: two WireGuard tunnels listen on the same UDP port.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
let
|
||||
own = map (w: w.interface) wgs;
|
||||
others = map (l: l.device) lans ++ [ cfg.wan.interface ];
|
||||
in
|
||||
lib.length own == lib.length (lib.unique own) && !(lib.any (d: lib.elem d others) own);
|
||||
message = "router: a WireGuard device name clashes with a LAN device or the WAN interface.";
|
||||
}
|
||||
]
|
||||
++ map (
|
||||
{ a, b }:
|
||||
let
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Dynamic DNS: ddclient keeps the provider's records on the uplink's addresses.
|
||||
# It runs from a timer; a watcher starts it as soon as an address of the uplink
|
||||
# changes, so a new PPPoE session or lease is published within seconds.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router;
|
||||
ddns = cfg.ddns;
|
||||
# The connection (an empty one when it is missing: the assertion says so).
|
||||
providers = builtins.fromJSON (builtins.readFile ../../pkgs/router-webui/providers.json);
|
||||
has = ddns.provider != null && cfg.dnsProviders ? ${ddns.provider};
|
||||
prov =
|
||||
cfg.dnsProviders.${if ddns.provider == null then "" else ddns.provider} or {
|
||||
type = "none";
|
||||
username = "token";
|
||||
server = null;
|
||||
secretFile = null;
|
||||
};
|
||||
|
||||
# Where the addresses are: IPv4 on the PPP device, IPv6 on the physical uplink
|
||||
# (or the PPP device when IPv6 runs over it).
|
||||
dev4 = cfg.wan.ipv4.interface;
|
||||
dev6 = if cfg.wan.ipv6.enable && cfg.wan.ipv6.transport == "ppp" then dev4 else cfg.wan.interface;
|
||||
devices = lib.unique (lib.optional ddns.ipv4 dev4 ++ lib.optional ddns.ipv6 dev6);
|
||||
|
||||
watch = pkgs.writeShellApplication {
|
||||
name = "router-ddns-watch";
|
||||
runtimeInputs = [
|
||||
pkgs.iproute2
|
||||
pkgs.gnugrep
|
||||
pkgs.systemd
|
||||
];
|
||||
text = ''
|
||||
# `ip monitor` also reports the lines of devices that do not exist yet.
|
||||
ip -o monitor address \
|
||||
| grep --line-buffered ${lib.concatMapStringsSep " " (d: "-e ' ${d} '") devices} \
|
||||
| while read -r _; do
|
||||
sleep 5 # an address change comes as several events
|
||||
systemctl start --no-block ddclient.service
|
||||
done
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && ddns.enable) {
|
||||
assertions = [
|
||||
{
|
||||
assertion = ddns.domains != [ ];
|
||||
message = "router.ddns: no domains to update.";
|
||||
}
|
||||
{
|
||||
assertion = ddns.ipv4 || ddns.ipv6;
|
||||
message = "router.ddns: neither IPv4 nor IPv6 is enabled.";
|
||||
}
|
||||
{
|
||||
assertion = has;
|
||||
message = "router.ddns.provider must name a connection in router.dnsProviders.";
|
||||
}
|
||||
{
|
||||
assertion = !has || lib.elem prov.type providers.ddns;
|
||||
message = "router.ddns: ddclient has no protocol ${prov.type}.";
|
||||
}
|
||||
{
|
||||
assertion = !has || (prov.secretFile != null && !lib.hasPrefix "/nix/store" prov.secretFile);
|
||||
message = "The secretFile of router.dnsProviders.${toString ddns.provider} must name a file outside the Nix store (the secret must not be copied into it).";
|
||||
}
|
||||
];
|
||||
|
||||
services.ddclient = {
|
||||
enable = true;
|
||||
inherit (ddns) domains ssl interval;
|
||||
protocol = prov.type;
|
||||
inherit (prov) username;
|
||||
passwordFile = prov.secretFile;
|
||||
server = lib.optionalString (prov.server != null) prov.server;
|
||||
zone = lib.optionalString (ddns.zone != null) ddns.zone;
|
||||
usev4 = lib.optionalString ddns.ipv4 "ifv4, ifv4=${dev4}";
|
||||
usev6 = lib.optionalString ddns.ipv6 "ifv6, ifv6=${dev6}";
|
||||
# ddclient only sends a changed address; its default of 30 s between
|
||||
# updates would hold back a new one that follows closely on another
|
||||
# (a PPP session that drops and comes back).
|
||||
extraConfig = "min-interval=1s";
|
||||
};
|
||||
|
||||
systemd.services.router-ddns-watch = {
|
||||
description = "Update dynamic DNS when the uplink's address changes";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
ExecStart = lib.getExe watch;
|
||||
Restart = "always";
|
||||
RestartSec = 5;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -4,8 +4,13 @@
|
||||
./assertions.nix
|
||||
./networking.nix
|
||||
./pppoe.nix
|
||||
./wireguard.nix
|
||||
./firewall.nix
|
||||
./dhcp.nix
|
||||
./dns.nix
|
||||
./ddns.nix
|
||||
./acme.nix
|
||||
./rollback.nix
|
||||
./power.nix
|
||||
];
|
||||
}
|
||||
|
||||
+47
-1
@@ -1,4 +1,9 @@
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router;
|
||||
dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans);
|
||||
@@ -6,6 +11,31 @@ let
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && dhcpLans != [ ]) {
|
||||
# Kea skips interfaces without carrier when it opens its sockets and only
|
||||
# looks again on a reload: a LAN cable plugged in after boot would get no
|
||||
# DHCP until a restart. So restart it when a DHCP LAN gets carrier.
|
||||
services.networkd-dispatcher = {
|
||||
enable = true;
|
||||
rules.kea-carrier = {
|
||||
onState = [
|
||||
"carrier"
|
||||
"degraded"
|
||||
"routable"
|
||||
];
|
||||
# The same for every configuration (the interfaces are in /etc): the
|
||||
# module builds it with writeShellApplication, whose shellcheck run
|
||||
# could not be rebuilt offline on the device.
|
||||
# Not a reload: a SIGHUP while Kea is still starting ends it (cleanly,
|
||||
# so systemd does not restart it).
|
||||
script = ''
|
||||
if ${pkgs.gnugrep}/bin/grep -qxF "$IFACE" /etc/router/dhcp-interfaces; then
|
||||
${config.systemd.package}/bin/systemctl try-restart --no-block kea-dhcp4-server.service
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
environment.etc."router/dhcp-interfaces".text = lib.concatMapStrings (l: "${l.device}\n") dhcpLans;
|
||||
|
||||
services.kea.dhcp4 = {
|
||||
enable = true;
|
||||
settings = {
|
||||
@@ -27,6 +57,11 @@ in
|
||||
inherit (l.ipv4) subnet;
|
||||
interface = l.device;
|
||||
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
|
||||
reservations = lib.mapAttrsToList (hostname: r: {
|
||||
hw-address = lib.toLower r.mac;
|
||||
ip-address = r.address;
|
||||
inherit hostname;
|
||||
}) l.dhcp.reservations;
|
||||
option-data = [
|
||||
{
|
||||
name = "routers";
|
||||
@@ -36,6 +71,17 @@ in
|
||||
name = "domain-name-servers";
|
||||
data = l.ipv4.address;
|
||||
}
|
||||
]
|
||||
# The local domain: clients use it for short names (nas -> nas.home...).
|
||||
++ lib.optionals (cfg.dns.domain != null) [
|
||||
{
|
||||
name = "domain-name";
|
||||
data = cfg.dns.domain;
|
||||
}
|
||||
{
|
||||
name = "domain-search";
|
||||
data = cfg.dns.domain;
|
||||
}
|
||||
];
|
||||
}) dhcpLans;
|
||||
};
|
||||
|
||||
+106
-7
@@ -2,29 +2,128 @@
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
|
||||
|
||||
# Tunnels that opted into DNS: listen on each of their addresses.
|
||||
dnsAddrs = lib.concatMap (w: w.addresses) (lib.filter (w: w.dns) (lib.attrValues cfg.wireguard));
|
||||
addrOf = a: builtins.head (lib.splitString "/" a);
|
||||
# IPv4 gets the proper network address; IPv6 netblocks are masked by Unbound.
|
||||
netOf =
|
||||
a:
|
||||
let
|
||||
parts = lib.splitString "/" a;
|
||||
in
|
||||
if lib.hasInfix ":" a then
|
||||
a
|
||||
else
|
||||
ipv4.cidr (builtins.head parts) (lib.toInt (builtins.elemAt parts 1));
|
||||
|
||||
dns = cfg.dns;
|
||||
inherit (dns) domain;
|
||||
fqdn = name: if lib.hasInfix "." name || domain == null then name else "${name}.${domain}";
|
||||
isV6 = lib.hasInfix ":";
|
||||
rrOf = a: if isV6 a then "AAAA" else "A";
|
||||
|
||||
# name -> addresses: explicit hosts plus DHCP reservations (A records only).
|
||||
reservations = lib.concatMap (
|
||||
l: lib.mapAttrsToList (n: r: lib.nameValuePair n [ r.address ]) l.dhcp.reservations
|
||||
) lans;
|
||||
# The router's own name resolves to its addresses on the LANs (and their
|
||||
# ULAs), so clients can reach it by name; with a domain also bare, for
|
||||
# clients that have no search domain.
|
||||
selfName = config.networking.hostName;
|
||||
selfAddrs =
|
||||
map (l: l.ipv4.address) lans
|
||||
++ map (l: addrOf l.ipv6.address) (lib.filter (l: l.ipv6.address != null) lans);
|
||||
# Unless dns.hosts names the router itself.
|
||||
selfDefined = lib.hasAttr selfName dns.hosts || lib.hasAttr (fqdn selfName) dns.hosts;
|
||||
selfRecord = lib.optional (!selfDefined) (lib.nameValuePair (fqdn selfName) selfAddrs);
|
||||
# (No reverse record for the bare name: the address has one already.)
|
||||
selfBare = lib.optional (!selfDefined && fqdn selfName != selfName) (
|
||||
lib.nameValuePair selfName selfAddrs
|
||||
);
|
||||
|
||||
hostRecords = lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts;
|
||||
reservationRecords = map (r: lib.nameValuePair (fqdn r.name) r.value) reservations;
|
||||
records =
|
||||
selfRecord
|
||||
++ hostRecords
|
||||
++ reservationRecords
|
||||
++ lib.mapAttrsToList (n: a: lib.nameValuePair n a) dns.overrides;
|
||||
|
||||
# What the configuration publishes itself: dynamic names (the clients' host
|
||||
# names, see dns.dynamicLeases) must never take one of these over.
|
||||
staticNames = builtins.toJSON {
|
||||
inherit domain;
|
||||
names = map (r: r.name) (selfRecord ++ hostRecords ++ reservationRecords) ++ map (r: r.name) selfBare;
|
||||
overrides = lib.attrNames dns.overrides;
|
||||
forwardZones = lib.attrNames dns.forwardZones;
|
||||
};
|
||||
|
||||
# Unbound reads these as one quoted argument.
|
||||
localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) (records ++ selfBare);
|
||||
# Reverse records for hosts and reservations (not for wildcard overrides).
|
||||
ptrs = lib.concatMap (r: map (a: ''"${a} ${r.name}."'') r.value) (
|
||||
lib.filter (r: !(lib.hasAttr r.name dns.overrides)) records
|
||||
);
|
||||
privateZones = lib.attrNames dns.forwardZones ++ lib.optional (domain != null) domain;
|
||||
|
||||
# Peers of tunnels that use the resolver may be remote networks with no
|
||||
# address of their own on the tunnel (site-to-site); allow what they route.
|
||||
dnsPeerNets = lib.filter (n: !lib.hasSuffix "/0" n) (
|
||||
lib.concatMap (w: lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)) (
|
||||
lib.filter (w: w.dns) (lib.attrValues cfg.wireguard)
|
||||
)
|
||||
);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.etc."router/dns-static.json".text = staticNames;
|
||||
warnings = lib.optional (dns.dynamicLeases && domain == null) ''
|
||||
router.dns.dynamicLeases has no effect without router.dns.domain: the clients' names need a domain to live in.
|
||||
'';
|
||||
|
||||
services.unbound = {
|
||||
enable = true;
|
||||
# What router-webui leases-dns publishes the clients' names through.
|
||||
localControlSocketPath = lib.mkIf dns.dynamicLeases "/run/unbound/unbound.ctl";
|
||||
settings = {
|
||||
server = {
|
||||
interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans;
|
||||
interface = [
|
||||
"127.0.0.1"
|
||||
]
|
||||
++ map (l: l.ipv4.address) lans
|
||||
++ map (l: addrOf l.ipv6.address) (lib.filter (l: l.ipv6.address != null) lans)
|
||||
++ map addrOf dnsAddrs;
|
||||
# LAN addresses are assigned by networkd, possibly after Unbound starts.
|
||||
ip-freebind = true;
|
||||
access-control = [
|
||||
"127.0.0.0/8 allow"
|
||||
]
|
||||
++ map (l: "${l.ipv4.subnet} allow") lans;
|
||||
++ map (l: "${l.ipv4.subnet} allow") lans
|
||||
++ map (l: "${l.ipv6.address} allow") (lib.filter (l: l.ipv6.address != null) lans)
|
||||
++ map (a: "${netOf a} allow") dnsAddrs
|
||||
++ map (n: "${n} allow") dnsPeerNets;
|
||||
local-zone =
|
||||
lib.optional (domain != null) "${domain}. static"
|
||||
# The router's bare name is a zone of its own (not under the domain).
|
||||
++ lib.optional (!lib.hasInfix "." selfName) "${selfName}. transparent"
|
||||
++ map (n: "${n}. redirect") (lib.attrNames dns.overrides);
|
||||
local-data = localData;
|
||||
local-data-ptr = ptrs;
|
||||
domain-insecure = privateZones ++ lib.attrNames dns.overrides;
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
|
||||
forward-zone = [
|
||||
{
|
||||
// lib.optionalAttrs (cfg.dns.forwarders != [ ] || dns.forwardZones != { }) {
|
||||
forward-zone =
|
||||
lib.mapAttrsToList (name: addrs: {
|
||||
inherit name;
|
||||
forward-addr = addrs;
|
||||
}) dns.forwardZones
|
||||
++ lib.optional (cfg.dns.forwarders != [ ]) {
|
||||
name = ".";
|
||||
forward-addr = cfg.dns.forwarders;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
+237
-31
@@ -4,46 +4,225 @@ let
|
||||
lans = lib.attrValues cfg.lans;
|
||||
|
||||
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
|
||||
lanIfaces = map (l: l.device) lans;
|
||||
mgmtIfaces = map (l: l.device) (lib.filter (l: l.allowManagement) lans);
|
||||
internetIfaces = map (l: l.device) (lib.filter (l: l.internet) lans);
|
||||
wgs = lib.attrValues cfg.wireguard;
|
||||
isV6 = lib.hasInfix ":";
|
||||
|
||||
# zone name -> devices of its member LANs
|
||||
zones = lib.mapAttrs (_: ls: map (l: l.device) ls) (lib.groupBy (l: l.zone) lans);
|
||||
# Everything that is a firewall zone member: LANs and tunnels.
|
||||
members =
|
||||
map (l: {
|
||||
inherit (l)
|
||||
device
|
||||
zone
|
||||
internet
|
||||
allowManagement
|
||||
;
|
||||
}) lans
|
||||
++ map (w: {
|
||||
device = w.interface;
|
||||
inherit (w) zone internet allowManagement;
|
||||
}) wgs;
|
||||
devicesOf = pred: map (m: m.device) (lib.filter pred members);
|
||||
|
||||
lanIfaces = map (l: l.device) lans;
|
||||
memberIfaces = devicesOf (_: true);
|
||||
dnsIfaces = lanIfaces ++ map (w: w.interface) (lib.filter (w: w.dns) wgs);
|
||||
mgmtIfaces = devicesOf (m: m.allowManagement);
|
||||
internetIfaces = devicesOf (m: m.internet);
|
||||
listenPorts = lib.unique (map (w: w.listenPort) (lib.filter (w: w.listenPort != null) wgs));
|
||||
masqueradeIfaces = map (w: w.interface) (lib.filter (w: w.masquerade) wgs);
|
||||
|
||||
# Destinations a kill-switched tunnel must never leak to an uplink.
|
||||
killSwitched = lib.filter (w: w.killSwitch) wgs;
|
||||
killNets = lib.concatMap (w: lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)) killSwitched;
|
||||
killRules =
|
||||
let
|
||||
v4 = lib.filter (n: !isV6 n) killNets;
|
||||
v6 = lib.filter isV6 killNets;
|
||||
list = ns: "{ ${lib.concatStringsSep ", " ns} }";
|
||||
in
|
||||
lib.optional (v4 != [ ]) "oifname ${set uplinks} ip daddr ${list v4} drop"
|
||||
++ lib.optional (v6 != [ ]) "oifname ${set uplinks} ip6 daddr ${list v6} drop";
|
||||
|
||||
# zone name -> devices of its members
|
||||
zones = lib.mapAttrs (_: ms: map (m: m.device) ms) (lib.groupBy (m: m.zone) members);
|
||||
zoneDevices = zone: zones.${zone} or [ ];
|
||||
|
||||
# LANs sharing a zone may forward to each other (a single LAN is plain L2).
|
||||
# Members of one zone may forward to each other, but never back out of the
|
||||
# device they came in on (a tunnel's peers do not see each other).
|
||||
intraZone = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
zone: devs:
|
||||
lib.optional (lib.length devs > 1) "iifname ${set devs} oifname ${set devs} accept # zone ${zone}"
|
||||
lib.optionals (lib.length devs > 1) (
|
||||
map (
|
||||
d: "iifname ${set [ d ]} oifname ${set (lib.filter (x: x != d) devs)} accept # zone ${zone}"
|
||||
) devs
|
||||
)
|
||||
) zones
|
||||
);
|
||||
|
||||
ruleToNft =
|
||||
setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }";
|
||||
|
||||
# Layer 4 match; `ports` and `sports` may be ints or "a-b" ranges.
|
||||
l4 =
|
||||
proto: ports: sports:
|
||||
if proto == "any" then
|
||||
""
|
||||
else if proto == "icmp" then
|
||||
"meta l4proto { icmp, ipv6-icmp } "
|
||||
else
|
||||
"${lib.optionalString (sports != [ ]) "${proto} sport ${setOf sports} "}${proto} dport ${
|
||||
if ports == [ ] then "1-65535" else setOf ports
|
||||
} ";
|
||||
|
||||
# Destination match, by family of the address.
|
||||
daddrOf =
|
||||
r:
|
||||
lib.optionalString (r.destination != null) (
|
||||
"${if isV6 r.destination then "ip6" else "ip"} daddr ${r.destination} "
|
||||
);
|
||||
|
||||
# One variant per source family; no sources means one unrestricted variant.
|
||||
# `key` is the family selector used for per-source rate limiting (null: both).
|
||||
variantsOf =
|
||||
r:
|
||||
let
|
||||
dest = lib.optionalString (r.destination != null) "ip daddr ${r.destination} ";
|
||||
proto =
|
||||
if r.protocol == "any" then
|
||||
""
|
||||
else if r.protocol == "icmp" then
|
||||
"meta l4proto { icmp, ipv6-icmp } "
|
||||
v4 = lib.filter (x: !isV6 x) r.sources;
|
||||
v6 = lib.filter isV6 r.sources;
|
||||
destFamily =
|
||||
if r.destination == null then
|
||||
null
|
||||
else if isV6 r.destination then
|
||||
"ip6"
|
||||
else
|
||||
"${r.protocol} dport ${
|
||||
if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }"
|
||||
} ";
|
||||
"ip";
|
||||
in
|
||||
"iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}";
|
||||
if r.sources == [ ] then
|
||||
[
|
||||
{
|
||||
src = "";
|
||||
key = destFamily;
|
||||
}
|
||||
]
|
||||
else
|
||||
lib.optional (v4 != [ ]) {
|
||||
src = "ip saddr ${setOf v4} ";
|
||||
key = "ip";
|
||||
}
|
||||
++ lib.optional (v6 != [ ]) {
|
||||
src = "ip6 saddr ${setOf v6} ";
|
||||
key = "ip6";
|
||||
};
|
||||
|
||||
# Everything one rule contributes. Indexed so meters/prefixes are unique.
|
||||
ruleParts =
|
||||
i: r:
|
||||
let
|
||||
id = toString i;
|
||||
label = "${r.from}->${r.to}";
|
||||
fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from;
|
||||
toLocal = r.to == "local";
|
||||
toDevs = zoneDevices r.to;
|
||||
base = "iifname ${set fromDevs} ${lib.optionalString (!toLocal) "oifname ${set toDevs} "}${daddrOf r}${l4 r.protocol r.ports r.sourcePorts}${lib.optionalString r.natOnly "ct status dnat "}";
|
||||
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
|
||||
limitLines =
|
||||
v:
|
||||
lib.optionals (r.rateLimit != null) (
|
||||
map
|
||||
(
|
||||
k:
|
||||
"${base}${v.src}ct state new meter rl${id}-${k} { ${k} saddr limit rate over ${r.rateLimit} } ${lib.optionalString r.log (logPrefix "rate-limited")}drop"
|
||||
)
|
||||
(
|
||||
if v.key == null then
|
||||
[
|
||||
"ip"
|
||||
"ip6"
|
||||
]
|
||||
else
|
||||
[ v.key ]
|
||||
)
|
||||
);
|
||||
acceptLines =
|
||||
v:
|
||||
lib.optional r.log "${base}${v.src}ct state new ${logPrefix "accepted"}"
|
||||
++ [ "${base}${v.src}accept # ${label}" ];
|
||||
in
|
||||
{
|
||||
# A zone nothing is assigned to yet has no devices; the rule waits for one.
|
||||
# `local` is the router itself: those rules are on the input chain.
|
||||
forward = lib.optionals (!toLocal && fromDevs != [ ] && toDevs != [ ]) (
|
||||
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r)
|
||||
);
|
||||
input = lib.optionals (toLocal && fromDevs != [ ]) (
|
||||
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r)
|
||||
);
|
||||
};
|
||||
|
||||
# Address translation (router.firewall.nat). Indexed like the rules.
|
||||
natParts =
|
||||
i: n:
|
||||
let
|
||||
label = "nat ${n.type} ${toString n.from}->${n.to}";
|
||||
fromDevs =
|
||||
if n.from == null then
|
||||
[ ]
|
||||
else if n.from == "wan" then
|
||||
uplinks
|
||||
else
|
||||
zoneDevices n.from;
|
||||
toDevs = if n.to == "wan" then uplinks else zoneDevices n.to;
|
||||
iif = lib.optionalString (n.from != null) "iifname ${set fromDevs} ";
|
||||
saddr = lib.optionalString (n.sources != [ ]) "ip saddr ${setOf n.sources} ";
|
||||
daddr = lib.optionalString (n.destination != null) "ip daddr ${n.destination} ";
|
||||
match = "${iif}${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}";
|
||||
target = "${n.toAddress}${lib.optionalString (n.toPort != null) ":${toString n.toPort}"}";
|
||||
# Zones with nothing assigned yet have no devices; the rule waits for one.
|
||||
live = (n.from == null || fromDevs != [ ]) && toDevs != [ ];
|
||||
in
|
||||
{
|
||||
prerouting = lib.optionals (live && n.type == "dnat") (
|
||||
[
|
||||
# Without a destination, from the uplink anything that arrives is
|
||||
# translated; from inside only what is addressed to the router.
|
||||
"${match}${
|
||||
lib.optionalString (n.destination == null && n.from != "wan") "fib daddr type local "
|
||||
}dnat to ${target} # ${label}"
|
||||
]
|
||||
# NAT loopback: internal clients addressing the router's public address.
|
||||
++ lib.optional n.hairpin "iifname ${set internetIfaces} ${saddr}${daddr}fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 n.protocol n.ports [ ]}dnat to ${target} # hairpin ${label}"
|
||||
);
|
||||
# The hairpin allow: these LANs may reach the host through the router's address.
|
||||
hairpinForward = lib.optional (live && n.type == "dnat" && n.hairpin) "iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${n.toAddress} ${l4 n.protocol (if n.toPort != null then [ n.toPort ] else n.ports) [ ]}ct status dnat accept # hairpin ${label}";
|
||||
postrouting = lib.optional (live && n.type == "masquerade") "${
|
||||
lib.optionalString (n.from != null) "iifname ${set fromDevs} "
|
||||
}oifname ${set toDevs} ${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}masquerade # ${label}"
|
||||
++ lib.optional (
|
||||
live && n.type == "snat"
|
||||
) "${iif}oifname ${set toDevs} ${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}snat to ${n.toAddress} # ${label}";
|
||||
};
|
||||
|
||||
parts = lib.imap0 ruleParts cfg.firewall.rules;
|
||||
natRules = lib.imap0 natParts cfg.firewall.nat;
|
||||
anyHairpin = lib.any (n: n.type == "dnat" && n.hairpin) cfg.firewall.nat;
|
||||
# Addresses the router itself holds on internal interfaces; anything else
|
||||
# that is local is a public (uplink) address.
|
||||
internalAddrs =
|
||||
map (l: l.ipv4.address) lans
|
||||
++ map (a: builtins.head (lib.splitString "/" a)) (
|
||||
lib.filter (a: !isV6 a) (lib.concatMap (w: w.addresses) wgs)
|
||||
);
|
||||
|
||||
uplink4 = cfg.wan.ipv4.interface;
|
||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
|
||||
uplinks = lib.unique [
|
||||
cfg.wan.interface
|
||||
uplink4
|
||||
];
|
||||
# When the physical link also carries LANs, only the PPP device is an uplink.
|
||||
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
|
||||
uplinks = lib.unique ([ uplink4 ] ++ lib.optional (!sharedLink) cfg.wan.interface);
|
||||
mss = cfg.wan.ipv4.pppoe.mtu - 40;
|
||||
mss6 = cfg.wan.ipv4.pppoe.mtu - 60;
|
||||
v6ppp = cfg.wan.ipv6.enable && cfg.wan.ipv6.transport == "ppp";
|
||||
# Where DHCPv6 replies arrive.
|
||||
v6dev = if v6ppp then uplink4 else cfg.wan.interface;
|
||||
dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
|
||||
in
|
||||
{
|
||||
@@ -69,21 +248,32 @@ in
|
||||
destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
|
||||
nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert
|
||||
} accept
|
||||
iifname ${set lanIfaces} icmp type echo-request accept
|
||||
iifname ${set lanIfaces} icmpv6 type echo-request accept
|
||||
iifname ${set memberIfaces} icmp type echo-request accept
|
||||
iifname ${set memberIfaces} icmpv6 type echo-request accept
|
||||
|
||||
${lib.optionalString (!pppoe) ''iifname "${cfg.wan.interface}" udp dport 68 accept''}
|
||||
${lib.optionalString cfg.wan.ipv6.enable ''iifname "${cfg.wan.interface}" udp dport 546 accept''}
|
||||
${lib.optionalString cfg.wan.ipv6.enable ''iifname "${v6dev}" udp dport 546 accept''}
|
||||
|
||||
iifname ${set lanIfaces} udp dport 53 accept
|
||||
iifname ${set lanIfaces} tcp dport 53 accept
|
||||
iifname ${set dnsIfaces} udp dport 53 accept
|
||||
iifname ${set dnsIfaces} tcp dport 53 accept
|
||||
${lib.optionalString (listenPorts != [ ])
|
||||
"iifname ${set uplinks} udp dport { ${
|
||||
lib.concatMapStringsSep ", " toString listenPorts
|
||||
} } accept # wireguard"
|
||||
}
|
||||
${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"}
|
||||
${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport 22 accept"}
|
||||
${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport { ${
|
||||
lib.concatMapStringsSep ", " toString ([ 22 ] ++ config.router.managementPorts)
|
||||
} } accept"}
|
||||
|
||||
${lib.concatStringsSep "\n " (lib.concatMap (p: p.input) parts)}
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
|
||||
${lib.concatStringsSep "\n " killRules}
|
||||
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
@@ -91,15 +281,21 @@ in
|
||||
|
||||
${lib.optionalString pppoe ''
|
||||
# Clamp MSS to the PPP MTU in both directions (never raised).
|
||||
iifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
|
||||
oifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
|
||||
iifname "${uplink4}" meta nfproto ipv4 tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
|
||||
oifname "${uplink4}" meta nfproto ipv4 tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
|
||||
${lib.optionalString v6ppp ''
|
||||
iifname "${uplink4}" meta nfproto ipv6 tcp flags syn tcp option maxseg size > ${toString mss6} tcp option maxseg size set ${toString mss6}
|
||||
oifname "${uplink4}" meta nfproto ipv6 tcp flags syn tcp option maxseg size > ${toString mss6} tcp option maxseg size set ${toString mss6}
|
||||
''}
|
||||
''}
|
||||
${lib.optionalString (
|
||||
internetIfaces != [ ]
|
||||
) "iifname ${set internetIfaces} oifname ${set uplinks} accept"}
|
||||
|
||||
# Zones: free inside a zone, denied between zones unless a rule says so.
|
||||
${lib.concatStringsSep "\n " (intraZone ++ map ruleToNft cfg.firewall.rules)}
|
||||
${lib.concatStringsSep "\n " (
|
||||
intraZone ++ lib.concatMap (p: p.hairpinForward) natRules ++ lib.concatMap (p: p.forward) parts
|
||||
)}
|
||||
}
|
||||
'';
|
||||
};
|
||||
@@ -107,9 +303,19 @@ in
|
||||
networking.nftables.tables.router-nat = {
|
||||
family = "ip";
|
||||
content = ''
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) natRules)}
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
${lib.concatStringsSep "\n " (lib.concatMap (p: p.postrouting) natRules)}
|
||||
oifname "${uplink4}" masquerade
|
||||
${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"}
|
||||
${lib.optionalString (
|
||||
masqueradeIfaces != [ ]
|
||||
) "oifname ${set masqueradeIfaces} masquerade # tunnels"}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -62,4 +62,20 @@ rec {
|
||||
in
|
||||
networkOf a.address p == networkOf b.address p;
|
||||
};
|
||||
|
||||
ipv6 = {
|
||||
# The first 64 bits of an address (any form, with or without a prefix
|
||||
# length) as a /64 prefix, e.g. fd2c:cdef:cde9::1/64 -> fd2c:cdef:cde9:0::/64.
|
||||
prefix64 =
|
||||
address:
|
||||
let
|
||||
bare = builtins.head (lib.splitString "/" address);
|
||||
halves = lib.splitString "::" bare;
|
||||
groups = s: lib.filter (g: g != "") (lib.splitString ":" s);
|
||||
head = groups (builtins.elemAt halves 0);
|
||||
tail = lib.optionals (lib.length halves > 1) (groups (builtins.elemAt halves 1));
|
||||
full = head ++ lib.replicate (8 - lib.length head - lib.length tail) "0" ++ tail;
|
||||
in
|
||||
"${lib.concatStringsSep ":" (lib.take 4 full)}::/64";
|
||||
};
|
||||
}
|
||||
|
||||
+170
-75
@@ -2,26 +2,71 @@
|
||||
let
|
||||
cfg = config.router;
|
||||
v6 = cfg.wan.ipv6.enable;
|
||||
v6ppp = v6 && cfg.wan.ipv6.transport == "ppp";
|
||||
# IPv6 on the physical link (not inside the PPP session).
|
||||
v6native = v6 && !v6ppp;
|
||||
# The device carrying DHCPv6 / RAs.
|
||||
v6dev = if v6ppp then cfg.wan.ipv4.interface else cfg.wan.interface;
|
||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||
vlan = cfg.wan.ipv4.pppoe.vlan;
|
||||
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
|
||||
vlanDevice =
|
||||
if cfg.wan.ipv4.pppoe.vlanName != null then
|
||||
cfg.wan.ipv4.pppoe.vlanName
|
||||
else
|
||||
"${cfg.wan.interface}.${toString vlan}";
|
||||
wanVlanOn = parent: lib.optional (pppoe && vlan != null && parent == cfg.wan.interface) vlanDevice;
|
||||
|
||||
lans = lib.attrValues cfg.lans;
|
||||
vlanLans = lib.filter (l: l.vlan != null) lans;
|
||||
# VLAN devices hang off their parent; the parent's own unit must list them.
|
||||
vlansOn = parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans);
|
||||
vlansOn =
|
||||
parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans) ++ wanVlanOn parent;
|
||||
# The WAN link's own unit is only needed when no LAN shares the link.
|
||||
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
|
||||
untaggedParents = map (l: l.interface) (lib.filter (l: l.vlan == null) lans);
|
||||
# Parents that carry only tagged LANs still need a unit to be brought up.
|
||||
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
|
||||
lib.unique (map (l: l.interface) vlanLans)
|
||||
);
|
||||
|
||||
dhcp = if pppoe then (if v6 then "ipv6" else "no") else (if v6 then "yes" else "ipv4");
|
||||
dhcp = if pppoe then (if v6native then "ipv6" else "no") else (if v6 then "yes" else "ipv4");
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
# Wait for the LAN devices (VLANs included: Kea only binds interfaces that
|
||||
# exist when it starts) but not for the uplink: an ISP outage or a pulled
|
||||
# cable must not hold up boot or a deployment.
|
||||
systemd.network.wait-online.extraArgs = map (l: "--interface=${l.device}") (
|
||||
lib.attrValues cfg.lans
|
||||
);
|
||||
|
||||
# Rename network cards by MAC address. Only at boot: the interface that is
|
||||
# up cannot be renamed, and router-apply stages such a change for the
|
||||
# next boot instead of activating it (see the names file below).
|
||||
systemd.network.links = lib.mapAttrs' (
|
||||
name: i:
|
||||
lib.nameValuePair "05-${name}" {
|
||||
matchConfig = {
|
||||
PermanentMACAddress = i.mac;
|
||||
Type = "ether";
|
||||
};
|
||||
linkConfig.Name = name;
|
||||
}
|
||||
) cfg.interfaces;
|
||||
|
||||
# The names this system gives to devices; router-apply compares the file
|
||||
# between the running and the new system and stages a change of a name for
|
||||
# the next boot (a device that is up cannot be renamed).
|
||||
environment.etc."router/interface-names".text =
|
||||
lib.concatMapStrings (l: l + "\n") (
|
||||
lib.sort lib.lessThan (
|
||||
lib.mapAttrsToList (name: i: "nic ${i.mac} ${name}") cfg.interfaces
|
||||
++ map (l: "vlan ${l.interface} ${toString l.vlan} ${l.device}") vlanLans
|
||||
++ lib.optional (pppoe && vlan != null) "vlan ${cfg.wan.interface} ${toString vlan} ${vlanDevice}"
|
||||
)
|
||||
);
|
||||
|
||||
boot.kernel.sysctl = {
|
||||
"net.ipv4.conf.all.forwarding" = 1;
|
||||
@@ -49,87 +94,137 @@ in
|
||||
}
|
||||
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
|
||||
|
||||
systemd.network.networks = {
|
||||
"10-wan" = {
|
||||
matchConfig.Name = cfg.wan.interface;
|
||||
networkConfig = {
|
||||
DHCP = dhcp;
|
||||
}
|
||||
// lib.optionalAttrs v6 {
|
||||
IPv6AcceptRA = true;
|
||||
KeepConfiguration = "yes";
|
||||
}
|
||||
// lib.optionalAttrs (pppoe && vlan != null) {
|
||||
VLAN = [ vlanDevice ];
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (!pppoe) {
|
||||
# The router runs its own resolver; never adopt the ISP's DNS for the host.
|
||||
dhcpV4Config.UseDNS = false;
|
||||
}
|
||||
// lib.optionalAttrs v6 {
|
||||
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
|
||||
dhcpV6Config = {
|
||||
WithoutRA = "solicit";
|
||||
UseDelegatedPrefix = true;
|
||||
UseDNS = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (pppoe && vlan != null) {
|
||||
# pppd configures the session; networkd only has to bring the device up.
|
||||
"11-wan-pppoe" = {
|
||||
matchConfig.Name = vlanDevice;
|
||||
networkConfig = {
|
||||
LinkLocalAddressing = "no";
|
||||
IPv6AcceptRA = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
// lib.mapAttrs' (
|
||||
name: lan:
|
||||
lib.nameValuePair "20-lan-${name}" (
|
||||
{
|
||||
matchConfig.Name = lan.device;
|
||||
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
|
||||
systemd.network.networks =
|
||||
lib.optionalAttrs (!sharedLink) {
|
||||
"10-wan" = {
|
||||
matchConfig.Name = cfg.wan.interface;
|
||||
networkConfig = {
|
||||
ConfigureWithoutCarrier = true;
|
||||
# Never accept RAs from the inside.
|
||||
DHCP = dhcp;
|
||||
}
|
||||
// lib.optionalAttrs v6native {
|
||||
IPv6AcceptRA = true;
|
||||
KeepConfiguration = "yes";
|
||||
}
|
||||
// lib.optionalAttrs (pppoe && !v6native) {
|
||||
# IPv6 (if any) lives inside the PPP session, never on the link.
|
||||
LinkLocalAddressing = "no";
|
||||
IPv6AcceptRA = false;
|
||||
}
|
||||
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
|
||||
VLAN = vlansOn lan.interface;
|
||||
}
|
||||
// lib.optionalAttrs lan.ipv6.pd {
|
||||
DHCPPrefixDelegation = true;
|
||||
IPv6SendRA = true;
|
||||
// lib.optionalAttrs (pppoe && vlan != null) {
|
||||
VLAN = [ vlanDevice ];
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs lan.ipv6.pd {
|
||||
dhcpPrefixDelegationConfig = {
|
||||
UplinkInterface = cfg.wan.interface;
|
||||
SubnetId = 0;
|
||||
Announce = true;
|
||||
Assign = true;
|
||||
};
|
||||
# No RDNSS yet: the global address is unknown at evaluation time.
|
||||
ipv6SendRAConfig.EmitDNS = false;
|
||||
// lib.optionalAttrs (!pppoe) {
|
||||
# The router runs its own resolver; never adopt the ISP's DNS for the host.
|
||||
dhcpV4Config.UseDNS = false;
|
||||
}
|
||||
)
|
||||
) cfg.lans
|
||||
// lib.listToAttrs (
|
||||
map (
|
||||
parent:
|
||||
lib.nameValuePair "19-trunk-${parent}" {
|
||||
matchConfig.Name = parent;
|
||||
// lib.optionalAttrs v6native {
|
||||
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
|
||||
dhcpV6Config = {
|
||||
WithoutRA = "solicit";
|
||||
UseDelegatedPrefix = true;
|
||||
UseDNS = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs v6ppp {
|
||||
# IPv6 inside the PPP session: pppd (IPv4, IPv6CP link-local) creates the
|
||||
# device; networkd takes RAs and DHCPv6 (prefix delegation) on it.
|
||||
"12-wan-ppp" = {
|
||||
matchConfig.Name = cfg.wan.ipv4.interface;
|
||||
networkConfig = {
|
||||
DHCP = "ipv6";
|
||||
IPv6AcceptRA = true;
|
||||
KeepConfiguration = "yes";
|
||||
};
|
||||
dhcpV6Config = {
|
||||
WithoutRA = "solicit";
|
||||
UseDelegatedPrefix = true;
|
||||
UseDNS = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (pppoe && vlan != null) {
|
||||
# pppd configures the session; networkd only has to bring the device up.
|
||||
"11-wan-pppoe" = {
|
||||
matchConfig.Name = vlanDevice;
|
||||
networkConfig = {
|
||||
LinkLocalAddressing = "no";
|
||||
IPv6AcceptRA = false;
|
||||
ConfigureWithoutCarrier = true;
|
||||
VLAN = vlansOn parent;
|
||||
};
|
||||
}
|
||||
) trunkParents
|
||||
);
|
||||
};
|
||||
}
|
||||
// lib.mapAttrs' (
|
||||
name: lan:
|
||||
lib.nameValuePair "20-lan-${name}" (
|
||||
{
|
||||
matchConfig.Name = lan.device;
|
||||
address = [
|
||||
"${lan.ipv4.address}/${toString lan.ipv4.prefixLength}"
|
||||
]
|
||||
++ lib.optional (lan.ipv6.address != null) lan.ipv6.address;
|
||||
networkConfig = {
|
||||
ConfigureWithoutCarrier = true;
|
||||
# Never accept RAs from the inside.
|
||||
IPv6AcceptRA = false;
|
||||
}
|
||||
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
|
||||
VLAN = vlansOn lan.interface;
|
||||
}
|
||||
// lib.optionalAttrs lan.ipv6.pd {
|
||||
DHCPPrefixDelegation = true;
|
||||
}
|
||||
// lib.optionalAttrs (lan.ipv6.pd || lan.ipv6.address != null) {
|
||||
IPv6SendRA = true;
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (lan.ipv6.address != null) {
|
||||
# With prefix delegation networkd announces only the delegated
|
||||
# prefix, so announce the ULA explicitly (SLAAC, on-link).
|
||||
ipv6Prefixes = [
|
||||
{
|
||||
Prefix = (import ./lib.nix { inherit lib; }).ipv6.prefix64 lan.ipv6.address;
|
||||
OnLink = true;
|
||||
AddressAutoconfiguration = true;
|
||||
}
|
||||
];
|
||||
}
|
||||
// lib.optionalAttrs lan.ipv6.pd {
|
||||
dhcpPrefixDelegationConfig = {
|
||||
UplinkInterface = v6dev;
|
||||
SubnetId = 0;
|
||||
Announce = true;
|
||||
Assign = true;
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (lan.ipv6.pd || lan.ipv6.address != null) {
|
||||
# RDNSS points hosts at the router's resolver on its stable ULA; the
|
||||
# delegated global address is unknown at evaluation time, so without
|
||||
# a static `ipv6.address` there is nothing to announce.
|
||||
ipv6SendRAConfig =
|
||||
if lan.ipv6.address != null then
|
||||
{
|
||||
EmitDNS = true;
|
||||
DNS = [ (builtins.head (lib.splitString "/" lan.ipv6.address)) ];
|
||||
}
|
||||
else
|
||||
{ EmitDNS = false; };
|
||||
}
|
||||
)
|
||||
) cfg.lans
|
||||
// lib.listToAttrs (
|
||||
map (
|
||||
parent:
|
||||
lib.nameValuePair "19-trunk-${parent}" {
|
||||
matchConfig.Name = parent;
|
||||
networkConfig = {
|
||||
LinkLocalAddressing = "no";
|
||||
IPv6AcceptRA = false;
|
||||
ConfigureWithoutCarrier = true;
|
||||
VLAN = vlansOn parent;
|
||||
};
|
||||
}
|
||||
) trunkParents
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+664
-15
@@ -7,6 +7,29 @@ in
|
||||
options.router = {
|
||||
enable = mkEnableOption "the router role";
|
||||
|
||||
interfaces = mkOption {
|
||||
default = { };
|
||||
example = {
|
||||
wan.mac = "52:54:00:12:34:56";
|
||||
};
|
||||
description = ''
|
||||
Names for physical interfaces, keyed by the name to give: the network
|
||||
card with the given MAC address is called that from the next boot on
|
||||
(`wan.interface` and `lans.*.interface` use these names). Interfaces
|
||||
that are not listed keep the kernel's name. Names cannot change while
|
||||
the system runs, so a change only takes effect after a reboot.
|
||||
'';
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options.mac = mkOption {
|
||||
type = types.strMatching "([0-9a-f]{2}:){5}[0-9a-f]{2}";
|
||||
example = "52:54:00:12:34:56";
|
||||
description = "Permanent MAC address of the network card, in lower case.";
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
wan = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
@@ -14,6 +37,10 @@ in
|
||||
description = ''
|
||||
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
|
||||
with PPPoE it also carries the (optionally tagged) PPPoE session.
|
||||
|
||||
The link may also carry LANs ("router on a stick") when the uplink
|
||||
is PPPoE on a VLAN and IPv6 is disabled: the untagged link is then
|
||||
free for a LAN and only the PPP device counts as an uplink.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -33,6 +60,12 @@ in
|
||||
default = null;
|
||||
description = "VLAN ID the ISP requires for PPPoE, or null for untagged.";
|
||||
};
|
||||
vlanName = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "isp";
|
||||
description = "Name of the VLAN device of the PPPoE session; null: `<interface>.<vlan>`.";
|
||||
};
|
||||
username = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
@@ -67,10 +100,27 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
ipv6.enable = mkEnableOption ''
|
||||
IPv6 on the uplink: DHCPv6 (soliciting even without RA flags) plus
|
||||
prefix delegation. The ISP is expected to delegate a single /64
|
||||
'';
|
||||
ipv6 = {
|
||||
enable = mkEnableOption ''
|
||||
IPv6 on the uplink: DHCPv6 (soliciting even without RA flags) plus
|
||||
prefix delegation. The ISP is expected to delegate a single /64
|
||||
'';
|
||||
|
||||
transport = mkOption {
|
||||
type = types.enum [
|
||||
"native"
|
||||
"ppp"
|
||||
];
|
||||
default = "native";
|
||||
description = ''
|
||||
Where IPv6 runs. `native`: untagged on the uplink link, next to a
|
||||
DHCPv4 or PPPoE (tagged) IPv4 uplink. `ppp`: inside the PPPoE
|
||||
session (IPv6CP), so DHCPv6 and router advertisements arrive on
|
||||
the PPP device; needs a PPPoE uplink, and leaves the physical link
|
||||
free for a LAN.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
lans = mkOption {
|
||||
@@ -96,6 +146,16 @@ in
|
||||
description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`.";
|
||||
};
|
||||
|
||||
deviceName = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "dmz";
|
||||
description = ''
|
||||
Name of the VLAN device (only with `vlan`); null: `<interface>.<vlan>`.
|
||||
Like `interfaces`, a changed name is safe only after a reboot.
|
||||
'';
|
||||
};
|
||||
|
||||
device = mkOption {
|
||||
type = types.str;
|
||||
readOnly = true;
|
||||
@@ -152,6 +212,29 @@ in
|
||||
example = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
reservations = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
mac = mkOption {
|
||||
type = types.str;
|
||||
example = "a8:a1:59:0b:4b:97";
|
||||
description = "Hardware address of the client.";
|
||||
};
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.20";
|
||||
description = "Address handed to the client; must lie inside the LAN.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
description = ''
|
||||
Fixed leases, keyed by hostname. The name is also sent to the
|
||||
client and published in DNS (under `router.dns.domain` if set).
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
ipv6.pd = mkOption {
|
||||
@@ -165,6 +248,19 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
ipv6.address = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "fd2c:cdef:cde9::1/64";
|
||||
description = ''
|
||||
Static unique-local (ULA) address of the router on this LAN,
|
||||
as a /64. It is announced via SLAAC, so hosts get a stable
|
||||
internal IPv6 address even without a delegated prefix, and
|
||||
the router's resolver answers on it (also advertised as
|
||||
RDNSS). Independent of `ipv6.pd`.
|
||||
'';
|
||||
};
|
||||
|
||||
allowManagement = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
@@ -173,7 +269,12 @@ in
|
||||
};
|
||||
|
||||
config.device =
|
||||
if config.vlan == null then config.interface else "${config.interface}.${toString config.vlan}";
|
||||
if config.vlan == null then
|
||||
config.interface
|
||||
else if config.deviceName != null then
|
||||
config.deviceName
|
||||
else
|
||||
"${config.interface}.${toString config.vlan}";
|
||||
|
||||
# "invalid" is only ever seen when assertions.nix is already failing.
|
||||
config.ipv4.subnet =
|
||||
@@ -186,23 +287,216 @@ in
|
||||
);
|
||||
};
|
||||
|
||||
firewall.rules = mkOption {
|
||||
wireguard = mkOption {
|
||||
default = { };
|
||||
description = ''
|
||||
WireGuard tunnels, keyed by name. A tunnel is a firewall member like a
|
||||
LAN (it has a `zone`, `internet`, `allowManagement`), so
|
||||
`router.firewall.rules` also cover traffic to and from it. Peers'
|
||||
`allowedIPs` are the destinations routed into the tunnel; routing is
|
||||
by destination only (no default-route tunnels).
|
||||
'';
|
||||
type = types.attrsOf (
|
||||
types.submodule (
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
default = "wg-${name}";
|
||||
description = "Name of the WireGuard device.";
|
||||
};
|
||||
privateKeyFile = mkOption {
|
||||
type = types.pathWith {
|
||||
absolute = true;
|
||||
inStore = false;
|
||||
};
|
||||
example = "/var/lib/secrets/wg-rw.key";
|
||||
description = ''
|
||||
File with the private key, outside the Nix store. It is
|
||||
handed to systemd-networkd as a systemd credential, so it
|
||||
can be root-only (mode 0600) and is read when networkd starts.
|
||||
'';
|
||||
};
|
||||
listenPort = mkOption {
|
||||
type = types.nullOr types.port;
|
||||
default = null;
|
||||
description = "UDP port to listen on (opened on the uplinks); null for a dial-out-only tunnel.";
|
||||
};
|
||||
mtu = mkOption {
|
||||
type = types.nullOr (types.ints.between 576 9000);
|
||||
default = null;
|
||||
description = "Tunnel MTU; null keeps the kernel default (1420).";
|
||||
};
|
||||
addresses = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [
|
||||
"10.100.0.1/24"
|
||||
"fd42:100::1/64"
|
||||
];
|
||||
description = ''
|
||||
Addresses (CIDR, IPv4 and/or IPv6) on the tunnel interface;
|
||||
empty means no transfer network (fine for site-to-site).
|
||||
'';
|
||||
};
|
||||
sourceAddresses = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "192.168.10.1" ];
|
||||
description = ''
|
||||
Source address (at most one per family) for traffic the router
|
||||
itself sends into this tunnel, e.g. DNS queries to the other
|
||||
site. Needed when the tunnel has no address of its own, so the
|
||||
other side sees a source it routes back instead of whatever the
|
||||
kernel picks. IPv4 ones must be an address the router holds on
|
||||
a LAN or a tunnel.
|
||||
'';
|
||||
};
|
||||
zone = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
description = "Firewall zone; shared with LANs or other tunnels if you want a common policy.";
|
||||
};
|
||||
internet = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Whether traffic from this tunnel may reach the internet via the uplink.";
|
||||
};
|
||||
allowManagement = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Whether SSH on the router is reachable through this tunnel.";
|
||||
};
|
||||
dns = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Whether the router's resolver answers on the tunnel's IPv4 address.";
|
||||
};
|
||||
masquerade = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Source-NAT IPv4 traffic leaving through this tunnel to the tunnel address.";
|
||||
};
|
||||
killSwitch = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Drop forwarded traffic for this tunnel's peers' `allowedIPs`
|
||||
when it would leave through an uplink (for example because
|
||||
the tunnel device is down), instead of letting it leak.
|
||||
'';
|
||||
};
|
||||
peers = mkOption {
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
publicKey = mkOption { type = types.str; };
|
||||
presharedKeyFile = mkOption {
|
||||
type = types.nullOr (
|
||||
types.pathWith {
|
||||
absolute = true;
|
||||
inStore = false;
|
||||
}
|
||||
);
|
||||
default = null;
|
||||
};
|
||||
endpoint = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "vpn.example.org:51820";
|
||||
};
|
||||
allowedIPs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "192.168.50.0/24" ];
|
||||
};
|
||||
persistentKeepalive = mkOption {
|
||||
type = types.nullOr (types.ints.between 1 65535);
|
||||
default = null;
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
wireguardEndpointRefresh = mkOption {
|
||||
type = types.nullOr (types.strMatching "[0-9]+(s|min|h)");
|
||||
default = "1min";
|
||||
example = "5min";
|
||||
description = ''
|
||||
How often the names in WireGuard peers' `endpoint` are looked up
|
||||
again, for peers that have not completed a handshake in the last two
|
||||
minutes. systemd-networkd resolves a name only when it creates the
|
||||
tunnel, so without this a tunnel whose peer got a new address (dynamic
|
||||
DNS) stays down until the peer contacts the router, or forever if both
|
||||
ends changed. A tunnel that works is not touched. null turns it off.
|
||||
'';
|
||||
};
|
||||
|
||||
update.channel = mkOption {
|
||||
type = types.nullOr (types.strMatching "unstable|[0-9]{2}\\.[0-9]{2}");
|
||||
default = null;
|
||||
example = "26.05";
|
||||
description = ''
|
||||
The nixpkgs branch whose packages and kernel `router-upgrade` offers:
|
||||
`unstable`, or a release like `26.05` (the branch `nixos-26.05`). It
|
||||
takes effect when you upgrade, not when you apply the settings. null:
|
||||
the packages that came with the installed release of nixos-router.
|
||||
'';
|
||||
};
|
||||
|
||||
firewall.zones = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "guests" ];
|
||||
description = ''
|
||||
Zones to define besides those of the LANs and tunnels (`zone` there
|
||||
assigns them), so rules can name them before anything is assigned. A
|
||||
zone without members matches nothing.
|
||||
'';
|
||||
};
|
||||
|
||||
firewall.nat = mkOption {
|
||||
default = [ ];
|
||||
description = ''
|
||||
Allow rules between zones, on top of the default (deny between zones).
|
||||
Rules are one-directional; return traffic is allowed by connection
|
||||
tracking. Both ends must be zones of LANs defined in `router.lans`.
|
||||
Address translation, separate from the allow rules. `dnat` sends
|
||||
traffic that arrives in zone `from` (`wan` for the uplink) and matches
|
||||
the rule to `toAddress` (a host in zone `to`); `snat` and `masquerade`
|
||||
rewrite the source of traffic leaving through zone `to` (`wan` for the
|
||||
uplink) to `toAddress`, or to the address of the outgoing interface.
|
||||
LANs with `internet = true` and tunnels with `masquerade` are already
|
||||
masqueraded; these rules come first. A translation does not allow
|
||||
anything: a forward needs a matching entry in `firewall.rules`.
|
||||
'';
|
||||
type = types.listOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
type = mkOption {
|
||||
type = types.enum [
|
||||
"dnat"
|
||||
"snat"
|
||||
"masquerade"
|
||||
];
|
||||
description = "Kind of translation.";
|
||||
};
|
||||
from = mkOption {
|
||||
type = types.str;
|
||||
description = "Source zone.";
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Zone the traffic comes from, or `wan`. For `snat` and `masquerade` null means any.";
|
||||
};
|
||||
to = mkOption {
|
||||
type = types.str;
|
||||
description = "Destination zone.";
|
||||
description = ''
|
||||
`dnat`: the zone of the host the traffic is sent to. `snat` and
|
||||
`masquerade`: the zone (or `wan`) the traffic leaves through.
|
||||
'';
|
||||
};
|
||||
protocol = mkOption {
|
||||
type = types.enum [
|
||||
@@ -214,9 +508,107 @@ in
|
||||
default = "any";
|
||||
};
|
||||
ports = mkOption {
|
||||
type = types.listOf types.port;
|
||||
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
||||
default = [ ];
|
||||
description = "Destination ports (tcp/udp only); empty means all ports.";
|
||||
description = "Destination ports or ranges to match (tcp/udp only); empty means all.";
|
||||
};
|
||||
sourcePorts = mkOption {
|
||||
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
||||
default = [ ];
|
||||
description = "Source ports or ranges to match (tcp/udp only).";
|
||||
};
|
||||
sources = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Only match traffic from these IPv4 addresses or CIDRs; empty means any.";
|
||||
};
|
||||
destination = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "203.0.113.7";
|
||||
description = ''
|
||||
Only match traffic addressed to this IPv4 address or CIDR.
|
||||
For `dnat` null means the router's own addresses (from `wan`:
|
||||
anything that arrives on the uplink).
|
||||
'';
|
||||
};
|
||||
toAddress = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "192.168.10.5";
|
||||
description = "`dnat`: the host to send the traffic to. `snat`: the source address to use. Not for `masquerade`.";
|
||||
};
|
||||
toPort = mkOption {
|
||||
type = types.nullOr types.port;
|
||||
default = null;
|
||||
description = "`dnat`: the port on the host when it differs (needs exactly one `ports` entry).";
|
||||
};
|
||||
hairpin = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
`dnat` from `wan` only: let LANs with `internet = true` reach
|
||||
the forward through the router's public address (NAT
|
||||
loopback, the source is masqueraded) and allow it to the host.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
firewall.rules = mkOption {
|
||||
default = [ ];
|
||||
description = ''
|
||||
Allow rules between zones, on top of the default (deny between zones).
|
||||
Rules are one-directional; return traffic is allowed by connection
|
||||
tracking. `to` must be the zone of a LAN or tunnel, or the reserved
|
||||
zone `local`, the router itself (SSH, the web UI, ...); `from` may also
|
||||
be the reserved zone `wan` (traffic arriving on the uplinks), which
|
||||
publishes services to the outside: such rules must name a
|
||||
`destination` (not for `local`) and protocol (and ports for tcp/udp),
|
||||
so a typo cannot expose a whole zone. A port forward on the same port
|
||||
takes the traffic first, so it never reaches `local`. Address translation is separate (`firewall.nat`);
|
||||
a port forward needs an allow rule as well, usually with `natOnly`.
|
||||
'';
|
||||
type = types.listOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
from = mkOption {
|
||||
type = types.str;
|
||||
description = "Source zone, or `wan`.";
|
||||
};
|
||||
to = mkOption {
|
||||
type = types.str;
|
||||
description = "Destination zone, or `local` (the router itself).";
|
||||
};
|
||||
protocol = mkOption {
|
||||
type = types.enum [
|
||||
"any"
|
||||
"tcp"
|
||||
"udp"
|
||||
"icmp"
|
||||
];
|
||||
default = "any";
|
||||
};
|
||||
ports = mkOption {
|
||||
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
||||
default = [ ];
|
||||
example = [
|
||||
443
|
||||
"50000-50100"
|
||||
];
|
||||
description = ''
|
||||
Destination ports or ranges (tcp/udp only); empty means all
|
||||
ports. After a port forward these are the translated ports
|
||||
(its `toPort`, if it has one).
|
||||
'';
|
||||
};
|
||||
sourcePorts = mkOption {
|
||||
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
||||
default = [ ];
|
||||
example = [ 53 ];
|
||||
description = "Source ports or ranges (tcp/udp only); empty means any.";
|
||||
};
|
||||
destination = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
@@ -224,7 +616,91 @@ in
|
||||
example = "192.168.10.5";
|
||||
description = ''
|
||||
Restrict the rule to an IPv4 address or CIDR inside the
|
||||
destination zone; null means the whole zone.
|
||||
destination zone; null means the whole zone. IPv6 addresses
|
||||
are accepted too (for pinholes) but cannot be checked against
|
||||
the zone, because delegated prefixes are not known at build time.
|
||||
'';
|
||||
};
|
||||
sources = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "198.51.100.0/24" ];
|
||||
description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source.";
|
||||
};
|
||||
natOnly = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Only match connections that a `firewall.nat` port forward
|
||||
translated (the allow rule that goes with a forward), not
|
||||
traffic addressed directly to the destination.
|
||||
'';
|
||||
};
|
||||
rateLimit = mkOption {
|
||||
type = types.nullOr (types.strMatching "[0-9]+/(second|minute|hour)");
|
||||
default = null;
|
||||
example = "10/minute";
|
||||
description = "Limit new connections per source address; the excess is dropped.";
|
||||
};
|
||||
log = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Log new connections accepted by this rule, and connections dropped by `rateLimit` (kernel log).";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
dnsProviders = mkOption {
|
||||
default = { };
|
||||
description = ''
|
||||
Accounts at DNS providers, by name. Dynamic DNS (`ddns.provider`) and
|
||||
certificates (`acme.provider`) use them, so a token is kept once.
|
||||
'';
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
type = mkOption {
|
||||
type = types.str;
|
||||
example = "cloudflare";
|
||||
description = ''
|
||||
The provider. For dynamic DNS the ddclient protocol
|
||||
(<https://ddclient.net/protocols.html>); for certificates the
|
||||
provider code of lego (<https://go-acme.github.io/lego/dns>).
|
||||
The names are the same where both know the provider.
|
||||
The lists are in `pkgs/router-webui/providers.json`.
|
||||
'';
|
||||
};
|
||||
username = mkOption {
|
||||
type = types.str;
|
||||
default = "token";
|
||||
description = "Login for dynamic DNS; `token` for an API token (Cloudflare).";
|
||||
};
|
||||
server = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "dynupdate.no-ip.com";
|
||||
description = "The provider's update server, for protocols that need one (`dyndns2`).";
|
||||
};
|
||||
secretFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "/var/lib/router/dns-secrets/cloudflare";
|
||||
description = ''
|
||||
File with the password or API token (dynamic DNS; for
|
||||
certificates through cloudflare, desec, digitalocean, duckdns
|
||||
and hetzner the same token). Never a path in the Nix store.
|
||||
'';
|
||||
};
|
||||
environmentFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "/var/lib/router/dns-secrets/route53.env";
|
||||
description = ''
|
||||
File with `VARIABLE=value` lines for lego, for certificates
|
||||
through providers other than those above; the variables are
|
||||
listed at <https://go-acme.github.io/lego/dns>. Never a path in the Nix store.
|
||||
'';
|
||||
};
|
||||
};
|
||||
@@ -232,6 +708,179 @@ in
|
||||
);
|
||||
};
|
||||
|
||||
acme = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
A certificate from Let's Encrypt for the web UI, proven by a DNS
|
||||
record (DNS-01, no port open to the internet), renewed automatically.
|
||||
'';
|
||||
};
|
||||
provider = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "cloudflare";
|
||||
description = "The name of a connection in `dnsProviders` that can change the records of the domains.";
|
||||
};
|
||||
domains = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "router.example.org" ];
|
||||
description = "Names in the certificate; the first is its name. A wildcard like `*.home.example.org` is possible.";
|
||||
};
|
||||
email = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Where Let's Encrypt writes about expiring certificates.";
|
||||
};
|
||||
dnsResolver = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = "1.1.1.1:53";
|
||||
example = "9.9.9.9:53";
|
||||
description = ''
|
||||
Resolver (`address:port`) that lego asks to find the zone's name
|
||||
servers and to check that the challenge record has spread. Without
|
||||
one it would use the router's own Unbound, which answers the local
|
||||
domain itself and caches. `null`: the router's resolver.
|
||||
'';
|
||||
};
|
||||
staging = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Use the staging service: certificates that browsers do not trust, for trying it out.";
|
||||
};
|
||||
acceptTerms = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Accept the Let's Encrypt subscriber agreement.";
|
||||
};
|
||||
};
|
||||
|
||||
ddns = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Keep DNS records at a provider pointing at the router's uplink
|
||||
addresses (ddclient). It checks every `interval` and as soon as an
|
||||
uplink address changes.
|
||||
'';
|
||||
};
|
||||
provider = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "cloudflare";
|
||||
description = "The name of a connection in `dnsProviders` to update the records through.";
|
||||
};
|
||||
zone = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "example.org";
|
||||
description = "The zone the names are in, for providers that need it (Cloudflare).";
|
||||
};
|
||||
domains = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "home.example.org" ];
|
||||
description = "Names to keep up to date.";
|
||||
};
|
||||
ipv4 = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Update A records with the IPv4 address of the uplink.";
|
||||
};
|
||||
ipv6 = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Update AAAA records with the IPv6 address of the uplink.";
|
||||
};
|
||||
ssl = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Talk to the provider over HTTPS.";
|
||||
};
|
||||
interval = mkOption {
|
||||
type = types.str;
|
||||
default = "5min";
|
||||
description = "How often to check, besides when an address changes (systemd time span).";
|
||||
};
|
||||
};
|
||||
|
||||
managementPorts = mkOption {
|
||||
type = types.listOf types.port;
|
||||
default = [ ];
|
||||
example = [ 443 ];
|
||||
description = ''
|
||||
TCP ports on the router, besides SSH, reachable from LANs and tunnels
|
||||
with `allowManagement` (the web UI adds its own).
|
||||
'';
|
||||
};
|
||||
|
||||
dns.domain = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "home.example.org";
|
||||
description = ''
|
||||
Local domain. It is authoritative on the router: names below it that
|
||||
have no record answer NXDOMAIN and are never forwarded. Short names in
|
||||
`dns.hosts` and DHCP reservation names live in it.
|
||||
'';
|
||||
};
|
||||
|
||||
dns.hosts = mkOption {
|
||||
type = types.attrsOf (types.listOf types.str);
|
||||
default = { };
|
||||
example = {
|
||||
nas = [ "192.168.10.20" ];
|
||||
"git.example.org" = [
|
||||
"10.43.0.50"
|
||||
"fd00::50"
|
||||
];
|
||||
};
|
||||
description = ''
|
||||
Local records: name to IPv4/IPv6 addresses. A name without a dot is
|
||||
placed in `dns.domain`; a full name overrides public DNS for that
|
||||
name only (split horizon). Reverse records are created as well.
|
||||
'';
|
||||
};
|
||||
|
||||
dns.overrides = mkOption {
|
||||
type = types.attrsOf (types.listOf types.str);
|
||||
default = { };
|
||||
example = {
|
||||
"k8s.example.org" = [ "10.43.0.50" ];
|
||||
};
|
||||
description = "Like `dns.hosts`, but the name and everything below it answer with these addresses (wildcard).";
|
||||
};
|
||||
|
||||
dns.dynamicLeases = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Publish the host names of DHCP clients as `<name>.<dns.domain>` (with
|
||||
reverse records), updated as leases come and go. Needs `dns.domain`.
|
||||
|
||||
A client picks its own name, so only its first label is used, only
|
||||
under the local domain, and a name that is configured here (`dns.hosts`,
|
||||
a reservation, the router itself, a wildcard or a forward zone it falls
|
||||
under) is never taken over. Of two clients claiming one name the newer
|
||||
lease wins. Run by `router-webui leases-dns` (the settings module).
|
||||
'';
|
||||
};
|
||||
|
||||
dns.forwardZones = mkOption {
|
||||
type = types.attrsOf (types.listOf types.str);
|
||||
default = { };
|
||||
example = {
|
||||
"branch.example.org" = [ "10.1.0.254" ];
|
||||
};
|
||||
description = ''
|
||||
Zones answered by other resolvers (e.g. across a tunnel). DNSSEC
|
||||
validation is skipped for them: they are private zones.
|
||||
'';
|
||||
};
|
||||
|
||||
dns.forwarders = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# A router keeps routing with the lid shut: laptops make fine routers, but
|
||||
# logind suspends them on lid close by default. Also picks the kernel.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
config = lib.mkIf config.router.enable {
|
||||
# Newest hardware support (and nftables/WireGuard fixes) over the LTS default.
|
||||
boot.kernelPackages = lib.mkDefault pkgs.linuxPackages_latest;
|
||||
services.logind.settings.Login = {
|
||||
HandleLidSwitch = "ignore";
|
||||
HandleLidSwitchExternalPower = "ignore";
|
||||
HandleLidSwitchDocked = "ignore";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,13 @@ let
|
||||
pppoe = wan.ipv4.pppoe;
|
||||
|
||||
# Device carrying the PPPoE session: the VLAN on the uplink, or the uplink itself.
|
||||
device = if pppoe.vlan == null then wan.interface else "${wan.interface}.${toString pppoe.vlan}";
|
||||
device =
|
||||
if pppoe.vlan == null then
|
||||
wan.interface
|
||||
else if pppoe.vlanName != null then
|
||||
pppoe.vlanName
|
||||
else
|
||||
"${wan.interface}.${toString pppoe.vlan}";
|
||||
|
||||
# Unit that appears once udev has seen the device (VLAN devices are created
|
||||
# by networkd after boot has begun, so pppd must not start before it).
|
||||
@@ -28,7 +34,12 @@ let
|
||||
defaultroute
|
||||
replacedefaultroute
|
||||
noipdefault
|
||||
nodefaultroute6
|
||||
${
|
||||
if wan.ipv6.enable && wan.ipv6.transport == "ppp" then
|
||||
"+ipv6\n ipv6cp-use-ipaddr"
|
||||
else
|
||||
"nodefaultroute6"
|
||||
}
|
||||
noccp
|
||||
mtu ${toString pppoe.mtu}
|
||||
mru ${toString pppoe.mtu}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Commit-confirm for deployments. `router-arm` starts a timer; unless
|
||||
# `router-confirm` stops it in time, `router-rollback` re-activates the last
|
||||
# confirmed generation. Pair it with `nixos-rebuild test` (activate without
|
||||
# changing the boot default): a reboot also comes back to the confirmed system.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router.rollback;
|
||||
profile = "/nix/var/nix/profiles/system";
|
||||
|
||||
tools = [
|
||||
pkgs.coreutils
|
||||
config.systemd.package
|
||||
];
|
||||
|
||||
# The confirmed system: the profile, or what booted if there is none.
|
||||
confirmed = ''
|
||||
target=${profile}
|
||||
[ -e "$target/bin/switch-to-configuration" ] || target=/run/booted-system
|
||||
'';
|
||||
|
||||
rollback = pkgs.writeShellApplication {
|
||||
name = "router-rollback";
|
||||
runtimeInputs = tools;
|
||||
text = ''
|
||||
${confirmed}
|
||||
echo "router: rolling back to $(readlink -f "$target")"
|
||||
# A failing hook must not stop the rollback.
|
||||
hook() {
|
||||
:
|
||||
${cfg.onRollback}
|
||||
}
|
||||
hook || echo "router: a rollback hook failed" >&2
|
||||
exec "$target/bin/switch-to-configuration" test
|
||||
'';
|
||||
};
|
||||
|
||||
arm = pkgs.writeShellApplication {
|
||||
name = "router-arm";
|
||||
runtimeInputs = tools;
|
||||
text = ''
|
||||
timeout=''${1:-${cfg.timeout}}
|
||||
systemctl stop router-rollback.timer router-rollback.service 2>/dev/null || true
|
||||
systemd-run --quiet --unit=router-rollback --on-active="$timeout" \
|
||||
--timer-property=AccuracySec=1s ${rollback}/bin/router-rollback
|
||||
echo "router: rollback in $timeout unless confirmed (router-confirm)"
|
||||
'';
|
||||
};
|
||||
|
||||
confirm = pkgs.writeShellApplication {
|
||||
name = "router-confirm";
|
||||
runtimeInputs = tools;
|
||||
text = ''
|
||||
systemctl stop router-rollback.timer
|
||||
current=$(readlink -f /run/current-system)
|
||||
${config.nix.package}/bin/nix-env -p ${profile} --set "$current"
|
||||
"$current/bin/switch-to-configuration" boot
|
||||
${cfg.onConfirm}
|
||||
echo "router: confirmed $current"
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.router.rollback = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Install the commit-confirm helpers `router-arm`, `router-confirm` and `router-rollback`.";
|
||||
};
|
||||
onConfirm = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = "Shell commands `router-confirm` runs once the current system is confirmed.";
|
||||
};
|
||||
onRollback = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = "Shell commands `router-rollback` runs before it re-activates the confirmed system.";
|
||||
};
|
||||
timeout = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "5min";
|
||||
example = "90s";
|
||||
description = "How long a deployment may stay unconfirmed before it is rolled back (systemd time span).";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (config.router.enable && cfg.enable) {
|
||||
environment.systemPackages = [
|
||||
arm
|
||||
confirm
|
||||
rollback
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router;
|
||||
|
||||
# systemd-networkd runs unprivileged and could not read root-only key files.
|
||||
# LoadCredential= makes systemd read them as root and hand networkd a copy
|
||||
# in its credentials directory, so the files can stay 0600 root.
|
||||
credDir = "/run/credentials/systemd-networkd.service";
|
||||
keyCred = name: "wg-${name}-key";
|
||||
pskCred = name: peer: "wg-${name}-${peer}-psk";
|
||||
credentials = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
name: wg:
|
||||
[ "${keyCred name}:${toString wg.privateKeyFile}" ]
|
||||
++ lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
peer: p:
|
||||
lib.optional (p.presharedKeyFile != null) "${pskCred name peer}:${toString p.presharedKeyFile}"
|
||||
) wg.peers
|
||||
)
|
||||
) cfg.wireguard
|
||||
);
|
||||
|
||||
# Peers whose endpoint is a name (not an address). systemd-networkd looks a
|
||||
# name up when it creates the device (and retries until it resolves, every
|
||||
# 25 s at most), but never again: after the peer's address changes both
|
||||
# sides keep sending to the old one.
|
||||
isName = e: e != null && !lib.hasPrefix "[" e && builtins.match "[0-9.]+:[0-9]+" e == null;
|
||||
namedPeers = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
name: wg:
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
peer: p:
|
||||
lib.optional (isName p.endpoint) {
|
||||
inherit (wg) interface;
|
||||
inherit peer;
|
||||
inherit (p) publicKey endpoint;
|
||||
}
|
||||
) wg.peers
|
||||
)
|
||||
) cfg.wireguard
|
||||
);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && cfg.wireguard != { }) {
|
||||
systemd.network.netdevs = lib.mapAttrs' (
|
||||
name: wg:
|
||||
lib.nameValuePair "30-wg-${name}" {
|
||||
netdevConfig = {
|
||||
Kind = "wireguard";
|
||||
Name = wg.interface;
|
||||
}
|
||||
// lib.optionalAttrs (wg.mtu != null) { MTUBytes = wg.mtu; };
|
||||
wireguardConfig = {
|
||||
PrivateKeyFile = "${credDir}/${keyCred name}";
|
||||
}
|
||||
// lib.optionalAttrs (wg.listenPort != null) { ListenPort = wg.listenPort; };
|
||||
wireguardPeers = lib.mapAttrsToList (
|
||||
peer: p:
|
||||
{
|
||||
PublicKey = p.publicKey;
|
||||
AllowedIPs = p.allowedIPs;
|
||||
}
|
||||
// lib.optionalAttrs (p.endpoint != null) { Endpoint = p.endpoint; }
|
||||
// lib.optionalAttrs (p.persistentKeepalive != null) {
|
||||
PersistentKeepalive = p.persistentKeepalive;
|
||||
}
|
||||
// lib.optionalAttrs (p.presharedKeyFile != null) {
|
||||
PresharedKeyFile = "${credDir}/${pskCred name peer}";
|
||||
}
|
||||
) wg.peers;
|
||||
}
|
||||
) cfg.wireguard;
|
||||
|
||||
systemd.services.systemd-networkd.serviceConfig.LoadCredential = credentials;
|
||||
|
||||
systemd.network.networks = lib.mapAttrs' (
|
||||
name: wg:
|
||||
lib.nameValuePair "30-wg-${name}" {
|
||||
matchConfig.Name = wg.interface;
|
||||
address = wg.addresses;
|
||||
networkConfig = {
|
||||
ConfigureWithoutCarrier = true;
|
||||
IPv6AcceptRA = false;
|
||||
};
|
||||
# Cryptokey routing: a peer's allowedIPs are the destinations sent into the tunnel.
|
||||
routes = lib.concatMap (
|
||||
p:
|
||||
map (
|
||||
ip:
|
||||
let
|
||||
sameFamily = lib.filter (s: lib.hasInfix ":" s == lib.hasInfix ":" ip) wg.sourceAddresses;
|
||||
in
|
||||
{
|
||||
Destination = ip;
|
||||
}
|
||||
// lib.optionalAttrs (sameFamily != [ ]) { PreferredSource = builtins.head sameFamily; }
|
||||
) p.allowedIPs
|
||||
) (lib.attrValues wg.peers);
|
||||
}
|
||||
) cfg.wireguard;
|
||||
# Look the names up again, like wireguard-tools' reresolve-dns: only for
|
||||
# peers that have not shown a handshake lately (a working tunnel is left
|
||||
# alone; with keepalive, handshakes are at most about two minutes apart).
|
||||
systemd.services.router-wireguard-reresolve =
|
||||
lib.mkIf (cfg.wireguardEndpointRefresh != null && namedPeers != [ ])
|
||||
{
|
||||
description = "Look up the names of WireGuard peer endpoints again";
|
||||
after = [ "systemd-networkd.service" ];
|
||||
path = [
|
||||
pkgs.wireguard-tools
|
||||
pkgs.gawk
|
||||
pkgs.coreutils
|
||||
];
|
||||
script = ''
|
||||
now=$(date +%s)
|
||||
refresh() { # device peer-name public-key endpoint
|
||||
last=$(wg show "$1" latest-handshakes 2>/dev/null | awk -v k="$3" '$1 == k { print $2 }')
|
||||
[ -n "$last" ] || return 0 # no such device or peer (yet)
|
||||
if [ "$last" -gt 0 ] && [ $((now - last)) -lt 135 ]; then return 0; fi
|
||||
before=$(wg show "$1" endpoints | awk -v k="$3" '$1 == k { print $2 }')
|
||||
if ! wg set "$1" peer "$3" endpoint "$4"; then
|
||||
echo "$1: $2: cannot resolve $4"
|
||||
return 0
|
||||
fi
|
||||
after=$(wg show "$1" endpoints | awk -v k="$3" '$1 == k { print $2 }')
|
||||
if [ "$before" != "$after" ]; then echo "$1: $2: endpoint $before -> $after ($4)"; fi
|
||||
}
|
||||
''
|
||||
+ lib.concatMapStrings (
|
||||
p:
|
||||
"refresh ${
|
||||
lib.escapeShellArgs [
|
||||
p.interface
|
||||
p.peer
|
||||
p.publicKey
|
||||
p.endpoint
|
||||
]
|
||||
}\n"
|
||||
) namedPeers;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
|
||||
RestrictAddressFamilies = [
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
"AF_NETLINK"
|
||||
"AF_UNIX"
|
||||
];
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
};
|
||||
};
|
||||
systemd.timers.router-wireguard-reresolve =
|
||||
lib.mkIf (cfg.wireguardEndpointRefresh != null && namedPeers != [ ])
|
||||
{
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = cfg.wireguardEndpointRefresh;
|
||||
AccuracySec = "10s";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
# A router configured by a settings file instead of Nix code, so that it can
|
||||
# be changed on the box itself (by the web UI or by hand) and rebuilt there.
|
||||
#
|
||||
# settings.json holds the `router` options as JSON plus a few system settings:
|
||||
#
|
||||
# {
|
||||
# "system": { "hostName": "router", "timeZone": "UTC", "sshKeys": [ "ssh-ed25519 ..." ],
|
||||
# "stateVersion": "26.11" },
|
||||
# "router": { "wan": { "interface": "enp1s0" }, "lans": { "lan": { ... } } }
|
||||
# }
|
||||
#
|
||||
# The module system validates it like any configuration: unknown keys, wrong
|
||||
# types and the router's assertions all fail the build. Secrets stay out of it
|
||||
# (files under /var/lib/router, referenced by path).
|
||||
#
|
||||
# On the device the configuration lives in /var/lib/router/config: a flake
|
||||
# (pinned to this router's source), settings.json and an optional local.nix.
|
||||
# `router-apply` builds it and activates it with commit-confirm: unless
|
||||
# `router-confirm` follows within router.rollback.timeout, the previous system
|
||||
# comes back. A change of interface names is only staged for the next boot.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
options,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.routerSettings;
|
||||
profile = "/nix/var/nix/profiles/system";
|
||||
settings = lib.importJSON cfg.file;
|
||||
system = settings.system or { };
|
||||
|
||||
pending = "/run/router-pending-settings.json";
|
||||
confirmed = "${cfg.stateDirectory}/settings.confirmed.json";
|
||||
|
||||
apply = pkgs.writeShellApplication {
|
||||
name = "router-apply";
|
||||
runtimeInputs = [
|
||||
config.nix.package
|
||||
pkgs.coreutils
|
||||
pkgs.gawk
|
||||
pkgs.gnugrep
|
||||
];
|
||||
text = ''
|
||||
timeout=''${1:-${config.router.rollback.timeout}}
|
||||
# Remember what is being built: confirming records it (the web UI shows
|
||||
# settings that differ from it as not yet applied).
|
||||
building=$(mktemp /run/router-building.XXXXXX)
|
||||
cp ${cfg.directory}/settings.json "$building"
|
||||
echo "router: building ${cfg.directory}"
|
||||
new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \
|
||||
"path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel")
|
||||
if [ "$new" = "$(readlink -f /run/current-system)" ]; then
|
||||
echo "router: no changes"
|
||||
if [ "$new" = "$(readlink -f ${profile})" ]; then
|
||||
install -m 0600 "$building" ${confirmed}
|
||||
fi
|
||||
rm -f "$building"
|
||||
exit 0
|
||||
fi
|
||||
# Interface names cannot change on a running system, and the new
|
||||
# configuration refers to them: stage it for the next boot instead.
|
||||
names() { cat "$1/etc/router/interface-names" 2>/dev/null || true; }
|
||||
staged=
|
||||
# A card gets, loses or changes its name.
|
||||
[ "$(names "$new" | grep '^nic ' || true)" = "$(names /run/current-system | grep '^nic ' || true)" ] || staged=1
|
||||
# A VLAN device that exists keeps its parent and ID but gets another name.
|
||||
awk 'NR == FNR { if ($1 == "vlan") old[$2 " " $3] = $4; next }
|
||||
$1 == "vlan" && ($2 " " $3) in old && old[$2 " " $3] != $4 { bad = 1 }
|
||||
END { exit bad }' <(names /run/current-system) <(names "$new") || staged=1
|
||||
if [ -n "$staged" ]; then
|
||||
nix-env -p ${profile} --set "$new"
|
||||
"$new/bin/switch-to-configuration" boot
|
||||
install -m 0600 "$building" ${confirmed}
|
||||
rm -f "$building"
|
||||
echo "router: interface names change; the new configuration starts at the next reboot"
|
||||
exit 0
|
||||
fi
|
||||
mv "$building" ${pending}
|
||||
router-arm "$timeout"
|
||||
echo "router: activating $new"
|
||||
"$new/bin/switch-to-configuration" test
|
||||
'';
|
||||
};
|
||||
|
||||
# What the web UI's MCP endpoint offers an assistant as the router's options.
|
||||
optionsJson =
|
||||
(pkgs.nixosOptionsDoc {
|
||||
options = {
|
||||
inherit (options) router;
|
||||
};
|
||||
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
|
||||
+ "/share/doc/nixos/options.json";
|
||||
|
||||
# Evaluate the saved settings like an apply would, without building: the
|
||||
# option types and the router's assertions. Started by the web UI.
|
||||
check = pkgs.writeShellApplication {
|
||||
name = "router-check";
|
||||
runtimeInputs = [
|
||||
config.nix.package
|
||||
pkgs.coreutils
|
||||
pkgs.jq
|
||||
];
|
||||
text = ''
|
||||
etag=$(sha256sum ${cfg.directory}/settings.json | cut -c1-16)
|
||||
mkdir -p ${cfg.stateDirectory}/check
|
||||
out=$(mktemp ${cfg.stateDirectory}/check/result.XXXXXX)
|
||||
if msg=$(nix eval --raw "path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel.drvPath" 2>&1); then
|
||||
jq -n --arg etag "$etag" --arg time "$(date -u +%FT%TZ)" '{ok: true, etag: $etag, time: $time}' > "$out"
|
||||
else
|
||||
jq -n --arg etag "$etag" --arg time "$(date -u +%FT%TZ)" --arg error "$msg" '{ok: false, error: $error, etag: $etag, time: $time}' > "$out"
|
||||
fi
|
||||
mv "$out" ${cfg.stateDirectory}/check/result.json
|
||||
'';
|
||||
};
|
||||
|
||||
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./update.nix
|
||||
./upgrade.nix
|
||||
];
|
||||
|
||||
# Outside `router`: the settings file defines that whole tree.
|
||||
options.routerSettings = {
|
||||
file = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "The settings file (JSON) this system is built from.";
|
||||
};
|
||||
directory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/router/config";
|
||||
description = "Where the configuration (flake and settings) lives on the device.";
|
||||
};
|
||||
stateDirectory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/router";
|
||||
description = "Secrets and web UI state (password hash, certificate).";
|
||||
};
|
||||
webui = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Serve the web UI (HTTPS) to LANs and tunnels with `allowManagement`.";
|
||||
};
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
};
|
||||
httpPort = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.port;
|
||||
default = 80;
|
||||
description = "Redirect plain HTTP on this port to HTTPS (to the LANs and tunnels with `allowManagement`); null: do not listen.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
router = lib.mkMerge [
|
||||
(settings.router // { enable = true; })
|
||||
{
|
||||
rollback.onConfirm = ''
|
||||
if [ -e ${pending} ]; then mv ${pending} ${confirmed}; fi
|
||||
'';
|
||||
}
|
||||
(lib.mkIf cfg.webui.enable {
|
||||
managementPorts = [
|
||||
cfg.webui.port
|
||||
]
|
||||
++ lib.optional (cfg.webui.httpPort != null) cfg.webui.httpPort;
|
||||
})
|
||||
];
|
||||
|
||||
networking.hostName = system.hostName or "router";
|
||||
time.timeZone = system.timeZone or "UTC";
|
||||
users.users.root.openssh.authorizedKeys.keys = system.sshKeys or [ ];
|
||||
# Written by router-setup (the web UI password). Users are mutable, so it
|
||||
# only sets the password when the account is created, at installation;
|
||||
# `passwd` changes it later.
|
||||
users.users.root.hashedPasswordFile = lib.mkDefault "${cfg.stateDirectory}/root-password";
|
||||
system.stateVersion = system.stateVersion or config.system.nixos.release;
|
||||
|
||||
nix.settings.experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
|
||||
environment.systemPackages = [
|
||||
apply
|
||||
webui
|
||||
pkgs.jq # for looking at and editing settings.json by hand
|
||||
pkgs.wireguard-tools # wg: the tunnels' state, from a shell
|
||||
];
|
||||
|
||||
# The web UI starts these rather than running anything itself, so a switch
|
||||
# that restarts the UI does not cut them short; a switch must not restart
|
||||
# or stop them either (router-apply is what runs the switch).
|
||||
systemd.services.router-apply = {
|
||||
description = "Build and activate the router configuration";
|
||||
restartIfChanged = false;
|
||||
stopIfChanged = false;
|
||||
path = [ "/run/current-system/sw" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = lib.getExe apply;
|
||||
TimeoutStartSec = "1h";
|
||||
};
|
||||
};
|
||||
# Puts root's password hash (written by the web UI) into /etc/shadow now,
|
||||
# rather than at the next switch; the web UI itself may not touch /etc.
|
||||
systemd.services.router-check = lib.mkIf cfg.webui.enable {
|
||||
description = "Evaluate the router configuration (no build)";
|
||||
restartIfChanged = false;
|
||||
stopIfChanged = false;
|
||||
path = [ "/run/current-system/sw" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = lib.getExe check;
|
||||
TimeoutStartSec = "10min";
|
||||
};
|
||||
};
|
||||
systemd.services.router-root-password = lib.mkIf cfg.webui.enable {
|
||||
description = "Apply root's password from the web UI";
|
||||
restartIfChanged = false;
|
||||
stopIfChanged = false;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "router-root-password" ''
|
||||
exec ${pkgs.shadow}/bin/usermod --password "$(cat ${cfg.stateDirectory}/root-password)" root
|
||||
'';
|
||||
};
|
||||
};
|
||||
systemd.services.router-confirm = {
|
||||
description = "Confirm the active router configuration";
|
||||
restartIfChanged = false;
|
||||
stopIfChanged = false;
|
||||
path = [ "/run/current-system/sw" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "/run/current-system/sw/bin/router-confirm";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.router-webui = lib.mkIf cfg.webui.enable {
|
||||
description = "Router web UI";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
# iproute2: the dashboard reads the default route and the PPP peer;
|
||||
# wireguard-tools: the tunnels' status; qrencode: client configs.
|
||||
path = [
|
||||
config.systemd.package
|
||||
pkgs.iproute2
|
||||
pkgs.wireguard-tools
|
||||
pkgs.qrencode
|
||||
pkgs.mkpasswd # the hash of root's password
|
||||
];
|
||||
serviceConfig = {
|
||||
ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port}${
|
||||
lib.optionalString (cfg.webui.httpPort != null) " -http :${toString cfg.webui.httpPort}"
|
||||
}${
|
||||
lib.optionalString (
|
||||
config.router.acme.enable && config.router.acme.domains != [ ]
|
||||
) " -acme-dir /var/lib/acme/${lib.head config.router.acme.domains}"
|
||||
} -options ${optionsJson} -config ${cfg.directory} -state ${cfg.stateDirectory}";
|
||||
Restart = "on-failure";
|
||||
# It edits settings and secrets and talks to systemd; nothing else.
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ cfg.stateDirectory ];
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
NoNewPrivileges = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = [
|
||||
"AF_UNIX"
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
"AF_NETLINK"
|
||||
];
|
||||
LockPersonality = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
};
|
||||
};
|
||||
|
||||
# The DHCP clients' names in DNS (router.dns.dynamicLeases): reads Kea's
|
||||
# leases and publishes them in the running Unbound.
|
||||
systemd.services.router-leases-dns =
|
||||
lib.mkIf (config.router.enable && config.router.dns.dynamicLeases)
|
||||
{
|
||||
description = "Publish DHCP clients' names in DNS";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [
|
||||
"unbound.service"
|
||||
"kea-dhcp4-server.service"
|
||||
];
|
||||
path = [ config.services.unbound.package ];
|
||||
serviceConfig = {
|
||||
ExecStart = "${lib.getExe webui} leases-dns -leases /var/lib/kea/dhcp4.leases -static /etc/router/dns-static.json -socket ${config.services.unbound.localControlSocketPath} -run /run/router-leases-dns";
|
||||
RuntimeDirectory = "router-leases-dns";
|
||||
Restart = "always";
|
||||
RestartSec = 5;
|
||||
# It reads leases and talks to Unbound's socket; nothing else.
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateNetwork = true;
|
||||
PrivateDevices = true;
|
||||
NoNewPrivileges = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = [ "AF_UNIX" ];
|
||||
LockPersonality = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Rebuilding on the device must work without internet (the uplink may be
|
||||
# what is being configured). What a changed configuration rebuilds is all
|
||||
# generated by NixOS, but those builds need these packages; found by
|
||||
# diffing the derivation graphs of typical changes (pool, addresses, host
|
||||
# name, PPPoE on a VLAN, a VLAN LAN, IPv6 PD/ULA, a port forward, DNS
|
||||
# records) against the installed system.
|
||||
system.extraDependencies = with pkgs; [
|
||||
stdenvNoCC
|
||||
findXMLCatalogs
|
||||
jq.dev
|
||||
kmod.dev
|
||||
libcap-text-verifier
|
||||
libxslt.bin
|
||||
# nftables checks its ruleset at build time inside lkl.
|
||||
libredirect
|
||||
lklWithFirewall.lib
|
||||
lndir
|
||||
makeShellWrapper
|
||||
mdadm
|
||||
nukeReferences
|
||||
# Switching the uplink to PPPoE is a typical first change.
|
||||
ppp
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
# Updating an installed router to a newer version of nixos-router.
|
||||
#
|
||||
# A release is a store path (see `lib.mkRelease`): the new source, its
|
||||
# nixpkgs, the configuration flake that points at them and, for offline
|
||||
# routers, the reference system with everything the rebuild needs. It travels
|
||||
# as a binary cache signed with the update key: a bundle file (uploaded in the
|
||||
# web UI or copied over) or a URL the router pulls from. `router-update`
|
||||
# accepts it only when every path in its closure carries a signature of
|
||||
# `routerUpdate.publicKeys`, then
|
||||
#
|
||||
# 1. imports it into the store,
|
||||
# 2. points the configuration flake at it,
|
||||
# 3. migrates settings.json to the release's schema (migrations/N.jq),
|
||||
# 4. rebuilds with `router-apply` (commit-confirm: unless the update is
|
||||
# confirmed in time, the previous system comes back).
|
||||
#
|
||||
# The configuration directory as it was is kept in config.pre-update until the
|
||||
# update is confirmed; a rollback (or a failed update) puts it back.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.routerSettings;
|
||||
keys = config.routerUpdate.publicKeys;
|
||||
settings = lib.importJSON cfg.file;
|
||||
snapshot = "${cfg.stateDirectory}/config.pre-update";
|
||||
updateDir = "${cfg.stateDirectory}/update";
|
||||
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
|
||||
migrate = pkgs.callPackage ../../pkgs/router-migrate.nix { };
|
||||
|
||||
restore = pkgs.writeShellApplication {
|
||||
name = "router-update-restore";
|
||||
runtimeInputs = [
|
||||
pkgs.coreutils
|
||||
pkgs.findutils
|
||||
];
|
||||
text = ''
|
||||
[ -d ${snapshot} ] || exit 0
|
||||
find ${cfg.directory} -mindepth 1 -delete
|
||||
cp -a ${snapshot}/. ${cfg.directory}/
|
||||
rm -rf ${snapshot}
|
||||
echo "router: restored the configuration from before the update"
|
||||
'';
|
||||
};
|
||||
|
||||
update = pkgs.writeShellApplication {
|
||||
name = "router-update";
|
||||
runtimeInputs = [
|
||||
config.nix.package
|
||||
config.systemd.package
|
||||
webui
|
||||
migrate
|
||||
pkgs.coreutils
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
pkgs.util-linux
|
||||
];
|
||||
text = ''
|
||||
usage() {
|
||||
echo "usage: router-update [--force] (--request | --bundle FILE | --url URL) [timeout]" >&2
|
||||
echo " --bundle FILE a bundle made by router-release" >&2
|
||||
echo " --url URL a server with the release's latest and cache/ (HTTP or HTTPS)" >&2
|
||||
echo " --request what the web UI asked for (${updateDir}/request.json)" >&2
|
||||
echo " --force also install a release that is not newer" >&2
|
||||
exit 2
|
||||
}
|
||||
die() { echo "router-update: $*" >&2; exit 1; }
|
||||
|
||||
force=0 mode="" arg="" timeout=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--force) force=1 ;;
|
||||
--bundle) shift; [ $# -gt 0 ] || usage; mode=bundle arg=$1 ;;
|
||||
--url) shift; [ $# -gt 0 ] || usage; mode=url arg=$1 ;;
|
||||
--request)
|
||||
req=${updateDir}/request.json
|
||||
[ -r "$req" ] || die "no request"
|
||||
mode=$(jq -er .mode "$req") arg=$(jq -r '.url // ""' "$req")
|
||||
force=$(jq -r 'if .force then 1 else 0 end' "$req")
|
||||
rm -f "$req" ;;
|
||||
-*) usage ;;
|
||||
*) timeout=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$mode" ] || usage
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
exec 9>/run/router-update.lock
|
||||
flock -n 9 || die "another update is running"
|
||||
|
||||
trusted=$(tr '\n' ' ' < /etc/router-update-keys)
|
||||
[ -n "''${trusted// /}" ] || die "this router has no update key"
|
||||
|
||||
staged=${updateDir}/staged
|
||||
case $mode in
|
||||
bundle)
|
||||
rm -rf "$staged"
|
||||
router-webui unbundle "$arg" "$staged"
|
||||
mode=staged ;;
|
||||
esac
|
||||
case $mode in
|
||||
staged)
|
||||
store="file://$staged/cache"
|
||||
latest=$(cat "$staged/latest") ;;
|
||||
url)
|
||||
base=''${arg%/}
|
||||
store="$base/cache"
|
||||
latest=$(curl -fsS --max-time 30 "$base/latest") ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
release=$(jq -er .release <<<"$latest") || die "the update has no release"
|
||||
[[ $release =~ ^/nix/store/[0-9a-z]{32}-[A-Za-z0-9+._-]+$ ]] || die "not a store path: $release"
|
||||
|
||||
# Nothing is trusted until every path of the closure is signed by our key.
|
||||
echo "router-update: verifying $release"
|
||||
nix --option trusted-public-keys "$trusted" store verify \
|
||||
--store "$store" --no-contents --sigs-needed 1 -r "$release" \
|
||||
|| die "the update is not signed with this router's update key"
|
||||
echo "router-update: importing it"
|
||||
nix --option trusted-public-keys "$trusted" copy --from "$store" "$release"
|
||||
rm -rf "$staged"
|
||||
|
||||
new_version=$(cat "$release/version")
|
||||
new_serial=$(cat "$release/serial")
|
||||
cur_version=$(jq -r '.version // "unknown"' /etc/router-release)
|
||||
cur_serial=$(jq -r '.serial // 0' /etc/router-release)
|
||||
echo "router-update: installed $cur_version, update $new_version"
|
||||
if [ "$new_serial" -le "$cur_serial" ] && [ "$force" -ne 1 ]; then
|
||||
die "this release is not newer than the installed one (--force installs it anyway)"
|
||||
fi
|
||||
|
||||
config=${cfg.directory}
|
||||
[ ! -d ${snapshot} ] || die "an earlier update is not confirmed yet"
|
||||
cp -a "$config" ${snapshot}
|
||||
fail() {
|
||||
echo "router-update: $*; keeping $cur_version" >&2
|
||||
${restore}/bin/router-update-restore
|
||||
exit 1
|
||||
}
|
||||
|
||||
install -m 0644 "$release/flake.nix" "$config/flake.nix"
|
||||
rm -f "$config/flake.lock"
|
||||
# A nixpkgs that router-upgrade chose stays, rather than the release's.
|
||||
if [ -r "$config/nixpkgs-pin.json" ]; then
|
||||
nix flake lock --override-input nixos-router/nixpkgs "$(jq -r .ref "$config/nixpkgs-pin.json")" "path:$config" \
|
||||
|| fail "pinning the upgraded nixpkgs failed"
|
||||
fi
|
||||
|
||||
router-migrate-settings "$release" "$config/settings.json" || fail "migrating the settings failed"
|
||||
|
||||
echo "router-update: building and activating $new_version"
|
||||
if ! router-apply ''${timeout:+"$timeout"}; then
|
||||
if systemctl is-active --quiet router-rollback.timer; then
|
||||
# Activation failed half way: the rollback restores the configuration.
|
||||
systemctl start router-rollback.service
|
||||
else
|
||||
${restore}/bin/router-update-restore
|
||||
fi
|
||||
die "applying $new_version failed; $cur_version is back"
|
||||
fi
|
||||
if systemctl is-active --quiet router-rollback.timer; then
|
||||
echo "router-update: $new_version is active; confirm it (router-confirm, or in the web UI) or $cur_version comes back"
|
||||
else
|
||||
rm -rf ${snapshot} # nothing changed: nothing to confirm
|
||||
fi
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options = {
|
||||
routerSettings.release = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
|
||||
default = null;
|
||||
description = "Version, serial, revision and settings schema of this nixos-router (`/etc/router-release`).";
|
||||
};
|
||||
routerUpdate.publicKeys = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "my-update-key-1:AAAA…=" ];
|
||||
description = ''
|
||||
Public keys (`nix key convert-secret-to-public`) of the keys whose
|
||||
signatures make an update acceptable.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.etc."router-release".text = builtins.toJSON (
|
||||
if config.routerSettings.release == null then { } else config.routerSettings.release
|
||||
);
|
||||
environment.etc."router-update-keys".text = lib.concatLines keys;
|
||||
environment.systemPackages = [
|
||||
update
|
||||
restore
|
||||
];
|
||||
|
||||
assertions = lib.optional (config.routerSettings.release != null) {
|
||||
assertion = (settings.version or 0) == config.routerSettings.release.schema;
|
||||
message = ''
|
||||
settings.json is at version ${
|
||||
toString (settings.version or 0)
|
||||
}, but this release of nixos-router expects
|
||||
version ${toString config.routerSettings.release.schema}. `router-update` migrates the settings; if you edited
|
||||
them by hand, set "version" accordingly.
|
||||
'';
|
||||
};
|
||||
|
||||
# A rolled back or failed update puts the configuration directory back;
|
||||
# a confirmed one keeps the new one.
|
||||
router.rollback.onRollback = "${restore}/bin/router-update-restore";
|
||||
router.rollback.onConfirm = "rm -rf ${snapshot}";
|
||||
|
||||
systemd.services.router-update = {
|
||||
description = "Install a router update";
|
||||
restartIfChanged = false;
|
||||
stopIfChanged = false;
|
||||
path = [ "/run/current-system/sw" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${lib.getExe update} --request";
|
||||
TimeoutStartSec = "2h";
|
||||
};
|
||||
};
|
||||
|
||||
# Old generations (and the updates' leftovers) would fill the disk.
|
||||
nix.gc = {
|
||||
automatic = true;
|
||||
dates = "weekly";
|
||||
options = "--delete-older-than 30d";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
# Upgrading the packages (nixpkgs: the kernel and everything else) of an
|
||||
# installed router without a new release of nixos-router.
|
||||
#
|
||||
# The branch to follow is the setting `router.update.channel` (unstable, or a
|
||||
# release like 26.05). nixpkgs is pinned in the configuration flake's
|
||||
# flake.lock, so an upgrade is a new lock:
|
||||
#
|
||||
# router-upgrade check what the channel's newest revision is, against the running one
|
||||
# router-upgrade preview lock a copy of the configuration flake to it and evaluate the
|
||||
# system: which packages change version, how much would be
|
||||
# downloaded and built (nothing is downloaded or activated)
|
||||
# router-upgrade download fetch and build it now (a flaky uplink is no problem later)
|
||||
# router-upgrade apply take the lock and run `router-apply` (commit-confirm)
|
||||
#
|
||||
# The revision comes from channels.nixos.org: the branch's last revision that
|
||||
# passed the NixOS tests. A configuration directory that was upgraded keeps
|
||||
# the choice in nixpkgs-pin.json, which `router-update` locks again after it
|
||||
# installs a new release of nixos-router. The directory as it was is kept in
|
||||
# config.pre-update, as for updates: a rollback or a failure puts it back.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.routerSettings;
|
||||
up = cfg.upgrade;
|
||||
snapshot = "${cfg.stateDirectory}/config.pre-update";
|
||||
dir = "${cfg.stateDirectory}/upgrade";
|
||||
|
||||
upgrade = pkgs.writeShellApplication {
|
||||
name = "router-upgrade";
|
||||
runtimeInputs = [
|
||||
config.nix.package
|
||||
config.systemd.package
|
||||
pkgs.coreutils
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
pkgs.util-linux
|
||||
];
|
||||
text = ''
|
||||
usage() {
|
||||
echo "usage: router-upgrade check | preview [--force] [--downgrade] | download | apply [timeout] | --request" >&2
|
||||
echo " check the newest revision of router.update.channel, against the running one" >&2
|
||||
echo " preview the packages that change and the size of the download (nothing is fetched)" >&2
|
||||
echo " download fetch and build what was previewed, without activating it" >&2
|
||||
echo " apply activate what was previewed (fetching it if need be), with commit-confirm" >&2
|
||||
exit 2
|
||||
}
|
||||
die() { echo "router-upgrade: $*" >&2; exit 1; }
|
||||
[ $# -gt 0 ] || usage
|
||||
if [ "$1" = --request ]; then
|
||||
# What the web UI asked for (${dir}/request.json).
|
||||
req=${dir}/request.json
|
||||
[ -r "$req" ] || die "no request"
|
||||
action=$(jq -er .action "$req")
|
||||
force=$(jq -r 'if .force then "--force" else "" end' "$req")
|
||||
downgrade=$(jq -r 'if .downgrade then "--downgrade" else "" end' "$req")
|
||||
timeout=$(jq -r '.timeout // ""' "$req")
|
||||
rm -f "$req"
|
||||
# The web UI shows the last run (a finished unit forgets its journal).
|
||||
exec > >(tee ${dir}/last.log) 2>&1
|
||||
case $action in
|
||||
preview) set -- preview ''${force:+"$force"} ''${downgrade:+"$downgrade"} ;;
|
||||
apply) set -- apply ''${timeout:+"$timeout"} ;;
|
||||
check | download) set -- "$action" ;;
|
||||
*) die "unknown request: $action" ;;
|
||||
esac
|
||||
fi
|
||||
cmd=$1
|
||||
shift
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
|
||||
config=${cfg.directory}
|
||||
mkdir -p ${dir}
|
||||
|
||||
channel=$(jq -r '.router.update.channel // empty' "$config/settings.json")
|
||||
[ -n "$channel" ] || die "no channel: set router.update.channel (unstable, or a release like 26.05)"
|
||||
if [ "$channel" = unstable ]; then branch=nixos-unstable; else branch="nixos-$channel"; fi
|
||||
|
||||
running_rev=$(/run/current-system/sw/bin/nixos-version --revision || true)
|
||||
running_version=$(cat /run/current-system/nixos-version)
|
||||
short() { echo "''${1:0:7}"; }
|
||||
|
||||
newest_rev() {
|
||||
local rev
|
||||
# (channels.nixos.org answers with a redirect to releases.nixos.org.)
|
||||
rev=$(curl -fsSL --max-time 30 "${up.channelsUrl}/$branch/git-revision") \
|
||||
|| die "cannot read ${up.channelsUrl}/$branch/git-revision (no internet, or no channel of that name)"
|
||||
[[ $rev =~ ^[0-9a-f]{40}$ ]] || die "unexpected answer for $branch: ''${rev:0:60}"
|
||||
echo "$rev"
|
||||
}
|
||||
|
||||
check() {
|
||||
newest=$(newest_rev)
|
||||
if [ "$newest" = "$running_rev" ]; then same=true; else same=false; fi
|
||||
jq -n --arg channel "$channel" --arg branch "$branch" --arg time "$(date -u +%FT%TZ)" \
|
||||
--arg running "$running_rev" --arg version "$running_version" --arg newest "$newest" \
|
||||
--argjson upToDate "$same" \
|
||||
'{channel: $channel, branch: $branch, time: $time, running: {rev: $running, version: $version},
|
||||
newest: {rev: $newest}, upToDate: $upToDate}' > ${dir}/check.json.new
|
||||
mv ${dir}/check.json.new ${dir}/check.json
|
||||
echo "channel $channel ($branch)"
|
||||
echo " running $running_version"
|
||||
echo " newest $(short "$newest")"
|
||||
if [ "$same" = true ]; then echo "up to date"; else echo "a newer revision is available"; fi
|
||||
}
|
||||
|
||||
# The names (with versions) of store paths, one per line.
|
||||
names() { sed -e 's|^/nix/store/[a-z0-9]\{32\}-||' -e 's/\.drv$//'; }
|
||||
|
||||
# The packages of the running system none of whose versions is among the
|
||||
# new system's derivations': {name, from, to}. Approximate: a package that is rebuilt
|
||||
# without a new version is not listed, and build-time versions of a package that
|
||||
# the system does not run are not either.
|
||||
changes() { # file of the running system's paths, file of the new system's
|
||||
jq -n --rawfile old "$1" --rawfile new "$2" '
|
||||
def lines: split("\n") | map(select(. != "" and (test("\\.(tar|patch|diff|zip|tgz|tbz2?|txz|gz|xz|bz2|sh|py|nix|json|conf|asc|sig|lock)$") | not)));
|
||||
def strip: sub("-(bin|dev|lib|out|man|doc|info|devdoc|static|lib32|modules|modules-shrunk|libgcc|getent|debug)$"; "");
|
||||
def parse: capture("^(?<n>.*?)-(?<v>[0-9].*)$") // {n: ., v: ""};
|
||||
def table: map(strip | parse) | group_by(.n) | map({key: .[0].n, value: (map(.v) | unique)}) | from_entries;
|
||||
($old | lines | table) as $o | ($new | lines | table) as $n
|
||||
| [ $o | to_entries[] | select($n[.key] != null and .value != [""])
|
||||
| select(((.value - $n[.key]) | length) == (.value | length) and (($n[.key] - .value) | length) > 0)
|
||||
| {name: .key, from: (.value | join(", ")), to: ($n[.key] - .value | join(", "))} ]
|
||||
| sort_by(.name)'
|
||||
}
|
||||
|
||||
preview() {
|
||||
local force=0 downgrade=0 arg
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--force) force=1 ;;
|
||||
--downgrade) downgrade=1 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
check
|
||||
rm -rf ${dir}/preview.json ${dir}/flake ${dir}/result
|
||||
if [ "$same" = true ] && [ "$force" -eq 0 ]; then return 0; fi
|
||||
|
||||
mkdir ${dir}/flake
|
||||
for f in flake.nix settings.json local.nix flake.lock; do
|
||||
if [ -e "$config/$f" ]; then cp "$config/$f" ${dir}/flake/; fi
|
||||
done
|
||||
ref="${up.nixpkgsRef}"
|
||||
ref=''${ref//%rev%/$newest}
|
||||
echo "router-upgrade: locking nixpkgs to $(short "$newest")"
|
||||
nix flake lock --override-input nixos-router/nixpkgs "$ref" "path:${dir}/flake"
|
||||
node=$(jq -r '.nodes[.nodes[.root].inputs["nixos-router"]].inputs.nixpkgs' ${dir}/flake/flake.lock)
|
||||
hash=$(jq -er --arg n "$node" '.nodes[$n].locked.narHash' ${dir}/flake/flake.lock)
|
||||
case $ref in
|
||||
*narHash=*) pin=$ref ;;
|
||||
*\?*) pin="$ref&narHash=$hash" ;;
|
||||
*) pin="$ref?narHash=$hash" ;;
|
||||
esac
|
||||
|
||||
attr="path:${dir}/flake#nixosConfigurations.router.config.system"
|
||||
echo "router-upgrade: evaluating the new system"
|
||||
new_version=$(nix eval --raw "$attr.nixos.version")
|
||||
# The release (26.05) of a version like 26.05.20260101.abcdef0 or 26.11pre-git.
|
||||
release() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"$1"; }
|
||||
newer=$(printf '%s\n%s\n' "$(release "$new_version")" "$(release "$running_version")" | sort -V | tail -1)
|
||||
if [ "$newer" != "$(release "$new_version")" ]; then
|
||||
[ "$downgrade" -eq 1 ] \
|
||||
|| die "$new_version is older than the running $running_version: NixOS cannot be downgraded safely (use --downgrade to try anyway)"
|
||||
echo "router-upgrade: warning: $new_version is older than the running $running_version; state written by the newer release may not work with it"
|
||||
fi
|
||||
drv=$(nix eval --raw "$attr.build.toplevel.drvPath")
|
||||
|
||||
nix path-info -r /run/current-system | names > ${dir}/old.names
|
||||
nix path-info -r "$drv" | names > ${dir}/new.names
|
||||
changes ${dir}/old.names ${dir}/new.names > ${dir}/changes.json
|
||||
rm -f ${dir}/old.names ${dir}/new.names
|
||||
if [ "$(jq length ${dir}/changes.json)" -gt 0 ]; then
|
||||
jq -r '.[] | " \(.name): \(.from) -> \(.to)"' ${dir}/changes.json
|
||||
else
|
||||
echo " no package changes"
|
||||
fi
|
||||
if jq -e 'any(.[]; .name == "linux")' ${dir}/changes.json > /dev/null; then reboot=true; else reboot=false; fi
|
||||
|
||||
# Nothing is fetched: the substituters only say what they have.
|
||||
dry=$(nix build --dry-run --no-link "$attr.build.toplevel" 2>&1 || true)
|
||||
download=$(grep -o '([0-9.]* [KMGT]iB download, [0-9.]* [KMGT]iB unpacked)' <<<"$dry" | head -1 || true)
|
||||
builds=$(grep -cE '^ +/nix/store/[^ ]*\.drv$' <<<"$dry" || true)
|
||||
echo " to fetch: ''${download:-nothing, or no cache reachable}; to build here: $builds"
|
||||
|
||||
jq -n --arg channel "$channel" --arg rev "$newest" --arg ref "$pin" --arg version "$new_version" \
|
||||
--arg time "$(date -u +%FT%TZ)" --arg download "$download" --argjson builds "$builds" \
|
||||
--argjson reboot "$reboot" --slurpfile changes ${dir}/changes.json \
|
||||
'{channel: $channel, rev: $rev, ref: $ref, version: $version, time: $time, download: $download,
|
||||
builds: $builds, rebootNeeded: $reboot, changes: $changes[0]}' > ${dir}/preview.json
|
||||
echo "$new_version$([ "$reboot" = false ] || echo ' (a new kernel runs after a reboot)'): router-upgrade apply activates it"
|
||||
}
|
||||
|
||||
download() {
|
||||
[ -r ${dir}/preview.json ] || die "nothing is previewed (router-upgrade preview)"
|
||||
free=$(df --output=avail -B1M /nix/store | tail -1)
|
||||
[ "$free" -ge 3072 ] || die "less than 3 GiB free on /nix/store (''${free} MiB): free some space first"
|
||||
echo "router-upgrade: fetching and building (nothing is activated)"
|
||||
nix build --no-link --out-link ${dir}/result \
|
||||
"path:${dir}/flake#nixosConfigurations.router.config.system.build.toplevel"
|
||||
echo "downloaded $(jq -r .version ${dir}/preview.json); router-upgrade apply activates it"
|
||||
}
|
||||
|
||||
apply() {
|
||||
local timeout=''${1:-}
|
||||
[ -r ${dir}/preview.json ] || die "nothing is previewed (router-upgrade preview)"
|
||||
exec 9>/run/router-update.lock
|
||||
flock -n 9 || die "an update or upgrade is running"
|
||||
[ ! -d ${snapshot} ] || die "an earlier update or upgrade is not confirmed yet"
|
||||
cmp -s "$config/flake.nix" ${dir}/flake/flake.nix \
|
||||
|| die "the configuration flake changed since it was previewed: preview again"
|
||||
cp -a "$config" ${snapshot}
|
||||
fail() {
|
||||
echo "router-upgrade: $*" >&2
|
||||
/run/current-system/sw/bin/router-update-restore
|
||||
exit 1
|
||||
}
|
||||
cp ${dir}/flake/flake.lock "$config/flake.lock"
|
||||
jq '{channel, rev, ref}' ${dir}/preview.json > "$config/nixpkgs-pin.json"
|
||||
|
||||
echo "router-upgrade: building and activating $(jq -r .version ${dir}/preview.json)"
|
||||
if ! router-apply ''${timeout:+"$timeout"}; then
|
||||
if systemctl is-active --quiet router-rollback.timer; then
|
||||
# Activation failed half way: the rollback restores the configuration.
|
||||
systemctl start router-rollback.service
|
||||
die "the upgrade failed; the previous system is back"
|
||||
fi
|
||||
fail "the upgrade failed; the previous system is back"
|
||||
fi
|
||||
rm -rf ${dir}/preview.json ${dir}/flake ${dir}/result
|
||||
if systemctl is-active --quiet router-rollback.timer; then
|
||||
echo "router-upgrade: active; confirm it (router-confirm, or in the web UI) or the previous system comes back"
|
||||
else
|
||||
rm -rf ${snapshot} # nothing changed: nothing to confirm
|
||||
fi
|
||||
}
|
||||
|
||||
case $cmd in
|
||||
check) check ;;
|
||||
preview) preview "$@" ;;
|
||||
download) download ;;
|
||||
apply) apply "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.routerSettings.upgrade = {
|
||||
channelsUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "https://channels.nixos.org";
|
||||
description = "Where the newest revision of a nixpkgs branch is read (`<branch>/git-revision`).";
|
||||
};
|
||||
nixpkgsRef = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "github:NixOS/nixpkgs/%rev%";
|
||||
description = "The flake reference of a nixpkgs revision (`%rev%` stands for it).";
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.systemPackages = [ upgrade ];
|
||||
|
||||
# The web UI starts this rather than running anything itself, so a switch
|
||||
# that restarts the UI does not cut an upgrade short; a switch must not
|
||||
# restart or stop it either (router-apply is what runs the switch).
|
||||
systemd.services.router-upgrade = {
|
||||
description = "Look for, prepare or apply a nixpkgs upgrade";
|
||||
restartIfChanged = false;
|
||||
stopIfChanged = false;
|
||||
path = [ "/run/current-system/sw" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${lib.getExe upgrade} --request";
|
||||
TimeoutStartSec = "2h";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# router-migrate-settings RELEASE FILE: bring a settings.json up to the
|
||||
# schema of a release by running its migrations/N.jq in order.
|
||||
{
|
||||
writeShellApplication,
|
||||
coreutils,
|
||||
jq,
|
||||
}:
|
||||
writeShellApplication {
|
||||
name = "router-migrate-settings";
|
||||
runtimeInputs = [
|
||||
coreutils
|
||||
jq
|
||||
];
|
||||
text = ''
|
||||
[ $# -eq 2 ] || { echo "usage: router-migrate-settings RELEASE SETTINGS.json" >&2; exit 2; }
|
||||
release=$1 file=$2
|
||||
schema=$(cat "$release/schema")
|
||||
version=$(jq -r '.version // 0' "$file")
|
||||
if [ "$version" -gt "$schema" ]; then
|
||||
echo "the settings are at version $version, newer than this release ($schema)" >&2
|
||||
exit 1
|
||||
fi
|
||||
while [ "$version" -lt "$schema" ]; do
|
||||
next=$((version + 1))
|
||||
echo "migrating the settings to version $next"
|
||||
jq -f "$release/migrations/$next.jq" "$file" | jq --argjson v "$next" '.version = $v' > "$file.new"
|
||||
chmod 0600 "$file.new"
|
||||
mv "$file.new" "$file"
|
||||
version=$next
|
||||
done
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A REST API (described by OpenAPI at /api/openapi.json) with the same
|
||||
// features, tokens, scopes, limits and audit log as the MCP server: every
|
||||
// operation runs one of the MCP tools (mcpTools), so the two cannot drift.
|
||||
// The routes below say how a request becomes a tool's arguments.
|
||||
|
||||
type apiRoute struct {
|
||||
method, path, tool string
|
||||
summary string
|
||||
description string // instead of the tool's, where that names MCP tools
|
||||
}
|
||||
|
||||
var apiRoutes = []apiRoute{
|
||||
{"GET", "/api/v1/status", "get_status", "State now: uplink, pings, system, whether settings are unapplied", ""},
|
||||
{"GET", "/api/v1/settings", "get_settings", "The saved settings and their ETag",
|
||||
"The saved settings (`system` and `router`) and whether they differ from the confirmed (running) system. The response carries the settings' ETag, which an edit must send back in `If-Match`. Secrets are not in the settings, only paths of files that hold them."},
|
||||
{"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)",
|
||||
"Edit the saved settings with a JSON merge patch (RFC 7386): objects merge, a value replaces, `null` removes a key, arrays are replaced whole. Send the ETag from the last read in `If-Match`; if the settings changed since, nothing is written and the answer is 409. The settings are saved but not applied: a person applies them in the web UI. Not allowed: ssh keys, the schema version, the state version, and file paths outside the router's state directory. Call `GET /api/v1/settings/check` afterwards."},
|
||||
{"GET", "/api/v1/settings/check", "check_settings", "Evaluate the saved settings without applying them", ""},
|
||||
{"GET", "/api/v1/changes", "get_pending_changes", "The difference between the confirmed and the saved settings", ""},
|
||||
{"DELETE", "/api/v1/changes", "discard_changes", "Throw the unapplied changes away", ""},
|
||||
{"GET", "/api/v1/interfaces", "list_interfaces", "Network cards and VLAN devices", ""},
|
||||
{"GET", "/api/v1/dhcp-leases", "list_dhcp_leases", "DHCP clients holding a lease", ""},
|
||||
{"GET", "/api/v1/update", "get_update_status", "The installed release and the last update run", ""},
|
||||
{"GET", "/api/v1/apply-log", "get_apply_log", "The log and result of the last apply", ""},
|
||||
{"GET", "/api/v1/logs", "get_logs", "Lines of the system journal", ""},
|
||||
}
|
||||
|
||||
// etagHeader documents the ETag header of the answers about the settings.
|
||||
var etagHeader = map[string]any{"ETag": map[string]any{"description": "The settings' ETag (in quotes): send it in `If-Match` to edit them.", "schema": map[string]any{"type": "string"}}}
|
||||
|
||||
// resultContent is the 200 response's content: the result's type as JSON, or text.
|
||||
func resultContent(b *schemaBuilder, tool *mcpTool) map[string]any {
|
||||
if tool.output == nil {
|
||||
return map[string]any{"text/plain": map[string]any{"schema": map[string]any{"type": "string"}}}
|
||||
}
|
||||
return map[string]any{"application/json": map[string]any{"schema": b.of(reflect.TypeOf(tool.output))}}
|
||||
}
|
||||
|
||||
func routeText(rt apiRoute, tool *mcpTool) string {
|
||||
if rt.description != "" {
|
||||
return rt.description
|
||||
}
|
||||
return tool.description
|
||||
}
|
||||
|
||||
func toolNamed(name string) *mcpTool {
|
||||
for i := range mcpTools {
|
||||
if mcpTools[i].name == name {
|
||||
return &mcpTools[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (a *app) registerAPI(mux *http.ServeMux) {
|
||||
for _, rt := range apiRoutes {
|
||||
mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt))
|
||||
}
|
||||
a.registerAreas(mux)
|
||||
mux.HandleFunc("GET /api/v1/options", a.apiOptions)
|
||||
mux.HandleFunc("GET /api/openapi.json", a.apiSpec)
|
||||
}
|
||||
|
||||
func (a *app) apiHandler(rt apiRoute) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
tok, ok := a.bearer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tool := toolNamed(rt.tool)
|
||||
if !tok.allows(tool.scope) {
|
||||
apiError(w, http.StatusForbidden, "this token may not "+tool.scope+" (scope)")
|
||||
return
|
||||
}
|
||||
args, err := apiArgs(w, r, tool)
|
||||
if err != nil {
|
||||
apiError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := a.runTool(tok, tool, args)
|
||||
if err != nil {
|
||||
var bad argError
|
||||
switch {
|
||||
case errors.Is(err, errStale):
|
||||
apiError(w, http.StatusConflict, err.Error())
|
||||
case errors.As(err, &bad) || tool.scope == scopeChange:
|
||||
apiError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
apiError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if text, ok := res.(string); ok {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(w, text+"\n")
|
||||
return
|
||||
}
|
||||
switch r := res.(type) {
|
||||
case SettingsResponse:
|
||||
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
||||
case EditResponse:
|
||||
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(res)
|
||||
}
|
||||
}
|
||||
|
||||
// apiArgs builds a tool's arguments from the request: the query for GET
|
||||
// (typed by the tool's schema), If-Match and the body for the settings patch.
|
||||
func apiArgs(w http.ResponseWriter, r *http.Request, tool *mcpTool) (json.RawMessage, error) {
|
||||
switch r.Method {
|
||||
case http.MethodPatch:
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, errors.New("request too large")
|
||||
}
|
||||
var patch any
|
||||
if err := json.Unmarshal(body, &patch); err != nil {
|
||||
return nil, errors.New("the body must be a JSON merge patch")
|
||||
}
|
||||
etag := strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`)
|
||||
if etag == "" {
|
||||
return nil, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)")
|
||||
}
|
||||
return json.Marshal(map[string]any{"etag": etag, "patch": patch})
|
||||
case http.MethodGet:
|
||||
props, _ := tool.schema["properties"].(map[string]any)
|
||||
args := map[string]any{}
|
||||
for name, p := range props {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if typ, _ := p.(map[string]any)["type"].(string); typ == "integer" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, errors.New(name + " must be a whole number")
|
||||
}
|
||||
args[name] = n
|
||||
} else {
|
||||
args[name] = v
|
||||
}
|
||||
}
|
||||
return json.Marshal(args)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *app) apiOptions(w http.ResponseWriter, r *http.Request) {
|
||||
tok, ok := a.bearer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = tok
|
||||
data, err := os.ReadFile(a.optionsFile)
|
||||
if a.optionsFile == "" || err != nil {
|
||||
apiError(w, http.StatusNotFound, "no option documentation on this router")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// apiSpec serves the OpenAPI description. It describes the interface only (no
|
||||
// router data), so it needs no token.
|
||||
func (a *app) apiSpec(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(openAPISpec(a.sys.installedRelease().Version, a.settingsSchema(), r.Host))
|
||||
}
|
||||
|
||||
func openAPISpec(version string, schema map[string]any, host string) map[string]any {
|
||||
errResp := func(d string) map[string]any {
|
||||
return map[string]any{"description": d, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}}
|
||||
}
|
||||
b := newSchemaBuilder()
|
||||
paths := map[string]any{}
|
||||
for _, rt := range apiRoutes {
|
||||
tool := toolNamed(rt.tool)
|
||||
op := map[string]any{
|
||||
"operationId": rt.tool,
|
||||
"summary": rt.summary,
|
||||
"description": routeText(rt, tool),
|
||||
"tags": []string{tool.scope},
|
||||
"security": []any{map[string]any{"bearer": []string{}}},
|
||||
"x-scope": tool.scope,
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "The result.", "content": resultContent(b, tool)},
|
||||
"401": errResp("No or an unknown token."),
|
||||
"403": errResp("The token's scope does not allow this."),
|
||||
},
|
||||
}
|
||||
var params []any
|
||||
switch rt.method {
|
||||
case "GET":
|
||||
props, _ := tool.schema["properties"].(map[string]any)
|
||||
required, _ := tool.schema["required"].([]string)
|
||||
for name, p := range props {
|
||||
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" {
|
||||
op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.")
|
||||
}
|
||||
case "PATCH":
|
||||
props := tool.schema["properties"].(map[string]any)
|
||||
params = append(params, map[string]any{"name": "If-Match", "in": "header", "required": true,
|
||||
"description": props["etag"].(map[string]any)["description"], "schema": map[string]any{"type": "string"}})
|
||||
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{
|
||||
"application/merge-patch+json": map[string]any{"schema": props["patch"]},
|
||||
"application/json": map[string]any{"schema": props["patch"]},
|
||||
}}
|
||||
op["responses"].(map[string]any)["400"] = errResp("The patch is refused (see the message).")
|
||||
op["responses"].(map[string]any)["409"] = errResp("The settings changed since the ETag was read.")
|
||||
}
|
||||
if params != nil {
|
||||
op["parameters"] = params
|
||||
}
|
||||
if rt.tool == "get_settings" || rt.tool == "update_settings" {
|
||||
op["responses"].(map[string]any)["200"].(map[string]any)["headers"] = etagHeader
|
||||
}
|
||||
entry, _ := paths[rt.path].(map[string]any)
|
||||
if entry == nil {
|
||||
entry = map[string]any{}
|
||||
paths[rt.path] = entry
|
||||
}
|
||||
entry[strings.ToLower(rt.method)] = op
|
||||
}
|
||||
paths["/api/v1/options"] = map[string]any{"get": map[string]any{
|
||||
"operationId": "get_options", "summary": "Every router option: type, default, description", "tags": []string{scopeRead},
|
||||
"security": []any{map[string]any{"bearer": []string{}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "The options (JSON)", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{}}}}, "404": errResp("Not available.")},
|
||||
}}
|
||||
if schema != nil {
|
||||
for p, ops := range areaPaths(schema, b.of(reflect.TypeOf(EditResponse{}))) {
|
||||
paths[p] = ops
|
||||
}
|
||||
}
|
||||
addCurlSamples(paths, "https://"+host)
|
||||
if schema != nil { // the settings in the answers are the typed settings
|
||||
if sr, ok := b.defs["SettingsResponse"].(map[string]any); ok {
|
||||
sr["properties"].(map[string]any)["settings"] = schema
|
||||
}
|
||||
}
|
||||
schemas := map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}}
|
||||
for k, v := range b.defs {
|
||||
schemas[k] = v
|
||||
}
|
||||
return map[string]any{
|
||||
"openapi": "3.1.0",
|
||||
"info": map[string]any{
|
||||
"title": "nixos-router",
|
||||
"version": version,
|
||||
"description": apiIntro,
|
||||
},
|
||||
"tags": []any{
|
||||
map[string]any{"name": scopeRead, "x-displayName": "Read", "description": "Allowed for every token."},
|
||||
map[string]any{"name": scopeChange, "x-displayName": "Change", "description": "Needs a token with the access *change*."},
|
||||
map[string]any{"name": "settings", "x-displayName": "Settings areas", "description": settingsAreasText},
|
||||
},
|
||||
"servers": []any{map[string]any{"url": "/"}},
|
||||
"paths": paths,
|
||||
"components": map[string]any{
|
||||
"securitySchemes": map[string]any{"bearer": map[string]any{"type": "http", "scheme": "bearer", "description": "A token from the API access page (rtr_…)."}},
|
||||
"schemas": schemas,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rest sends a request to the REST API with a token.
|
||||
func rest(h http.Handler, token, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRESTAuthAndScopes(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
if rec := rest(h, "", "GET", "/api/v1/status", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no token: %d", rec.Code)
|
||||
}
|
||||
_, read, _ := a.tokens.Create("reader", "read")
|
||||
_, change, _ := a.tokens.Create("changer", "change")
|
||||
if rec := rest(h, read, "GET", "/api/v1/status", "", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"uplink"`) {
|
||||
t.Fatalf("status: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, read, "DELETE", "/api/v1/changes", "", nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("a read token discards: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, read, "PATCH", "/api/v1/settings", `{}`, map[string]string{"If-Match": "x"}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("a read token patches: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, change, "DELETE", "/api/v1/changes", "", nil); rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("discard: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, read, "POST", "/api/v1/status", "", nil); rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST: %d", rec.Code)
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/v1/status", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+read)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign origin: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTSettings(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
|
||||
os.WriteFile(sys.confirmedPath(), data, 0o600)
|
||||
}
|
||||
h := a.routes()
|
||||
_, tok, _ := a.tokens.Create("changer", "change")
|
||||
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("get: %d", rec.Code)
|
||||
}
|
||||
etag := rec.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("no ETag header")
|
||||
}
|
||||
patch := `{"system":{"hostName":"gw"}}`
|
||||
if rec := rest(h, tok, "PATCH", "/api/v1/settings", patch, nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("without If-Match: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, tok, "PATCH", "/api/v1/settings", patch, map[string]string{"If-Match": `"0000"`}); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("stale ETag: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, tok, "PATCH", "/api/v1/settings", `{"system":{"sshKeys":[]}}`, map[string]string{"If-Match": etag}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("refused patch: %d", rec.Code)
|
||||
}
|
||||
rec = rest(h, tok, "PATCH", "/api/v1/settings", patch, map[string]string{"If-Match": etag})
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"saved": true`) || rec.Header().Get("ETag") == etag {
|
||||
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = rest(h, tok, "GET", "/api/v1/changes", "", nil)
|
||||
if !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") || !strings.Contains(rec.Body.String(), "gw") {
|
||||
t.Fatalf("changes: %s %s", rec.Header().Get("Content-Type"), rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogs(t *testing.T) {
|
||||
one := 0
|
||||
cases := []struct {
|
||||
q logQuery
|
||||
want []string
|
||||
bad bool
|
||||
}{
|
||||
{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},
|
||||
[]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: "--vacuum-time=1s"}, nil, true},
|
||||
{logQuery{Unit: "a b"}, nil, true},
|
||||
{logQuery{Unit: "router-webui", Lines: 5000}, nil, true},
|
||||
{logQuery{Unit: "router-webui", Priority: "loud"}, nil, true},
|
||||
{logQuery{Unit: "router-webui", Since: "$(reboot)"}, nil, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
args, err := journalArgs(c.q)
|
||||
if (err != nil) != c.bad {
|
||||
t.Fatalf("%+v: error %v", c.q, err)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !slices.Contains(args, w) {
|
||||
t.Errorf("%+v: %q missing from %q", c.q, w, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
var got []string
|
||||
runJournal = func(name string, args ...string) (string, error) {
|
||||
got = append([]string{name}, args...)
|
||||
return "2026-10-31T22:16:08+0000 router pppd[1]: Connected", nil
|
||||
}
|
||||
t.Cleanup(func() { runJournal = command })
|
||||
_, tok, _ := a.tokens.Create("reader", "read")
|
||||
rec := rest(h, tok, "GET", "/api/v1/logs?unit=router-pppoe&lines=3&boot=-1", "", nil)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Connected") || !slices.Contains(got, "--boot=-1") || !slices.Contains(got, "--lines=3") {
|
||||
t.Fatalf("logs: %d %s %q", rec.Code, rec.Body, got)
|
||||
}
|
||||
if rec := rest(h, tok, "GET", "/api/v1/logs?lines=abc", "", nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad number: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, tok, "GET", "/api/v1/logs?unit=-x", "", nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad unit: %d", rec.Code)
|
||||
}
|
||||
text, failed := call(t, h, tok, "get_logs", map[string]any{"unit": "router-webui", "grep": "x"})
|
||||
if failed || !strings.Contains(text, "Connected") || !slices.Contains(got, "--unit=router-webui") {
|
||||
t.Fatalf("mcp get_logs: %v %q", failed, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPI(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
rec := rest(h, "", "GET", "/api/openapi.json", "", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("spec: %d", rec.Code)
|
||||
}
|
||||
var spec struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]map[string]map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil || spec.OpenAPI != "3.1.0" {
|
||||
t.Fatalf("spec: %v %s", err, spec.OpenAPI)
|
||||
}
|
||||
// Every tool has an operation (the two interfaces stay equal).
|
||||
ops := map[string]bool{}
|
||||
for _, methods := range spec.Paths {
|
||||
for _, op := range methods {
|
||||
ops[op["operationId"].(string)] = true
|
||||
}
|
||||
}
|
||||
for _, tool := range mcpTools {
|
||||
if !ops[tool.name] {
|
||||
t.Errorf("tool %s has no REST operation", tool.name)
|
||||
}
|
||||
}
|
||||
for _, rt := range apiRoutes {
|
||||
if spec.Paths[rt.path][strings.ToLower(rt.method)] == nil {
|
||||
t.Errorf("%s %s is not in the spec", rt.method, rt.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIReference(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
if rec := do(h, "GET", "/apireference", nil, nil); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("without a session: %d", rec.Code)
|
||||
}
|
||||
cookie := login(t, h)
|
||||
rec := do(h, "GET", "/apireference", nil, cookie)
|
||||
body := rec.Body.String()
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("reference: %d", rec.Code)
|
||||
}
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
m := regexp.MustCompile(`data-nonce="([^"]+)"`).FindStringSubmatch(body)
|
||||
if m == nil || !strings.Contains(csp, "'nonce-"+m[1]+"'") {
|
||||
t.Errorf("the page's nonce is not in its CSP: %q", csp)
|
||||
}
|
||||
if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") {
|
||||
t.Errorf("CSP too loose: %q", csp)
|
||||
}
|
||||
if !strings.Contains(body, `data-spec="/api/openapi.json"`) || !strings.Contains(body, "/static/redoc.standalone.js") {
|
||||
t.Errorf("the page does not load Redoc with the spec:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecExplainsETags(t *testing.T) {
|
||||
spec := openAPISpec("1.2.3", nil, "router.example")
|
||||
info := spec["info"].(map[string]any)["description"].(string)
|
||||
for _, want := range []string{"ETag", "If-Match", "409"} {
|
||||
if !strings.Contains(info, want) {
|
||||
t.Errorf("%q is not explained in the spec's description", want)
|
||||
}
|
||||
}
|
||||
patch := spec["paths"].(map[string]any)["/api/v1/settings"].(map[string]any)["patch"].(map[string]any)
|
||||
if strings.Contains(patch["description"].(string), "update_settings") || strings.Contains(patch["description"].(string), "check_settings") {
|
||||
t.Errorf("REST description names an MCP tool: %v", patch["description"])
|
||||
}
|
||||
src := patch["x-codeSamples"].([]any)[0].(map[string]any)["source"].(string)
|
||||
if !strings.Contains(src, "https://router.example/api/v1/settings") || !strings.Contains(src, "If-Match") {
|
||||
t.Errorf("curl sample: %s", src)
|
||||
}
|
||||
get := spec["paths"].(map[string]any)["/api/v1/settings"].(map[string]any)["get"].(map[string]any)
|
||||
if _, ok := get["responses"].(map[string]any)["200"].(map[string]any)["headers"].(map[string]any)["ETag"]; !ok {
|
||||
t.Errorf("GET settings does not document its ETag header")
|
||||
}
|
||||
}
|
||||
|
||||
// strs reads a string or a list of strings of a decoded schema.
|
||||
func strs(v any) []string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return []string{x}
|
||||
case []any:
|
||||
var out []string
|
||||
for _, e := range x {
|
||||
out = append(out, fmt.Sprint(e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// conforms checks a decoded JSON value against a schema of the OpenAPI
|
||||
// document (the subset typeschema.go emits), resolving $ref in spec.
|
||||
func conforms(v any, s map[string]any, spec map[string]any, path string) []string {
|
||||
if ref, ok := s["$ref"].(string); ok {
|
||||
name := strings.TrimPrefix(ref, "#/components/schemas/")
|
||||
def, _ := spec["components"].(map[string]any)["schemas"].(map[string]any)[name].(map[string]any)
|
||||
if def == nil {
|
||||
return []string{path + ": unknown schema " + name}
|
||||
}
|
||||
return conforms(v, def, spec, path)
|
||||
}
|
||||
if branches, ok := s["anyOf"].([]any); ok {
|
||||
for _, b := range branches {
|
||||
if len(conforms(v, b.(map[string]any), spec, path)) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return []string{path + ": matches none of the alternatives"}
|
||||
}
|
||||
types := strs(s["type"])
|
||||
if len(types) == 0 {
|
||||
return nil // any
|
||||
}
|
||||
kind := "null"
|
||||
switch v.(type) {
|
||||
case bool:
|
||||
kind = "boolean"
|
||||
case string:
|
||||
kind = "string"
|
||||
case float64:
|
||||
kind = "number"
|
||||
case []any:
|
||||
kind = "array"
|
||||
case map[string]any:
|
||||
kind = "object"
|
||||
}
|
||||
if f, ok := v.(float64); ok && f == float64(int64(f)) && slices.Contains(types, "integer") {
|
||||
kind = "integer"
|
||||
}
|
||||
if !slices.Contains(types, kind) {
|
||||
return []string{path + ": is " + kind + ", the schema says " + strings.Join(types, " or ")}
|
||||
}
|
||||
var errs []string
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
if s["format"] == "date-time" {
|
||||
if _, err := time.Parse(time.RFC3339Nano, x); err != nil {
|
||||
errs = append(errs, path+": not a date-time: "+x)
|
||||
}
|
||||
}
|
||||
if enum := strs(s["enum"]); enum != nil && !slices.Contains(enum, x) {
|
||||
errs = append(errs, path+": "+x+" is not in the enum")
|
||||
}
|
||||
case []any:
|
||||
items, _ := s["items"].(map[string]any)
|
||||
for i, e := range x {
|
||||
errs = append(errs, conforms(e, items, spec, fmt.Sprintf("%s[%d]", path, i))...)
|
||||
}
|
||||
case map[string]any:
|
||||
props, _ := s["properties"].(map[string]any)
|
||||
{
|
||||
for _, k := range strs(s["required"]) {
|
||||
if _, ok := x[k]; !ok {
|
||||
errs = append(errs, path+": "+k+" is missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, c := range x {
|
||||
if p, ok := props[k].(map[string]any); ok {
|
||||
errs = append(errs, conforms(c, p, spec, path+"/"+k)...)
|
||||
} else if add, ok := s["additionalProperties"].(map[string]any); ok {
|
||||
errs = append(errs, conforms(c, add, spec, path+"/"+k)...)
|
||||
} else if props != nil {
|
||||
errs = append(errs, path+": "+k+" is not in the schema")
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// What the API answers is what the OpenAPI document says it answers.
|
||||
func TestResponsesMatchTheirSchemas(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
|
||||
os.WriteFile(sys.confirmedPath(), data, 0o600)
|
||||
}
|
||||
h := a.routes()
|
||||
_, tok, _ := a.tokens.Create("changer", "change")
|
||||
// Through JSON, as a client sees the document.
|
||||
var spec map[string]any
|
||||
data, _ := json.Marshal(openAPISpec("1.2.3", nil, "router.example"))
|
||||
json.Unmarshal(data, &spec)
|
||||
check := func(method, path, body string, headers map[string]string) {
|
||||
t.Helper()
|
||||
rec := rest(h, tok, method, path, body, headers)
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("%s %s: %d %s", method, path, rec.Code, rec.Body)
|
||||
return
|
||||
}
|
||||
op := spec["paths"].(map[string]any)[path].(map[string]any)[strings.ToLower(method)].(map[string]any)
|
||||
content := op["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
|
||||
js, ok := content["application/json"].(map[string]any)
|
||||
if !ok {
|
||||
if _, text := content["text/plain"]; !text || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
|
||||
t.Errorf("%s %s: documented %v, sent %s", method, path, content, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
return
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
||||
t.Errorf("%s %s: %v", method, path, err)
|
||||
return
|
||||
}
|
||||
if errs := conforms(v, js["schema"].(map[string]any), spec, ""); len(errs) > 0 {
|
||||
t.Errorf("%s %s does not match its schema: %v\n%s", method, path, errs, rec.Body)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"/api/v1/status", "/api/v1/settings", "/api/v1/interfaces", "/api/v1/dhcp-leases", "/api/v1/update", "/api/v1/apply-log"} {
|
||||
check("GET", p, "", nil)
|
||||
}
|
||||
check("GET", "/api/v1/changes", "", nil)
|
||||
check("DELETE", "/api/v1/changes", "", nil)
|
||||
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
|
||||
check("PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": rec.Header().Get("ETag")})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The API reference is Redoc (static/redoc.standalone.js) showing
|
||||
// /api/openapi.json, so what is written below is the text of that document.
|
||||
|
||||
const apiIntro = `The router's management API. It does what the MCP server does: read the router's state and logs, and edit its saved settings.
|
||||
|
||||
# Authentication
|
||||
|
||||
Every call needs a token from the *API access* page of the web UI, sent as ` + "`Authorization: Bearer rtr_…`" + `.
|
||||
A **read** token may use the read operations, a **change** token also the others. Tokens expire (30 days unless
|
||||
chosen otherwise) and can be revoked. This document and the page showing it need no token.
|
||||
|
||||
# Changes are saved, never applied
|
||||
|
||||
An edit only changes the *saved* settings. A person reviews them on the *Changes* page of the web UI and applies
|
||||
them there; until then the router keeps running as it was. ` + "`GET /api/v1/changes`" + ` shows what differs and
|
||||
` + "`DELETE /api/v1/changes`" + ` throws it away. Check an edit before asking a person to apply it with
|
||||
` + "`GET /api/v1/settings/check`" + `.
|
||||
|
||||
# ETags: editing without overwriting someone else
|
||||
|
||||
The settings can be edited by several parties at once: people in the web UI, other tokens, other scripts. An
|
||||
**ETag** is a label for one version of the settings. It changes whenever they change.
|
||||
|
||||
1. Read the settings (or any part of them) with ` + "`GET`" + `. The response has an ` + "`ETag`" + ` header, for example ` + "`\"3f9a1c…\"`" + `.
|
||||
2. Send your edit with ` + "`If-Match: \"3f9a1c…\"`" + `: "apply this only if the settings are still that version".
|
||||
3. If somebody saved a change in between, the ETag no longer matches. Nothing is written and the answer is
|
||||
**409 Conflict**. Read again, redo the edit on the new settings, and retry.
|
||||
4. If it matches, the edit is saved and the response carries the **new ETag** for your next edit.
|
||||
|
||||
Every operation that changes the settings (` + "`PATCH`, `PUT`, `DELETE`" + ` below ` + "`/api/v1/settings`" + `) needs
|
||||
` + "`If-Match`" + `; without it the answer is 400. All parts of the settings share the one ETag.
|
||||
|
||||
# Errors
|
||||
|
||||
Errors are JSON, ` + "`{\"error\": \"…\"}`" + `: **400** refused (the message says why), **401** no, unknown or expired token,
|
||||
**403** the token's access is too low, **404** not set or no such option, **409** stale ETag, **500** the router failed.
|
||||
Results are JSON, except the pending changes and the journal, which are plain text.`
|
||||
|
||||
const settingsAreasText = `The settings are a tree, and every part of it has its own path below ` + "`/api/v1/settings`" + `:
|
||||
` + "`router/firewall`, `router/lans/{name}` (one LAN, by its name), `router/dns/hosts`, `system`" + `, down to a single option
|
||||
like ` + "`router/wan/interface`" + `. The documented paths go three levels deep; deeper ones work the same way. Lists are replaced
|
||||
whole, their elements have no paths of their own.
|
||||
|
||||
- ` + "`GET`" + ` reads a part (404: not set, so its defaults apply),
|
||||
- ` + "`PUT`" + ` replaces it, ` + "`PATCH`" + ` merges into an object, ` + "`DELETE`" + ` removes it,
|
||||
- all three edits need ` + "`If-Match`" + ` (see *ETags* above) and are saved, not applied.
|
||||
|
||||
Every value is checked against the option's type before it is saved; a wrong type, a number out of range or a key
|
||||
that is no option is refused with its place, for example ` + "`/router/dns/domain: expected a string`" + `.
|
||||
` + "`GET /api/v1/options`" + ` lists every option with its type, default and description.`
|
||||
|
||||
// addCurlSamples gives every operation a curl example for this router.
|
||||
func addCurlSamples(paths map[string]any, base string) {
|
||||
for path, v := range paths {
|
||||
ops, _ := v.(map[string]any)
|
||||
for method, o := range ops {
|
||||
op, _ := o.(map[string]any)
|
||||
if op == nil {
|
||||
continue
|
||||
}
|
||||
url := base + strings.NewReplacer("{name}", "NAME").Replace(path)
|
||||
var b strings.Builder
|
||||
b.WriteString("curl -sk")
|
||||
if method != "get" {
|
||||
b.WriteString(" -X " + strings.ToUpper(method))
|
||||
}
|
||||
b.WriteString(` -H "Authorization: Bearer $TOKEN"`)
|
||||
if hasHeaderParam(op, "If-Match") {
|
||||
b.WriteString(` \
|
||||
-H 'If-Match: "ETAG"'`)
|
||||
}
|
||||
if op["requestBody"] != nil {
|
||||
ct := "application/json"
|
||||
if method == "patch" && path == "/api/v1/settings" {
|
||||
ct = "application/merge-patch+json"
|
||||
}
|
||||
fmt.Fprintf(&b, " \\\n -H 'Content-Type: %s'", ct)
|
||||
if method == "patch" && path == "/api/v1/settings" {
|
||||
b.WriteString(` \
|
||||
-d '{"router":{"ddns":{"ipv6":false}}}'`)
|
||||
} else {
|
||||
b.WriteString(" \\\n -d @value.json")
|
||||
}
|
||||
}
|
||||
q := ""
|
||||
if op["operationId"] == "get_logs" {
|
||||
q = "?unit=router-pppoe&lines=50&grep=connect"
|
||||
}
|
||||
fmt.Fprintf(&b, " \\\n '%s%s'", url, q)
|
||||
op["x-codeSamples"] = []any{map[string]any{"lang": "Shell", "label": "curl", "source": b.String()}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasHeaderParam(op map[string]any, name string) bool {
|
||||
ps, _ := op["parameters"].([]any)
|
||||
for _, p := range ps {
|
||||
if m, _ := p.(map[string]any); m != nil && m["in"] == "header" && m["name"] == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// apiReference serves the page that shows the OpenAPI document with Redoc.
|
||||
// Redoc writes its styles from script, so this page (and only it) allows
|
||||
// styles carrying a nonce, and the worker it searches in.
|
||||
func (a *app) apiReference(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
raw := make([]byte, 16)
|
||||
_, _ = rand.Read(raw)
|
||||
nonce := base64.RawStdEncoding.EncodeToString(raw)
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'nonce-"+nonce+"'; worker-src blob:; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'")
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>API reference</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/static/apiref.css"></head>
|
||||
<body><div id="redoc" data-spec="/api/openapi.json" data-nonce="%s"></div>
|
||||
<script src="/static/redoc.standalone.js"></script><script src="/static/apiref.js"></script></body></html>
|
||||
`, nonce)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// What the REST API (and the MCP tools, which are the same code) answer. The
|
||||
// OpenAPI document's response schemas are derived from these types.
|
||||
|
||||
type StatusResponse struct {
|
||||
Time time.Time `json:"time" doc:"When the figures were taken."`
|
||||
Uplink UplinkInfo `json:"uplink"`
|
||||
Pings []PingInfo `json:"pings"`
|
||||
System SystemInfo `json:"system"`
|
||||
Settings SettingsInfo `json:"settings"`
|
||||
}
|
||||
|
||||
type UplinkInfo struct {
|
||||
Device string `json:"device" doc:"The uplink device: pppoe-wan, or the WAN interface."`
|
||||
Type string `json:"type" doc:"How the uplink is configured: dhcp or pppoe."`
|
||||
Connected bool `json:"connected" doc:"The uplink has an IPv4 address and the default route."`
|
||||
Reachable bool `json:"reachable" doc:"At least one public ping target answered."`
|
||||
IPv4 []string `json:"ipv4" doc:"The uplink's IPv4 addresses."`
|
||||
IPv6 []string `json:"ipv6" doc:"The uplink's IPv6 addresses."`
|
||||
Gateway string `json:"gateway" doc:"The default gateway ('' if none)."`
|
||||
Since time.Time `json:"since" doc:"When the uplink last changed state."`
|
||||
}
|
||||
|
||||
type PingInfo struct {
|
||||
Target string `json:"target" doc:"As configured: 'gateway' or an address."`
|
||||
Address string `json:"address" doc:"The address that was pinged."`
|
||||
OK bool `json:"ok" doc:"It answered."`
|
||||
RttMs int64 `json:"rttMs" doc:"Round-trip time in milliseconds."`
|
||||
}
|
||||
|
||||
type SystemInfo struct {
|
||||
UptimeSeconds int `json:"uptimeSeconds"`
|
||||
Load string `json:"load" doc:"Load averages (1, 5, 15 minutes)."`
|
||||
Kernel string `json:"kernel" doc:"The running kernel."`
|
||||
RebootForKernel string `json:"rebootForKernel" doc:"The kernel of the current system when it differs from the running one: a reboot is due ('' if none)."`
|
||||
RebootForConfiguration bool `json:"rebootForConfiguration" doc:"A configuration is staged for the next boot (interface names)."`
|
||||
CPUPercent *float64 `json:"cpuPercent" doc:"null until the second sample."`
|
||||
MemoryUsedBytes uint64 `json:"memoryUsedBytes"`
|
||||
MemoryTotalBytes uint64 `json:"memoryTotalBytes"`
|
||||
TemperatureC *float64 `json:"temperatureC" doc:"The hottest sensor; null if there is none."`
|
||||
DiskUsedBytes uint64 `json:"diskUsedBytes"`
|
||||
DiskTotalBytes uint64 `json:"diskTotalBytes"`
|
||||
}
|
||||
|
||||
type SettingsInfo struct {
|
||||
Unapplied bool `json:"unapplied" doc:"The saved settings differ from the confirmed (running) ones."`
|
||||
Applying bool `json:"applying" doc:"An apply is running."`
|
||||
LastApplyResult string `json:"lastApplyResult" doc:"Result of the last apply: success, exit-code, ... ('' if none)."`
|
||||
ConfirmationDueInSeconds int `json:"confirmationDueInSeconds" doc:"While a change awaits confirmation: the time left before the router rolls back (0 if none)."`
|
||||
}
|
||||
|
||||
type SettingsResponse struct {
|
||||
ETag string `json:"etag" doc:"The settings' version; the same as the ETag header (without quotes)."`
|
||||
Unapplied bool `json:"unapplied" doc:"The saved settings differ from the confirmed (running) ones."`
|
||||
Settings map[string]any `json:"settings" doc:"The saved settings (settings.json)."`
|
||||
}
|
||||
|
||||
type EditResponse struct {
|
||||
Saved bool `json:"saved" doc:"The edit was written to the saved settings."`
|
||||
Applied bool `json:"applied" doc:"Always false: a person applies changes in the web UI."`
|
||||
ETag string `json:"etag" doc:"The settings' new version, for the next edit."`
|
||||
Changes string `json:"changes" doc:"What this edit changed, as diff lines (- removed, + added)."`
|
||||
Next string `json:"next" doc:"What to do next."`
|
||||
}
|
||||
|
||||
type InterfaceInfo struct {
|
||||
Name string `json:"name" doc:"The name from the settings."`
|
||||
Kind string `json:"kind" enum:"physical,VLAN"`
|
||||
UsedAs string `json:"usedAs" doc:"WAN, 'LAN <name>', or '' if unused."`
|
||||
KernelName string `json:"kernelName,omitempty" doc:"Physical: the name the kernel gives the card now."`
|
||||
MAC string `json:"mac,omitempty" doc:"Physical: the card's MAC address, when it is on this machine."`
|
||||
DeviceName string `json:"deviceName,omitempty" doc:"VLAN: the name chosen for its device."`
|
||||
}
|
||||
|
||||
type LeaseInfo struct {
|
||||
Address string `json:"address"`
|
||||
MAC string `json:"mac"`
|
||||
Hostname string `json:"hostname"`
|
||||
LAN string `json:"lan"`
|
||||
Expires time.Time `json:"expires"`
|
||||
}
|
||||
|
||||
type UpdateStatusResponse struct {
|
||||
Installed Release `json:"installed"`
|
||||
Running bool `json:"running" doc:"An update is being installed."`
|
||||
LastResult string `json:"lastResult" doc:"Result of the last update run ('' if none)."`
|
||||
Log string `json:"log" doc:"The last update run's log."`
|
||||
}
|
||||
|
||||
type ApplyLogResponse struct {
|
||||
Applying bool `json:"applying"`
|
||||
Result string `json:"result" doc:"success, exit-code, ... ('' if none or still running)."`
|
||||
Log string `json:"log" doc:"The build and activation log."`
|
||||
}
|
||||
|
||||
type CheckResponse struct {
|
||||
Status string `json:"status" enum:"done,running" doc:"'running': still evaluating, ask again in a few seconds."`
|
||||
OK *bool `json:"ok,omitempty" doc:"The settings evaluate and the router's assertions pass."`
|
||||
ForTheSavedSettings *bool `json:"forTheSavedSettings,omitempty" doc:"The result is for the current saved settings (not older ones)."`
|
||||
CheckedAt *time.Time `json:"checkedAt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty" doc:"Why the settings do not evaluate."`
|
||||
Note string `json:"note,omitempty"`
|
||||
Next string `json:"next,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Area paths: /api/v1/settings/router/firewall, .../router/lans/lan, ... read,
|
||||
// replace (PUT), merge into (PATCH) or remove (DELETE) one part of the
|
||||
// settings. They edit the same saved settings as PATCH /api/v1/settings, with
|
||||
// the same ETag and the same limits (editSettings), and check what is written
|
||||
// against the schema made from the option documentation.
|
||||
|
||||
func (a *app) settingsSchema() map[string]any {
|
||||
a.schemaOnce.Do(func() { a.schemaVal = settingsSchema(a.optionsFile) })
|
||||
return a.schemaVal
|
||||
}
|
||||
|
||||
func (a *app) registerAreas(mux *http.ServeMux) {
|
||||
for _, m := range []string{"GET", "PUT", "PATCH", "DELETE"} {
|
||||
mux.HandleFunc(m+" /api/v1/settings/{path...}", a.areaHandler(m))
|
||||
}
|
||||
}
|
||||
|
||||
// areaSegments splits an area path; only router and system exist.
|
||||
func areaSegments(p string) ([]string, error) {
|
||||
segs := strings.Split(strings.Trim(p, "/"), "/")
|
||||
if segs[0] != "router" && segs[0] != "system" {
|
||||
return nil, errors.New("settings areas are below router/ and system/")
|
||||
}
|
||||
for _, s := range segs {
|
||||
if s == "" || len(s) > 128 || strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
|
||||
return nil, errors.New("bad path")
|
||||
}
|
||||
}
|
||||
return segs, nil
|
||||
}
|
||||
|
||||
func getPath(v any, segs []string) (any, bool) {
|
||||
for _, s := range segs {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if v, ok = m[s]; !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func setPath(root map[string]any, segs []string, val any) error {
|
||||
m := root
|
||||
for i, s := range segs[:len(segs)-1] {
|
||||
next, ok := m[s]
|
||||
if !ok || next == nil {
|
||||
child := map[string]any{}
|
||||
m[s] = child
|
||||
m = child
|
||||
continue
|
||||
}
|
||||
child, ok := next.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s is not an object, so nothing can be set below it", strings.Join(segs[:i+1], "/"))
|
||||
}
|
||||
m = child
|
||||
}
|
||||
m[segs[len(segs)-1]] = val
|
||||
return nil
|
||||
}
|
||||
|
||||
func delPath(root map[string]any, segs []string) {
|
||||
parent, ok := getPath(root, segs[:len(segs)-1])
|
||||
if m, isMap := parent.(map[string]any); ok && isMap {
|
||||
delete(m, segs[len(segs)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) areaHandler(method string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
tok, ok := a.bearer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
scope := scopeChange
|
||||
if method == http.MethodGet {
|
||||
scope = scopeRead
|
||||
}
|
||||
if !tok.allows(scope) {
|
||||
apiError(w, http.StatusForbidden, "this token may not "+scope+" (scope)")
|
||||
return
|
||||
}
|
||||
segs, err := areaSegments(r.PathValue("path"))
|
||||
if err != nil {
|
||||
apiError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
var area map[string]any
|
||||
if schema := a.settingsSchema(); schema != nil {
|
||||
if area, err = resolveSchema(schema, segs); err != nil {
|
||||
apiError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
var body []byte
|
||||
if method == http.MethodPut || method == http.MethodPatch {
|
||||
if body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)); err != nil {
|
||||
apiError(w, http.StatusRequestEntityTooLarge, "request too large")
|
||||
return
|
||||
}
|
||||
}
|
||||
res, status, err := a.area(method, segs, area, body, strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`))
|
||||
entry := AuditEntry{Time: time.Now().UTC(), Token: tok.Name, Tool: method + " /" + strings.Join(segs, "/"), Args: string(body)}
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
a.sys.audit(entry)
|
||||
if err != nil {
|
||||
apiError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
if method != http.MethodGet {
|
||||
if r, ok := res.(EditResponse); ok {
|
||||
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
||||
a.sys.noteTokenEdit(tok.Name, r.ETag)
|
||||
}
|
||||
} else if data, err := os.ReadFile(a.sys.settingsPath()); err == nil {
|
||||
w.Header().Set("ETag", `"`+etag(data)+`"`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(res)
|
||||
}
|
||||
}
|
||||
|
||||
// area does one operation; the status is for an error.
|
||||
func (a *app) area(method string, segs []string, area map[string]any, body []byte, ifMatch string) (any, int, error) {
|
||||
if method == http.MethodGet {
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, err
|
||||
}
|
||||
doc, err := parseSettings(data)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, err
|
||||
}
|
||||
v, ok := getPath(map[string]any(doc), segs)
|
||||
if !ok {
|
||||
return nil, http.StatusNotFound, errors.New("not set in the settings (its defaults apply)")
|
||||
}
|
||||
return v, 0, nil
|
||||
}
|
||||
if ifMatch == "" {
|
||||
return nil, http.StatusBadRequest, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)")
|
||||
}
|
||||
var val any
|
||||
if method != http.MethodDelete {
|
||||
if err := json.Unmarshal(body, &val); err != nil {
|
||||
return nil, http.StatusBadRequest, errors.New("the body must be JSON")
|
||||
}
|
||||
}
|
||||
path := "/" + strings.Join(segs, "/")
|
||||
res, err := a.editSettings(ifMatch, func(next Settings) (Settings, error) {
|
||||
root := map[string]any(next)
|
||||
switch method {
|
||||
case http.MethodDelete:
|
||||
delPath(root, segs)
|
||||
return next, nil
|
||||
case http.MethodPatch:
|
||||
patch, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return nil, argError("a PATCH body is a JSON merge patch (an object); use PUT to set a value")
|
||||
}
|
||||
cur, _ := getPath(root, segs)
|
||||
if cur == nil {
|
||||
cur = map[string]any{}
|
||||
}
|
||||
if _, ok := cur.(map[string]any); !ok {
|
||||
return nil, argError(path + " is not an object; use PUT to replace it")
|
||||
}
|
||||
val = mergePatch(cur, patch)
|
||||
case http.MethodPut:
|
||||
if val == nil {
|
||||
return nil, argError("use DELETE to remove a setting")
|
||||
}
|
||||
}
|
||||
if area != nil {
|
||||
if errs := validate(val, area, path); len(errs) > 0 {
|
||||
if len(errs) > 5 {
|
||||
errs = append(errs[:5], fmt.Sprintf("… and %d more", len(errs)-5))
|
||||
}
|
||||
return nil, argError(strings.Join(errs, "; "))
|
||||
}
|
||||
}
|
||||
return next, setPath(root, segs, val)
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, errStale):
|
||||
return nil, http.StatusConflict, err
|
||||
case err != nil:
|
||||
return nil, http.StatusBadRequest, err
|
||||
}
|
||||
return res, 0, nil
|
||||
}
|
||||
|
||||
// areaOps lists the paths of the areas for the OpenAPI document: the options
|
||||
// to a depth of three (router/lans/{name}); deeper paths work the same.
|
||||
func areaPaths(schema, edited map[string]any) map[string]any {
|
||||
paths := map[string]any{}
|
||||
var emit func(parts []string, s map[string]any)
|
||||
emit = func(parts []string, s map[string]any) {
|
||||
path := "/api/v1/settings/" + strings.Join(parts, "/")
|
||||
node := unwrapNullable(s)
|
||||
id := strings.NewReplacer("/", "_", "{", "", "}", "").Replace(strings.Join(parts, "_"))
|
||||
params := []any{}
|
||||
for _, p := range parts {
|
||||
if p == "{name}" {
|
||||
params = append(params, map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}, "description": "The name of the entry (the key in the map)."})
|
||||
}
|
||||
}
|
||||
withParams := func(extra ...any) []any { return append(append([]any{}, params...), extra...) }
|
||||
errs := func(codes ...string) map[string]any {
|
||||
m := map[string]any{"401": map[string]any{"description": "No or an unknown token."}, "403": map[string]any{"description": "The token's scope does not allow this."}}
|
||||
for _, c := range codes {
|
||||
m[c] = map[string]any{"description": map[string]string{"400": "Refused: the value does not fit the option's type (see the message).", "404": "Not set (its defaults apply), or no such option.", "409": "The settings changed since the ETag was read."}[c], "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}}
|
||||
}
|
||||
return m
|
||||
}
|
||||
ifMatch := map[string]any{"name": "If-Match", "in": "header", "required": true, "schema": map[string]any{"type": "string"}, "description": "The ETag from the last read of the settings or of any part of them (the `ETag` header). The edit is refused with 409 if the settings changed since."}
|
||||
jsonBody := func(sch map[string]any) map[string]any {
|
||||
return map[string]any{"required": true, "content": map[string]any{"application/json": map[string]any{"schema": sch}}}
|
||||
}
|
||||
ok200 := func(desc string, sch map[string]any) map[string]any {
|
||||
r := errs("400", "404", "409")
|
||||
r["200"] = map[string]any{"description": desc, "headers": etagHeader, "content": map[string]any{"application/json": map[string]any{"schema": sch}}}
|
||||
return r
|
||||
}
|
||||
sec := []any{map[string]any{"bearer": []string{}}}
|
||||
ops := map[string]any{
|
||||
"get": map[string]any{"operationId": "get_" + id, "summary": "Read " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
|
||||
"parameters": params, "responses": ok200("The value.", s), "x-scope": scopeRead},
|
||||
"put": map[string]any{"operationId": "put_" + id, "summary": "Replace " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
|
||||
"parameters": withParams(ifMatch), "requestBody": jsonBody(node), "responses": ok200("Saved, not applied: the result has the new ETag and the difference.", edited), "x-scope": scopeChange},
|
||||
"delete": map[string]any{"operationId": "delete_" + id, "summary": "Remove " + strings.Join(parts, "/") + " (its defaults apply again)", "tags": []string{"settings"}, "security": sec,
|
||||
"parameters": withParams(ifMatch), "responses": ok200("Saved, not applied.", edited), "x-scope": scopeChange},
|
||||
}
|
||||
if node["type"] == "object" {
|
||||
ops["patch"] = map[string]any{"operationId": "patch_" + id, "summary": "Merge into " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
|
||||
"parameters": withParams(ifMatch), "requestBody": jsonBody(map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386): objects merge, a value replaces, null removes a key, arrays are replaced whole."}),
|
||||
"responses": ok200("Saved, not applied.", edited), "x-scope": scopeChange}
|
||||
}
|
||||
paths[path] = ops
|
||||
if len(parts) >= 3 {
|
||||
return
|
||||
}
|
||||
props, _ := node["properties"].(map[string]any)
|
||||
keys := make([]string, 0, len(props))
|
||||
for k := range props {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if c, ok := props[k].(map[string]any); ok {
|
||||
emit(append(append([]string{}, parts...), k), c)
|
||||
}
|
||||
}
|
||||
if add, ok := node["additionalProperties"].(map[string]any); ok && node["type"] == "object" {
|
||||
emit(append(append([]string{}, parts...), "{name}"), add)
|
||||
}
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
for _, top := range []string{"router", "system"} {
|
||||
if s, ok := props[top].(map[string]any); ok {
|
||||
emit([]string{top}, s)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A few options in the format of nixosOptionsDoc.
|
||||
const testOptions = `{
|
||||
"router.wan.interface": {"loc": ["router","wan","interface"], "type": "string", "description": "The uplink."},
|
||||
"router.lans": {"loc": ["router","lans"], "type": "attribute set of (submodule)", "description": "LANs.", "default": {"_type":"literalExpression","text":"{ }"}},
|
||||
"router.lans.<name>.interface": {"loc": ["router","lans","<name>","interface"], "type": "string"},
|
||||
"router.lans.<name>.internet": {"loc": ["router","lans","<name>","internet"], "type": "boolean"},
|
||||
"router.lans.<name>.vlan": {"loc": ["router","lans","<name>","vlan"], "type": "null or integer between 1 and 4094 (both inclusive)"},
|
||||
"router.lans.<name>.ipv4.address": {"loc": ["router","lans","<name>","ipv4","address"], "type": "string matching the pattern [0-9.]+"},
|
||||
"router.firewall.rules": {"loc": ["router","firewall","rules"], "type": "list of (submodule)"},
|
||||
"router.firewall.rules.*.from": {"loc": ["router","firewall","rules","*","from"], "type": "string"},
|
||||
"router.firewall.rules.*.to": {"loc": ["router","firewall","rules","*","to"], "type": "string"},
|
||||
"router.firewall.rules.*.protocol": {"loc": ["router","firewall","rules","*","protocol"], "type": "one of \"any\", \"tcp\", \"udp\", \"icmp\""},
|
||||
"router.firewall.rules.*.ports": {"loc": ["router","firewall","rules","*","ports"], "type": "list of (16 bit unsigned integer; between 0 and 65535 (both inclusive) or string matching the pattern [0-9]+-[0-9]+)"},
|
||||
"router.dns.domain": {"loc": ["router","dns","domain"], "type": "null or string"},
|
||||
"router.dns.hosts": {"loc": ["router","dns","hosts"], "type": "attribute set of list of string"}
|
||||
}`
|
||||
|
||||
func areaApp(t *testing.T) (*app, http.Handler, string) {
|
||||
t.Helper()
|
||||
a, sys := newTestApp(t)
|
||||
opts := filepath.Join(t.TempDir(), "options.json")
|
||||
os.WriteFile(opts, []byte(testOptions), 0o600)
|
||||
a.optionsFile = opts
|
||||
data, _ := os.ReadFile(sys.settingsPath())
|
||||
os.WriteFile(sys.confirmedPath(), data, 0o600)
|
||||
_, tok, _ := a.tokens.Create("area", "change")
|
||||
return a, a.routes(), tok
|
||||
}
|
||||
|
||||
func TestParseOptType(t *testing.T) {
|
||||
sub := func() map[string]any { return map[string]any{"type": "object"} }
|
||||
cases := map[string]string{
|
||||
"boolean": `{"type":"boolean"}`,
|
||||
"null or string": `{"anyOf":[{"type":"string"},{"type":"null"}]}`,
|
||||
"list of string": `{"items":{"type":"string"},"type":"array"}`,
|
||||
"attribute set of list of string": `{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object"}`,
|
||||
`one of "dhcp", "pppoe"`: `{"enum":["dhcp","pppoe"]}`,
|
||||
"integer between 8 and 30 (both inclusive)": `{"maximum":30,"minimum":8,"type":"integer"}`,
|
||||
"16 bit unsigned integer; between 0 and 65535 (both inclusive)": `{"maximum":65535,"minimum":0,"type":"integer"}`,
|
||||
"list of (submodule)": `{"items":{"type":"object"},"type":"array"}`,
|
||||
"something nobody has thought of yet": `{}`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, _ := json.Marshal(parseOptType(in, sub))
|
||||
if string(got) != want {
|
||||
t.Errorf("%q: %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
// Alternatives in parentheses.
|
||||
got, _ := json.Marshal(parseOptType("list of (16 bit unsigned integer; between 0 and 65535 (both inclusive) or string matching the pattern [0-9]+-[0-9]+)", sub))
|
||||
if !strings.Contains(string(got), `"anyOf"`) || !strings.Contains(string(got), `[0-9]+-[0-9]+`) {
|
||||
t.Errorf("ports: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
opts := filepath.Join(t.TempDir(), "o.json")
|
||||
os.WriteFile(opts, []byte(testOptions), 0o600)
|
||||
a.optionsFile = opts
|
||||
root := a.settingsSchema()
|
||||
lans, err := resolveSchema(root, []string{"router", "lans"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok := map[string]any{"lan": map[string]any{"interface": "eth1", "internet": true, "vlan": float64(20), "ipv4": map[string]any{"address": "10.0.0.1"}}}
|
||||
if errs := validate(ok, lans, "/router/lans"); len(errs) != 0 {
|
||||
t.Fatalf("valid: %v", errs)
|
||||
}
|
||||
for name, bad := range map[string]map[string]any{
|
||||
"unknown option": {"lan": map[string]any{"interfce": "eth1"}},
|
||||
"wrong type": {"lan": map[string]any{"internet": "yes"}},
|
||||
"range": {"lan": map[string]any{"vlan": float64(5000)}},
|
||||
"not whole": {"lan": map[string]any{"vlan": 1.5}},
|
||||
"pattern": {"lan": map[string]any{"ipv4": map[string]any{"address": "ten"}}},
|
||||
} {
|
||||
if errs := validate(bad, lans, "/router/lans"); len(errs) == 0 {
|
||||
t.Errorf("%s was accepted", name)
|
||||
}
|
||||
}
|
||||
if _, err := resolveSchema(root, []string{"router", "firewall", "rules", "0"}); err == nil {
|
||||
t.Error("list elements are addressable")
|
||||
}
|
||||
if _, err := resolveSchema(root, []string{"router", "nothing"}); err == nil {
|
||||
t.Error("unknown option resolves")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaPaths(t *testing.T) {
|
||||
_, h, tok := areaApp(t)
|
||||
if rec := rest(h, tok, "GET", "/api/v1/settings/router/lans/main", "", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"interface"`) {
|
||||
t.Fatalf("get a LAN: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
etag := rest(h, tok, "GET", "/api/v1/settings/router/lans", "", nil).Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("no ETag")
|
||||
}
|
||||
for path, want := range map[string]int{
|
||||
"/api/v1/settings/router/nothing": 404,
|
||||
"/api/v1/settings/other": 404,
|
||||
"/api/v1/settings/router/dns": 404, // not in the sample: defaults apply
|
||||
"/api/v1/settings/router/firewall/rules/0": 404,
|
||||
} {
|
||||
if rec := rest(h, tok, "GET", path, "", nil); rec.Code != want {
|
||||
t.Errorf("GET %s: %d, want %d", path, rec.Code, want)
|
||||
}
|
||||
}
|
||||
hm := map[string]string{"If-Match": etag}
|
||||
// Typos and wrong types are refused with the place.
|
||||
rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": 5}`, hm)
|
||||
if rec.Code != 400 || !strings.Contains(rec.Body.String(), "/router/dns/domain") {
|
||||
t.Fatalf("wrong type: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domian": "x"}`, hm); rec.Code != 400 || !strings.Contains(rec.Body.String(), "unknown option") {
|
||||
t.Fatalf("typo: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test"}`, nil); rec.Code != 400 {
|
||||
t.Fatalf("without If-Match: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test"}`, map[string]string{"If-Match": "0000"}); rec.Code != 409 {
|
||||
t.Fatalf("stale: %d", rec.Code)
|
||||
}
|
||||
rec = rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test", "hosts": {"nas": ["192.168.10.9"]}}`, hm)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"saved": true`) {
|
||||
t.Fatalf("put: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
etag = rec.Header().Get("ETag")
|
||||
hm = map[string]string{"If-Match": etag}
|
||||
if rec := rest(h, tok, "GET", "/api/v1/settings/router/dns/hosts/nas", "", nil); !strings.Contains(rec.Body.String(), "192.168.10.9") {
|
||||
t.Fatalf("read back: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
// PATCH merges: the other host stays.
|
||||
rec = rest(h, tok, "PATCH", "/api/v1/settings/router/dns/hosts", `{"printer": ["192.168.10.8"]}`, hm)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
hm = map[string]string{"If-Match": rec.Header().Get("ETag")}
|
||||
body := rest(h, tok, "GET", "/api/v1/settings/router/dns", "", nil).Body.String()
|
||||
if !strings.Contains(body, "nas") || !strings.Contains(body, "printer") || !strings.Contains(body, "home.test") {
|
||||
t.Fatalf("after patch: %s", body)
|
||||
}
|
||||
// What the API may not change stays out of reach.
|
||||
if rec := rest(h, tok, "PUT", "/api/v1/settings/system/sshKeys", `[]`, hm); rec.Code != 400 {
|
||||
t.Fatalf("ssh keys: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = rest(h, tok, "PUT", "/api/v1/settings/router/wan", `{"interface":"x"}`, hm)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("wan: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
hm = map[string]string{"If-Match": rec.Header().Get("ETag")}
|
||||
// DELETE removes; the diff and the edit note work like for the whole document.
|
||||
rec = rest(h, tok, "DELETE", "/api/v1/settings/router/dns", "", hm)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("delete: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, tok, "GET", "/api/v1/settings/router/dns", "", nil); rec.Code != 404 {
|
||||
t.Fatalf("after delete: %d", rec.Code)
|
||||
}
|
||||
// A read token reads but cannot write.
|
||||
a, _, _ := areaApp(t)
|
||||
_, read, _ := a.tokens.Create("reader", "read")
|
||||
if rec := rest(a.routes(), read, "GET", "/api/v1/settings/router/lans", "", nil); rec.Code != 200 {
|
||||
t.Fatalf("read token reads: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(a.routes(), read, "PUT", "/api/v1/settings/router/dns", `{}`, map[string]string{"If-Match": "x"}); rec.Code != 403 {
|
||||
t.Fatalf("read token wrote: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaOpenAPI(t *testing.T) {
|
||||
_, h, _ := areaApp(t)
|
||||
rec := rest(h, "", "GET", "/api/openapi.json", "", nil)
|
||||
var spec struct {
|
||||
Paths map[string]map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"/api/v1/settings/router/lans", "/api/v1/settings/router/lans/{name}", "/api/v1/settings/router/dns/domain", "/api/v1/settings/system/hostName"} {
|
||||
if spec.Paths[p] == nil {
|
||||
t.Errorf("%s is not documented", p)
|
||||
}
|
||||
}
|
||||
lan := spec.Paths["/api/v1/settings/router/lans/{name}"]
|
||||
for _, m := range []string{"get", "put", "patch", "delete"} {
|
||||
if lan[m] == nil {
|
||||
t.Errorf("lans/{name} has no %s", m)
|
||||
}
|
||||
}
|
||||
if _, ok := spec.Paths["/api/v1/settings/router/dns/domain"]["patch"]; ok {
|
||||
t.Error("a string option has a PATCH")
|
||||
}
|
||||
keys := make([]string, 0)
|
||||
for p := range spec.Paths {
|
||||
keys = append(keys, p)
|
||||
}
|
||||
if !slices.Contains(keys, "/api/v1/settings") {
|
||||
t.Error("the whole settings are gone from the spec")
|
||||
}
|
||||
}
|
||||
|
||||
// With ROUTER_OPTIONS_JSON set to the real options file, every option's type is
|
||||
// understood (nothing falls back to "anything") and the sample settings of the
|
||||
// tests validate.
|
||||
func TestRealOptionsSchema(t *testing.T) {
|
||||
path := os.Getenv("ROUTER_OPTIONS_JSON")
|
||||
if path == "" {
|
||||
t.Skip("ROUTER_OPTIONS_JSON not set")
|
||||
}
|
||||
entries, err := loadOptions(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, e := range entries {
|
||||
if !strings.HasPrefix(k, "router.") {
|
||||
continue
|
||||
}
|
||||
s := parseOptType(e.Type, func() map[string]any { return map[string]any{"type": "object"} })
|
||||
if len(s) == 0 {
|
||||
t.Errorf("%s: type %q is not understood", k, e.Type)
|
||||
}
|
||||
}
|
||||
root := settingsSchema(path)
|
||||
if root == nil {
|
||||
t.Fatal("no schema")
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(sample), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if errs := validate(doc, root, ""); len(errs) != 0 {
|
||||
t.Errorf("the sample settings do not validate: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// LIVE_SETTINGS_JSON: a real router's settings must validate too.
|
||||
func TestLiveSettingsValidate(t *testing.T) {
|
||||
opts, live := os.Getenv("ROUTER_OPTIONS_JSON"), os.Getenv("LIVE_SETTINGS_JSON")
|
||||
if opts == "" || live == "" {
|
||||
t.Skip("ROUTER_OPTIONS_JSON and LIVE_SETTINGS_JSON not set")
|
||||
}
|
||||
data, err := os.ReadFile(live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if errs := validate(doc, settingsSchema(opts), ""); len(errs) != 0 {
|
||||
t.Errorf("%v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// Viewers (Redoc) fail on a null where the OpenAPI document needs a list.
|
||||
func TestSpecHasNoNulls(t *testing.T) {
|
||||
a, _, _ := areaApp(t)
|
||||
spec := openAPISpec("1.2.3", a.settingsSchema(), "router.example")
|
||||
var walk func(path string, v any)
|
||||
walk = func(path string, v any) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
t.Errorf("null at %s", path)
|
||||
case map[string]any:
|
||||
for k, c := range x {
|
||||
walk(path+"/"+k, c)
|
||||
}
|
||||
case []any:
|
||||
for i, c := range x {
|
||||
walk(fmt.Sprintf("%s/%d", path, i), c)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Round trip, as the served document is.
|
||||
data, _ := json.Marshal(spec)
|
||||
var doc any
|
||||
_ = json.Unmarshal(data, &doc)
|
||||
walk("", doc)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Password hashes are stored as "pbkdf2-sha256$<iterations>$<salt>$<key>"
|
||||
// (base64, no padding) in a file only root can read.
|
||||
const (
|
||||
pbkdf2Iterations = 600_000
|
||||
pbkdf2KeyLen = 32
|
||||
)
|
||||
|
||||
var b64 = base64.RawStdEncoding
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, pbkdf2KeyLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", pbkdf2Iterations, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
var errBadHash = errors.New("unrecognised password hash")
|
||||
|
||||
func checkPassword(stored, password string) (bool, error) {
|
||||
parts := strings.Split(strings.TrimSpace(stored), "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false, errBadHash
|
||||
}
|
||||
iter, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iter < 1 {
|
||||
return false, errBadHash
|
||||
}
|
||||
salt, err1 := b64.DecodeString(parts[2])
|
||||
want, err2 := b64.DecodeString(parts[3])
|
||||
if err1 != nil || err2 != nil || len(want) == 0 {
|
||||
return false, errBadHash
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Sessions live in memory: a restart of the UI (e.g. by a switch) logs
|
||||
// everyone out, which is acceptable for a single-admin box.
|
||||
type session struct {
|
||||
csrf string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type sessions struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
m map[string]session
|
||||
}
|
||||
|
||||
func newSessions(ttl time.Duration) *sessions {
|
||||
return &sessions{ttl: ttl, m: map[string]session{}}
|
||||
}
|
||||
|
||||
func (s *sessions) create() (id string, sess session) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id = randomToken()
|
||||
sess = session{csrf: randomToken(), expires: time.Now().Add(s.ttl)}
|
||||
s.m[id] = sess
|
||||
return id, sess
|
||||
}
|
||||
|
||||
// get returns the session and extends it.
|
||||
func (s *sessions) get(id string) (session, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.m[id]
|
||||
if !ok || time.Now().After(sess.expires) {
|
||||
delete(s.m, id)
|
||||
return session{}, false
|
||||
}
|
||||
sess.expires = time.Now().Add(s.ttl)
|
||||
s.m[id] = sess
|
||||
return sess, true
|
||||
}
|
||||
|
||||
func (s *sessions) delete(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.m, id)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
h, err := hashPassword("correct horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := checkPassword(h, "correct horse"); !ok || err != nil {
|
||||
t.Fatalf("right password rejected: %v", err)
|
||||
}
|
||||
if ok, _ := checkPassword(h, "wrong"); ok {
|
||||
t.Fatal("wrong password accepted")
|
||||
}
|
||||
if _, err := checkPassword("plaintext", "plaintext"); err == nil {
|
||||
t.Fatal("garbage hash accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertInfo describes the certificate the web UI serves.
|
||||
type CertInfo struct {
|
||||
Subject, Issuer string
|
||||
NotAfter time.Time
|
||||
SelfSigned bool
|
||||
Fingerprint string // SHA-256, colon separated
|
||||
}
|
||||
|
||||
func readCertInfo(path string) (CertInfo, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return CertInfo{}, false
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return CertInfo{}, false
|
||||
}
|
||||
c, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return CertInfo{}, false
|
||||
}
|
||||
sum := sha256.Sum256(c.Raw)
|
||||
hexSum := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
var pairs []string
|
||||
for i := 0; i < len(hexSum); i += 2 {
|
||||
pairs = append(pairs, hexSum[i:i+2])
|
||||
}
|
||||
return CertInfo{Subject: c.Subject.String(), Issuer: c.Issuer.String(), NotAfter: c.NotAfter,
|
||||
SelfSigned: c.Subject.String() == c.Issuer.String(), Fingerprint: strings.Join(pairs, ":")}, true
|
||||
}
|
||||
|
||||
// certSource serves the certificate an ACME client keeps in dir while it is
|
||||
// there and valid, and the self-signed one otherwise. Renewed files are picked
|
||||
// up on the next connection.
|
||||
type certSource struct {
|
||||
fallback tls.Certificate
|
||||
dir string
|
||||
|
||||
mu sync.Mutex
|
||||
loaded tls.Certificate
|
||||
mod time.Time
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (c *certSource) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if c.dir == "" {
|
||||
return &c.fallback, nil
|
||||
}
|
||||
chain, key := filepath.Join(c.dir, "fullchain.pem"), filepath.Join(c.dir, "key.pem")
|
||||
a, errA := os.Stat(chain)
|
||||
b, errB := os.Stat(key)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if errA != nil || errB != nil {
|
||||
c.ok = false
|
||||
return &c.fallback, nil
|
||||
}
|
||||
mod := a.ModTime()
|
||||
if b.ModTime().After(mod) {
|
||||
mod = b.ModTime()
|
||||
}
|
||||
if !c.ok || !mod.Equal(c.mod) {
|
||||
cert, err := tls.LoadX509KeyPair(chain, key)
|
||||
c.mod = mod // do not retry a broken pair on every connection
|
||||
c.ok = err == nil
|
||||
if err != nil {
|
||||
log.Printf("certificate in %s: %v", c.dir, err)
|
||||
} else {
|
||||
c.loaded = cert
|
||||
}
|
||||
}
|
||||
if !c.ok {
|
||||
return &c.fallback, nil
|
||||
}
|
||||
return &c.loaded, nil
|
||||
}
|
||||
|
||||
// ACMEForm is the certificate section of the settings (router.acme).
|
||||
type ACMEForm struct {
|
||||
Enabled bool
|
||||
Provider string // a connection of router.dnsProviders
|
||||
Domains string // one per line or comma separated
|
||||
Email string
|
||||
Staging bool
|
||||
Agree bool
|
||||
}
|
||||
|
||||
func (s Settings) acme() ACMEForm {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
c, _ := router["acme"].(map[string]any)
|
||||
flag := func(k string, def bool) bool {
|
||||
if v, ok := c[k].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
return ACMEForm{Enabled: flag("enable", false), Provider: str(c, "provider"), Domains: strings.Join(stringList(c, "domains"), "\n"),
|
||||
Email: str(c, "email"), Staging: flag("staging", true), Agree: flag("acceptTerms", false)}
|
||||
}
|
||||
|
||||
func (s Settings) applyACME(f ACMEForm) error {
|
||||
errs := FormErrors{}
|
||||
f.Provider, f.Email = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Email)
|
||||
var domains []any
|
||||
for _, d := range splitList(f.Domains) {
|
||||
if !ddnsHostRe.MatchString(d) || !strings.Contains(d, ".") {
|
||||
errs["domains"] = d + " is not a host name"
|
||||
break
|
||||
}
|
||||
domains = append(domains, strings.ToLower(d))
|
||||
}
|
||||
if f.Provider != "" {
|
||||
p, ok := s.dnsProviderMap()[f.Provider].(map[string]any)
|
||||
switch {
|
||||
case !ok:
|
||||
errs["provider"] = "choose one of the DNS providers"
|
||||
case !slices.Contains(acmeTypes, str(p, "type")):
|
||||
errs["provider"] = "lego has no DNS provider " + str(p, "type") + "; it is for dynamic DNS"
|
||||
case acmeTokens[str(p, "type")] != "" && str(p, "secretFile") == "":
|
||||
errs["provider"] = "this account has no token (add it on the DNS providers page)"
|
||||
case acmeTokens[str(p, "type")] == "" && str(p, "environmentFile") == "":
|
||||
errs["provider"] = "this account has no certificate variables (add them on the DNS providers page)"
|
||||
}
|
||||
}
|
||||
if f.Email != "" && !strings.Contains(f.Email, "@") {
|
||||
errs["email"] = "an e-mail address"
|
||||
}
|
||||
if f.Enabled {
|
||||
if f.Provider == "" {
|
||||
errs["provider"] = "choose a DNS provider (add one on the DNS providers page)"
|
||||
}
|
||||
if len(domains) == 0 {
|
||||
errs["domains"] = "name at least one host"
|
||||
}
|
||||
if f.Email == "" {
|
||||
errs["email"] = "an address Let's Encrypt can write to about expiry"
|
||||
}
|
||||
if !f.Agree {
|
||||
errs["agree"] = "accept the Let's Encrypt subscriber agreement"
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
c := obj(obj(s, "router"), "acme")
|
||||
c["enable"], c["staging"], c["acceptTerms"] = f.Enabled, f.Staging, f.Agree
|
||||
set := func(k, v string) {
|
||||
if v == "" {
|
||||
delete(c, k)
|
||||
} else {
|
||||
c[k] = v
|
||||
}
|
||||
}
|
||||
set("provider", f.Provider)
|
||||
set("email", f.Email)
|
||||
if len(domains) > 0 {
|
||||
c["domains"] = domains
|
||||
} else {
|
||||
delete(c, "domains")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type certificateData struct {
|
||||
Cert CertInfo
|
||||
Has bool
|
||||
Serving string // "acme" or "self-signed"
|
||||
Form ACMEForm
|
||||
Errors FormErrors
|
||||
Providers []DNSProvider
|
||||
Status string // the order's last result
|
||||
Failed bool // the last order did not succeed: the log is shown open
|
||||
Log string
|
||||
}
|
||||
|
||||
// servedCert is what the web UI hands out: the ACME certificate if it is there.
|
||||
func (a *app) servedCert() (CertInfo, bool, string) {
|
||||
if a.sys.ACMEDir != "" {
|
||||
if c, ok := readCertInfo(filepath.Join(a.sys.ACMEDir, "fullchain.pem")); ok {
|
||||
return c, true, "acme"
|
||||
}
|
||||
}
|
||||
c, ok := readCertInfo(filepath.Join(a.sys.StateDir, "webui", "cert.pem"))
|
||||
return c, ok, "self-signed"
|
||||
}
|
||||
|
||||
func (a *app) certificateData(s Settings, f ACMEForm, errs FormErrors) certificateData {
|
||||
var providers []DNSProvider
|
||||
for _, p := range a.dnsProviders(s) {
|
||||
if p.ACME {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
d := certificateData{Form: f, Errors: errs, Providers: providers}
|
||||
d.Cert, d.Has, d.Serving = a.servedCert()
|
||||
if first := splitList(f.Domains); len(first) > 0 && s.acme().Enabled {
|
||||
unit := "acme-order-renew-" + strings.ToLower(first[0]) + ".service"
|
||||
if st := unitProperty(unit, "ActiveState"); st != "" {
|
||||
res := unitProperty(unit, "Result")
|
||||
d.Status = st + ", result " + res
|
||||
d.Failed = res != "success" || st == "failed"
|
||||
d.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "15", "-u", unit)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (a *app) certificatePage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "certificate", p)
|
||||
return
|
||||
}
|
||||
p.Data = a.certificateData(s, s.acme(), nil)
|
||||
a.render(w, "certificate", p)
|
||||
}
|
||||
|
||||
func (a *app) certificateSave(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "certificate", p)
|
||||
return
|
||||
}
|
||||
v := r.PostFormValue
|
||||
f := ACMEForm{Enabled: v("enabled") == "on", Provider: v("provider"), Domains: v("domains"), Email: v("email"),
|
||||
Staging: v("staging") == "on", Agree: v("agree") == "on"}
|
||||
if err := s.applyACME(f); err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = a.certificateData(s, f, fe)
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "certificate", p)
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "certificate", p)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to order the certificate; this page shows how it went.")
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A check evaluates the saved settings like an apply would (option types and
|
||||
// the router's assertions) without building anything: router-check.service
|
||||
// runs `nix eval` on the configuration and leaves its verdict in
|
||||
// <state>/check/result.json.
|
||||
|
||||
type CheckResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Etag string `json:"etag"` // of the settings that were evaluated
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
func (s System) checkResultPath() string { return filepath.Join(s.StateDir, "check", "result.json") }
|
||||
|
||||
func (s System) readCheck() (CheckResult, bool) {
|
||||
var r CheckResult
|
||||
data, err := os.ReadFile(s.checkResultPath())
|
||||
if err != nil || json.Unmarshal(data, &r) != nil {
|
||||
return r, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
func (s System) checkRunning() bool {
|
||||
return unitProperty("router-check.service", "ActiveState") == "activating"
|
||||
}
|
||||
|
||||
// checkSettings starts a check unless one runs or the last result is for the
|
||||
// current settings, and waits a while for it.
|
||||
func (a *app) checkSettings() (any, error) {
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := etag(data)
|
||||
if r, ok := a.sys.readCheck(); ok && r.Etag == current && !a.sys.checkRunning() {
|
||||
return a.checkReport(r, true), nil
|
||||
}
|
||||
if !a.sys.checkRunning() {
|
||||
if _, err := command("systemctl", "start", "--no-block", "router-check.service"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for range 50 {
|
||||
time.Sleep(time.Second)
|
||||
if a.sys.checkRunning() {
|
||||
continue
|
||||
}
|
||||
if r, ok := a.sys.readCheck(); ok {
|
||||
return a.checkReport(r, r.Etag == current), nil
|
||||
}
|
||||
break
|
||||
}
|
||||
return CheckResponse{Status: "running", Next: "Still evaluating; call check_settings again in a few seconds."}, nil
|
||||
}
|
||||
|
||||
func (a *app) checkReport(r CheckResult, current bool) CheckResponse {
|
||||
out := CheckResponse{Status: "done", OK: &r.OK, ForTheSavedSettings: ¤t, CheckedAt: &r.Time}
|
||||
if r.OK {
|
||||
out.Message = "The settings evaluate: the router's assertions pass."
|
||||
} else {
|
||||
out.Error = r.Error
|
||||
}
|
||||
if !current {
|
||||
out.Note = "The settings changed during or after this check; call check_settings again."
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sampleEvery = 5 * time.Second
|
||||
|
||||
// Collector samples the box every few seconds into the Store and keeps the
|
||||
// latest snapshot for the dashboard.
|
||||
type Collector struct {
|
||||
sys System
|
||||
store *Store
|
||||
storePath string
|
||||
leasesPath string
|
||||
|
||||
mu sync.Mutex
|
||||
snap Snapshot
|
||||
|
||||
prevIf map[string][2]uint64
|
||||
prevCPU [2]uint64 // busy, total
|
||||
prevTime time.Time
|
||||
}
|
||||
|
||||
// Snapshot is the state right now.
|
||||
type Snapshot struct {
|
||||
Time time.Time
|
||||
Uplink UplinkStatus
|
||||
System SystemStatus
|
||||
Pings []PingResult
|
||||
}
|
||||
|
||||
type UplinkStatus struct {
|
||||
Device string // pppoe-wan or the WAN interface
|
||||
Type string // dhcp or pppoe
|
||||
Exists bool
|
||||
Connected bool // an IPv4 address and the default route on the uplink
|
||||
Reachable bool // at least one public ping target answered
|
||||
Checked bool // public ping targets are configured (Reachable means something)
|
||||
IPv4 []string
|
||||
IPv6 []string
|
||||
Gateway string
|
||||
Since time.Time
|
||||
Events []Event
|
||||
}
|
||||
|
||||
type SystemStatus struct {
|
||||
Uptime time.Duration
|
||||
Load string
|
||||
Kernel string // the running kernel
|
||||
KernelNext string // the kernel of the current system when it differs: a reboot is due
|
||||
BootPending bool // a configuration is staged for the next boot (interface names)
|
||||
CPU float64 // percent; NaN until the second sample
|
||||
MemUsed uint64
|
||||
MemTotal uint64
|
||||
Temperature float64 // °C, the hottest sensor; NaN if there is none
|
||||
Sensors []Sensor
|
||||
DiskUsed uint64
|
||||
DiskTotal uint64
|
||||
}
|
||||
|
||||
type PingResult struct {
|
||||
Target string // as configured ("gateway" or an address)
|
||||
Address string // what was pinged
|
||||
RTT time.Duration
|
||||
OK bool
|
||||
}
|
||||
|
||||
func newCollector(sys System, leasesPath string) *Collector {
|
||||
path := filepath.Join(sys.StateDir, "webui", "metrics.gob")
|
||||
store, err := loadStore(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("metric history: %v (starting empty)", err)
|
||||
}
|
||||
return &Collector{sys: sys, store: store, storePath: path, leasesPath: leasesPath}
|
||||
}
|
||||
|
||||
func (c *Collector) Snapshot() Snapshot {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.snap
|
||||
}
|
||||
|
||||
func (c *Collector) Save() {
|
||||
if err := os.MkdirAll(filepath.Dir(c.storePath), 0o700); err != nil {
|
||||
log.Printf("saving metric history: %v", err)
|
||||
return
|
||||
}
|
||||
if err := c.store.Save(c.storePath); err != nil {
|
||||
log.Printf("saving metric history: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run samples until ctx ends, saving the history every 15 minutes and at the end.
|
||||
func (c *Collector) Run(ctx context.Context) {
|
||||
tick := time.NewTicker(sampleEvery)
|
||||
defer tick.Stop()
|
||||
save := time.NewTicker(15 * time.Minute)
|
||||
defer save.Stop()
|
||||
c.sample(time.Now())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.Save()
|
||||
return
|
||||
case <-save.C:
|
||||
c.Save()
|
||||
case now := <-tick.C:
|
||||
c.sample(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) sample(now time.Time) {
|
||||
s, _ := loadSettings(c.sys.settingsPath())
|
||||
f := s.form()
|
||||
prefs := c.sys.loadPrefs()
|
||||
|
||||
// Pings run while the rest is sampled.
|
||||
var pings []PingResult
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
pings = pingAll(prefs.PingTargets, 2*time.Second)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
dt := now.Sub(c.prevTime).Seconds()
|
||||
counters := interfaceCounters()
|
||||
if c.prevIf != nil && dt > 0 {
|
||||
for dev, cur := range counters {
|
||||
prev, ok := c.prevIf[dev]
|
||||
if !ok || cur[0] < prev[0] || cur[1] < prev[1] {
|
||||
continue // new device or counters reset (a PPP reconnect)
|
||||
}
|
||||
c.store.Add("if:"+dev+":rx", now, float64(cur[0]-prev[0])/dt)
|
||||
c.store.Add("if:"+dev+":tx", now, float64(cur[1]-prev[1])/dt)
|
||||
}
|
||||
}
|
||||
c.prevIf, c.prevTime = counters, now
|
||||
|
||||
sys := systemStatus()
|
||||
busy, total := cpuTimes()
|
||||
sys.CPU = math.NaN()
|
||||
if c.prevCPU[1] != 0 && total > c.prevCPU[1] {
|
||||
sys.CPU = 100 * float64(busy-c.prevCPU[0]) / float64(total-c.prevCPU[1])
|
||||
}
|
||||
c.prevCPU = [2]uint64{busy, total}
|
||||
c.store.Add("sys:cpu", now, sys.CPU)
|
||||
if sys.MemTotal > 0 {
|
||||
c.store.Add("sys:mem", now, 100*float64(sys.MemUsed)/float64(sys.MemTotal))
|
||||
}
|
||||
c.store.Add("sys:temp", now, sys.Temperature)
|
||||
for _, t := range sys.Sensors {
|
||||
c.store.Add("temp:"+t.Key, now, t.Temp)
|
||||
}
|
||||
|
||||
up := uplinkStatus(f.WAN)
|
||||
<-done
|
||||
for _, p := range pings {
|
||||
loss := 100.0
|
||||
if p.OK {
|
||||
loss = 0
|
||||
c.store.Add("ping:"+p.Target+":rtt", now, float64(p.RTT.Microseconds())/1000)
|
||||
}
|
||||
c.store.Add("ping:"+p.Target+":loss", now, loss)
|
||||
if p.Target != "gateway" {
|
||||
up.Checked = true
|
||||
up.Reachable = up.Reachable || p.OK
|
||||
}
|
||||
}
|
||||
detail := strings.Join(up.IPv4, ", ")
|
||||
if !up.Connected {
|
||||
detail = ""
|
||||
}
|
||||
c.store.SetConnected(up.Connected, now, detail)
|
||||
_, up.Since, up.Events = c.store.Uplink()
|
||||
|
||||
c.mu.Lock()
|
||||
c.snap = Snapshot{Time: now, Uplink: up, System: sys, Pings: pings}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// interfaceCounters reads rx/tx bytes of every interface but lo.
|
||||
func interfaceCounters() map[string][2]uint64 {
|
||||
out := map[string][2]uint64{}
|
||||
entries, _ := os.ReadDir("/sys/class/net")
|
||||
for _, e := range entries {
|
||||
dev := e.Name()
|
||||
if dev == "lo" {
|
||||
continue
|
||||
}
|
||||
rx, err1 := readUint(filepath.Join("/sys/class/net", dev, "statistics/rx_bytes"))
|
||||
tx, err2 := readUint(filepath.Join("/sys/class/net", dev, "statistics/tx_bytes"))
|
||||
if err1 == nil && err2 == nil {
|
||||
out[dev] = [2]uint64{rx, tx}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readUint(path string) (uint64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
||||
}
|
||||
|
||||
func cpuTimes() (busy, total uint64) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
line, _ := bufio.NewReader(f).ReadString('\n')
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return 0, 0
|
||||
}
|
||||
for i, v := range fields[1:] {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
total += n
|
||||
if i != 3 && i != 4 { // idle, iowait
|
||||
busy += n
|
||||
}
|
||||
}
|
||||
return busy, total
|
||||
}
|
||||
|
||||
func systemStatus() SystemStatus {
|
||||
s := SystemStatus{Sensors: sensors()}
|
||||
s.Temperature = hottest(s.Sensors)
|
||||
if data, err := os.ReadFile("/proc/uptime"); err == nil {
|
||||
if f := strings.Fields(string(data)); len(f) > 0 {
|
||||
secs, _ := strconv.ParseFloat(f[0], 64)
|
||||
s.Uptime = time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
|
||||
s.Kernel = strings.TrimSpace(string(data))
|
||||
}
|
||||
if dest, err := filepath.EvalSymlinks("/run/current-system/kernel"); err == nil {
|
||||
s.KernelNext = pendingKernel(s.Kernel, dest)
|
||||
}
|
||||
// router-apply sets the system profile without activating it when the
|
||||
// configuration changes interface names. (While a change awaits
|
||||
// confirmation the two differ too, but a rollback timer is running.)
|
||||
if cur, err := filepath.EvalSymlinks("/run/current-system"); err == nil {
|
||||
if prof, err := filepath.EvalSymlinks("/nix/var/nix/profiles/system"); err == nil && prof != cur {
|
||||
s.BootPending = rollbackIn() == 0
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
if f := strings.Fields(string(data)); len(f) >= 3 {
|
||||
s.Load = strings.Join(f[:3], " ")
|
||||
}
|
||||
}
|
||||
if f, err := os.Open("/proc/meminfo"); err == nil {
|
||||
mem := map[string]uint64{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
k, v, ok := strings.Cut(sc.Text(), ":")
|
||||
if ok {
|
||||
n, _ := strconv.ParseUint(strings.TrimSuffix(strings.TrimSpace(v), " kB"), 10, 64)
|
||||
mem[k] = n * 1024
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
s.MemTotal = mem["MemTotal"]
|
||||
if mem["MemAvailable"] <= s.MemTotal {
|
||||
s.MemUsed = s.MemTotal - mem["MemAvailable"]
|
||||
}
|
||||
}
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs("/", &st) == nil {
|
||||
s.DiskTotal = st.Blocks * uint64(st.Bsize)
|
||||
s.DiskUsed = (st.Blocks - st.Bfree) * uint64(st.Bsize)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var kernelDirRe = regexp.MustCompile(`-linux-([0-9][^/]*)/`)
|
||||
|
||||
// pendingKernel is the version of the kernel image the current system boots
|
||||
// (a path like /nix/store/…-linux-6.12.5/bzImage) if it is not the running one.
|
||||
func pendingKernel(running, image string) string {
|
||||
m := kernelDirRe.FindStringSubmatch(image)
|
||||
if m == nil || running == "" {
|
||||
return ""
|
||||
}
|
||||
// The store name says 7.3, the kernel 7.3.0 (or 7.3.0-suffix).
|
||||
base, _, _ := strings.Cut(running, "-")
|
||||
if base == m[1] || base == m[1]+".0" {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// Sensor is one temperature the machine reports.
|
||||
type Sensor struct {
|
||||
Key string // stable name of its series
|
||||
Label string
|
||||
Temp float64 // °C
|
||||
}
|
||||
|
||||
// sensors lists the thermal zones and hwmon temperature inputs, sorted by
|
||||
// label. Two sensors with the same name get a number.
|
||||
func sensors() []Sensor {
|
||||
return readSensors("/sys/class/thermal", "/sys/class/hwmon")
|
||||
}
|
||||
|
||||
func readSensors(thermalDir, hwmonDir string) []Sensor {
|
||||
var out []Sensor
|
||||
add := func(label, file string) {
|
||||
if v, err := readUint(file); err == nil && v > 0 && v < 150000 {
|
||||
out = append(out, Sensor{Label: label, Temp: float64(v) / 1000})
|
||||
}
|
||||
}
|
||||
text := func(file string) string {
|
||||
data, _ := os.ReadFile(file)
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
zones, _ := filepath.Glob(filepath.Join(thermalDir, "thermal_zone*"))
|
||||
for _, z := range zones {
|
||||
label := text(filepath.Join(z, "type"))
|
||||
if label == "" {
|
||||
label = filepath.Base(z)
|
||||
}
|
||||
add(label, filepath.Join(z, "temp"))
|
||||
}
|
||||
inputs, _ := filepath.Glob(filepath.Join(hwmonDir, "hwmon*", "temp*_input"))
|
||||
for _, in := range inputs {
|
||||
dir, base := filepath.Split(in)
|
||||
chip := text(filepath.Join(dir, "name"))
|
||||
if chip == "" {
|
||||
chip = filepath.Base(dir)
|
||||
}
|
||||
name := strings.TrimSuffix(base, "_input")
|
||||
sub := text(filepath.Join(dir, name+"_label"))
|
||||
if sub == "" {
|
||||
sub = name
|
||||
}
|
||||
add(chip+" "+sub, in)
|
||||
}
|
||||
slices.SortStableFunc(out, func(a, b Sensor) int { return strings.Compare(a.Label, b.Label) })
|
||||
count := map[string]int{}
|
||||
for i := range out {
|
||||
base := out[i].Label
|
||||
count[base]++
|
||||
if n := count[base]; n > 1 {
|
||||
out[i].Label = fmt.Sprintf("%s %d", base, n)
|
||||
}
|
||||
out[i].Key = out[i].Label
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hottest(sensors []Sensor) float64 {
|
||||
h := math.NaN()
|
||||
for _, t := range sensors {
|
||||
if math.IsNaN(h) || t.Temp > h {
|
||||
h = t.Temp
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// uplinkDevice is where the router's IPv4 internet traffic leaves.
|
||||
func uplinkDevice(wan WANForm) string {
|
||||
if wan.Type == "pppoe" {
|
||||
return "pppoe-wan"
|
||||
}
|
||||
return wan.Interface
|
||||
}
|
||||
|
||||
func uplinkStatus(wan WANForm) UplinkStatus {
|
||||
u := UplinkStatus{Device: uplinkDevice(wan), Type: wan.Type}
|
||||
iface, err := net.InterfaceByName(u.Device)
|
||||
if err != nil {
|
||||
return u
|
||||
}
|
||||
u.Exists = true
|
||||
addrs, _ := iface.Addrs()
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ip4 := ipnet.IP.To4(); ip4 != nil {
|
||||
u.IPv4 = append(u.IPv4, ip4.String())
|
||||
} else if ipnet.IP.IsGlobalUnicast() {
|
||||
u.IPv6 = append(u.IPv6, ipnet.IP.String())
|
||||
}
|
||||
}
|
||||
gw, dev := defaultRoute()
|
||||
u.Connected = len(u.IPv4) > 0 && dev == u.Device
|
||||
if u.Connected {
|
||||
u.Gateway = gw
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// defaultRoute returns the IPv4 default route's gateway (for a point-to-point
|
||||
// uplink: the peer) and device.
|
||||
func defaultRoute() (gateway, dev string) {
|
||||
out, err := command("ip", "-j", "-4", "route", "show", "default")
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var routes []struct {
|
||||
Gateway string `json:"gateway"`
|
||||
Dev string `json:"dev"`
|
||||
}
|
||||
if json.Unmarshal([]byte(out), &routes) != nil || len(routes) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
gateway, dev = routes[0].Gateway, routes[0].Dev
|
||||
if gateway == "" && dev != "" {
|
||||
out, err := command("ip", "-j", "-4", "addr", "show", "dev", dev)
|
||||
if err != nil {
|
||||
return "", dev
|
||||
}
|
||||
var links []struct {
|
||||
AddrInfo []struct {
|
||||
Address string `json:"address"` // the peer, on point-to-point links
|
||||
} `json:"addr_info"`
|
||||
}
|
||||
if json.Unmarshal([]byte(out), &links) == nil && len(links) > 0 && len(links[0].AddrInfo) > 0 {
|
||||
gateway = links[0].AddrInfo[0].Address
|
||||
}
|
||||
}
|
||||
return gateway, dev
|
||||
}
|
||||
|
||||
// pingAll pings every target once, in parallel. "gateway" is the default
|
||||
// route's gateway (the ISP's end of the line).
|
||||
func pingAll(targets []string, timeout time.Duration) []PingResult {
|
||||
out := make([]PingResult, len(targets))
|
||||
var wg sync.WaitGroup
|
||||
for i, t := range targets {
|
||||
out[i] = PingResult{Target: t, Address: t}
|
||||
if t == "gateway" {
|
||||
gw, _ := defaultRoute()
|
||||
out[i].Address = gw
|
||||
}
|
||||
addr, err := netip.ParseAddr(out[i].Address)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
wg.Go(func() {
|
||||
rtt, err := ping(addr, timeout)
|
||||
out[i].RTT, out[i].OK = rtt, err == nil
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
|
||||
// ping sends one ICMP echo request over a raw socket (the UI runs as root).
|
||||
func ping(addr netip.Addr, timeout time.Duration) (time.Duration, error) {
|
||||
network, request, reply := "ip4:icmp", byte(8), byte(0)
|
||||
if addr.Is6() && !addr.Is4In6() {
|
||||
network, request, reply = "ip6:ipv6-icmp", 128, 129
|
||||
}
|
||||
conn, err := net.ListenPacket(network, "")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
id := uint16(rand.Uint32())
|
||||
msg := make([]byte, 16)
|
||||
msg[0] = request
|
||||
binary.BigEndian.PutUint16(msg[4:], id)
|
||||
binary.BigEndian.PutUint16(msg[6:], 1)
|
||||
if request == 8 {
|
||||
// The kernel fills in the ICMPv6 checksum, not ICMPv4's.
|
||||
binary.BigEndian.PutUint16(msg[2:], checksum(msg))
|
||||
}
|
||||
start := time.Now()
|
||||
conn.SetDeadline(start.Add(timeout))
|
||||
if _, err := conn.WriteTo(msg, &net.IPAddr{IP: addr.AsSlice()}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, from, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Raw sockets see every ICMP message; ours carries our ID.
|
||||
if n >= 8 && buf[0] == reply && binary.BigEndian.Uint16(buf[4:]) == id {
|
||||
if ip, ok := from.(*net.IPAddr); ok && ip.IP.Equal(addr.AsSlice()) {
|
||||
return time.Since(start), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checksum(b []byte) uint16 {
|
||||
var sum uint32
|
||||
for i := 0; i+1 < len(b); i += 2 {
|
||||
sum += uint32(b[i])<<8 | uint32(b[i+1])
|
||||
}
|
||||
if len(b)%2 == 1 {
|
||||
sum += uint32(b[len(b)-1]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = sum&0xffff + sum>>16
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
|
||||
// Lease is an active DHCPv4 lease.
|
||||
type Lease struct {
|
||||
Address string
|
||||
MAC string
|
||||
Hostname string
|
||||
Start time.Time // when it was handed out or last renewed
|
||||
Expires time.Time
|
||||
LAN string
|
||||
}
|
||||
|
||||
// readLeases reads Kea's memfile: a CSV log in which the last line for an
|
||||
// address wins. The previous file of a lease file cleanup comes first.
|
||||
func readLeases(path string, lans []LANForm, now time.Time) []Lease {
|
||||
latest := map[string]Lease{}
|
||||
for _, p := range []string{path + ".completed", path + ".2", path} {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
parseLeases(f, latest, now)
|
||||
f.Close()
|
||||
}
|
||||
var out []Lease
|
||||
for _, l := range latest {
|
||||
if l.Expires.After(now) {
|
||||
l.LAN = lanOf(l.Address, lans)
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Lease) int {
|
||||
x, _ := netip.ParseAddr(a.Address)
|
||||
y, _ := netip.ParseAddr(b.Address)
|
||||
return x.Compare(y)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func parseLeases(r io.Reader, latest map[string]Lease, now time.Time) {
|
||||
cr := csv.NewReader(r)
|
||||
cr.FieldsPerRecord = -1
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
col := map[string]int{}
|
||||
for i, h := range header {
|
||||
col[h] = i
|
||||
}
|
||||
get := func(rec []string, name string) string {
|
||||
if i, ok := col[name]; ok && i < len(rec) {
|
||||
return rec[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for {
|
||||
rec, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(rec) > 0 && rec[0] == header[0] {
|
||||
continue // a header repeated by a cleanup
|
||||
}
|
||||
addr := get(rec, "address")
|
||||
expire, _ := strconv.ParseInt(get(rec, "expire"), 10, 64)
|
||||
lifetime, _ := strconv.ParseInt(get(rec, "valid_lifetime"), 10, 64)
|
||||
state := get(rec, "state")
|
||||
l := Lease{
|
||||
Address: addr,
|
||||
MAC: get(rec, "hwaddr"),
|
||||
Hostname: strings.ReplaceAll(get(rec, "hostname"), ",", ","),
|
||||
Expires: time.Unix(expire, 0),
|
||||
Start: time.Unix(expire-lifetime, 0),
|
||||
}
|
||||
if lifetime == 0 || (state != "" && state != "0") {
|
||||
l.Expires = time.Time{} // released, declined or reclaimed
|
||||
}
|
||||
latest[addr] = l
|
||||
}
|
||||
}
|
||||
|
||||
func lanOf(addr string, lans []LANForm) string {
|
||||
a, err := netip.ParseAddr(addr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, l := range lans {
|
||||
prefix := 24
|
||||
if p, err := strconv.Atoi(l.Prefix); err == nil {
|
||||
prefix = p
|
||||
}
|
||||
if pfx, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", l.Address, prefix)); err == nil && pfx.Masked().Contains(a) {
|
||||
return l.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Prefs are the dashboard's own settings. They live beside the UI's state,
|
||||
// not in settings.json: changing them needs no rebuild.
|
||||
type Prefs struct {
|
||||
PingTargets []string `json:"pingTargets"`
|
||||
// VLANs defined in the network page ("enp2s0.1001") that may not be used
|
||||
// yet; the interface dropdowns offer them.
|
||||
VLANs []string `json:"vlans,omitempty"`
|
||||
// Theme is "light" or "dark"; empty follows the browser's setting.
|
||||
Theme string `json:"theme,omitempty"`
|
||||
}
|
||||
|
||||
var defaultPrefs = Prefs{PingTargets: []string{"gateway", "1.1.1.1", "9.9.9.9"}}
|
||||
|
||||
func (s System) prefsPath() string { return filepath.Join(s.StateDir, "webui", "dashboard.json") }
|
||||
|
||||
func (s System) loadPrefs() Prefs {
|
||||
data, err := os.ReadFile(s.prefsPath())
|
||||
if err != nil {
|
||||
return defaultPrefs
|
||||
}
|
||||
var p Prefs
|
||||
if json.Unmarshal(data, &p) != nil || p.PingTargets == nil {
|
||||
return defaultPrefs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (s System) savePrefs(p Prefs) error {
|
||||
data, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.prefsPath()), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(s.prefsPath(), append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
const maxPingTargets = 5
|
||||
|
||||
// parsePingTargets takes targets separated by commas, spaces or lines:
|
||||
// "gateway" or IP addresses.
|
||||
func parsePingTargets(text string) ([]string, error) {
|
||||
targets := []string{}
|
||||
for _, t := range strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\r' || r == '\t' }) {
|
||||
if t != "gateway" {
|
||||
a, err := netip.ParseAddr(t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is neither \"gateway\" nor an IP address", t)
|
||||
}
|
||||
t = a.String()
|
||||
}
|
||||
if !slices.Contains(targets, t) {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
}
|
||||
if len(targets) > maxPingTargets {
|
||||
return nil, fmt.Errorf("at most %d targets", maxPingTargets)
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// Chart is an SVG line chart, laid out here so the template only draws it.
|
||||
type Chart struct {
|
||||
Title string
|
||||
Unit string // "bit/s", "ms", "%", "°C"
|
||||
Lines []ChartLine
|
||||
YTicks []ChartTick
|
||||
XTicks []ChartTick
|
||||
Cols []ChartCol // hover areas with a tooltip each
|
||||
Empty bool
|
||||
}
|
||||
|
||||
type ChartLine struct {
|
||||
Label string
|
||||
Class string // colour, see style.css
|
||||
Path string // SVG path data; gaps start a new subpath
|
||||
Now string // the latest value, formatted
|
||||
}
|
||||
|
||||
type ChartTick struct {
|
||||
Pos float64
|
||||
Label string
|
||||
}
|
||||
|
||||
type ChartCol struct {
|
||||
X, W float64
|
||||
Tip string
|
||||
}
|
||||
|
||||
// The chart's drawing area in SVG units (the template adds the margins).
|
||||
const (
|
||||
chartW = 600.0
|
||||
chartH = 120.0
|
||||
chartCols = 200
|
||||
)
|
||||
|
||||
type seriesSpec struct {
|
||||
Name string // in the Store
|
||||
Label string
|
||||
Scale float64 // e.g. 8 for bytes/s -> bit/s
|
||||
}
|
||||
|
||||
func (a *app) chart(title, unit string, rg timeRange, now time.Time, specs []seriesSpec) Chart {
|
||||
c := Chart{Title: title, Unit: unit}
|
||||
var start time.Time
|
||||
var step time.Duration
|
||||
cols := make([][]float64, len(specs))
|
||||
ymax := 0.0
|
||||
for i, sp := range specs {
|
||||
st, stp, vals := a.metrics.store.Read(sp.Name, rg.Tier, now)
|
||||
if keep := rg.slots(); keep < len(vals) {
|
||||
st = st.Add(stp * time.Duration(len(vals)-keep))
|
||||
vals = vals[len(vals)-keep:]
|
||||
}
|
||||
start, step = st, stp
|
||||
cols[i] = downsample(vals, chartCols, sp.Scale)
|
||||
for _, v := range cols[i] {
|
||||
if !math.IsNaN(v) {
|
||||
ymax = math.Max(ymax, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
n := chartCols
|
||||
span := step * time.Duration(rg.slots())
|
||||
colDur := span / time.Duration(n)
|
||||
if unit == "%" {
|
||||
ymax = math.Max(ymax, 1)
|
||||
if ymax > 50 {
|
||||
ymax = 100
|
||||
}
|
||||
}
|
||||
ymax = niceCeil(ymax)
|
||||
c.Empty = true
|
||||
for i, sp := range specs {
|
||||
line := ChartLine{Label: sp.Label, Class: fmt.Sprintf("s%d", i%10), Now: "–"}
|
||||
var path strings.Builder
|
||||
pen := false
|
||||
for j, v := range cols[i] {
|
||||
if math.IsNaN(v) {
|
||||
pen = false
|
||||
continue
|
||||
}
|
||||
c.Empty = false
|
||||
x := (float64(j) + 0.5) * chartW / float64(n)
|
||||
y := chartH - v/ymax*chartH
|
||||
if pen {
|
||||
fmt.Fprintf(&path, "L%.1f %.1f", x, y)
|
||||
} else {
|
||||
fmt.Fprintf(&path, "M%.1f %.1f", x, y)
|
||||
// A lone point still shows up as a short dash.
|
||||
if j+1 >= len(cols[i]) || math.IsNaN(cols[i][j+1]) {
|
||||
fmt.Fprintf(&path, "h1")
|
||||
}
|
||||
}
|
||||
pen = true
|
||||
line.Now = formatValue(v, unit)
|
||||
}
|
||||
line.Path = path.String()
|
||||
c.Lines = append(c.Lines, line)
|
||||
}
|
||||
for k := 0; k <= 2; k++ {
|
||||
v := ymax * float64(k) / 2
|
||||
c.YTicks = append(c.YTicks, ChartTick{Pos: chartH - v/ymax*chartH, Label: formatValue(v, unit)})
|
||||
}
|
||||
layout := "15:04"
|
||||
if span > 48*time.Hour {
|
||||
layout = "Jan 2"
|
||||
}
|
||||
for k := 0; k <= 4; k++ {
|
||||
t := start.Add(span * time.Duration(k) / 4)
|
||||
c.XTicks = append(c.XTicks, ChartTick{Pos: chartW * float64(k) / 4, Label: t.Local().Format(layout)})
|
||||
}
|
||||
for j := range n {
|
||||
t := start.Add(colDur * time.Duration(j))
|
||||
var tip strings.Builder
|
||||
tip.WriteString(t.Local().Format("Jan 2 15:04:05"))
|
||||
for i, sp := range specs {
|
||||
fmt.Fprintf(&tip, "\n%s: %s", sp.Label, formatValue(cols[i][j], unit))
|
||||
}
|
||||
c.Cols = append(c.Cols, ChartCol{X: chartW * float64(j) / float64(n), W: chartW / float64(n), Tip: tip.String()})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// downsample averages vals into n columns, ignoring gaps.
|
||||
func downsample(vals []float64, n int, scale float64) []float64 {
|
||||
if scale == 0 {
|
||||
scale = 1
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for j := range out {
|
||||
lo, hi := j*len(vals)/n, (j+1)*len(vals)/n
|
||||
sum, cnt := 0.0, 0
|
||||
for _, v := range vals[lo:max(hi, lo+1)] {
|
||||
if !math.IsNaN(v) {
|
||||
sum += v
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
out[j] = math.NaN()
|
||||
if cnt > 0 {
|
||||
out[j] = sum / float64(cnt) * scale
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// niceCeil rounds up to 1, 2 or 5 times a power of ten.
|
||||
func niceCeil(v float64) float64 {
|
||||
if v <= 0 || math.IsNaN(v) {
|
||||
return 1
|
||||
}
|
||||
p := math.Pow(10, math.Floor(math.Log10(v)))
|
||||
for _, m := range []float64{1, 2, 5, 10} {
|
||||
if v <= m*p {
|
||||
return m * p
|
||||
}
|
||||
}
|
||||
return 10 * p
|
||||
}
|
||||
|
||||
func formatValue(v float64, unit string) string {
|
||||
if math.IsNaN(v) {
|
||||
return "–"
|
||||
}
|
||||
switch unit {
|
||||
case "bit/s":
|
||||
return formatSI(v, "bit/s")
|
||||
case "ms":
|
||||
if v < 10 {
|
||||
return fmt.Sprintf("%.1f ms", v)
|
||||
}
|
||||
return fmt.Sprintf("%.0f ms", v)
|
||||
case "%":
|
||||
if v > 0 && v < 10 {
|
||||
return fmt.Sprintf("%.1f %%", v)
|
||||
}
|
||||
return fmt.Sprintf("%.0f %%", v)
|
||||
case "°C":
|
||||
return fmt.Sprintf("%.0f °C", v)
|
||||
}
|
||||
return fmt.Sprintf("%g %s", v, unit)
|
||||
}
|
||||
|
||||
func formatSI(v float64, unit string) string {
|
||||
for _, p := range []struct {
|
||||
f float64
|
||||
s string
|
||||
}{{1e9, "G"}, {1e6, "M"}, {1e3, "k"}} {
|
||||
if v >= p.f {
|
||||
x := v / p.f
|
||||
if x < 10 {
|
||||
return fmt.Sprintf("%.1f %s%s", x, p.s, unit)
|
||||
}
|
||||
return fmt.Sprintf("%.0f %s%s", x, p.s, unit)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%.0f %s", v, unit)
|
||||
}
|
||||
|
||||
func formatBytes(v uint64) string {
|
||||
f := float64(v)
|
||||
for _, p := range []struct {
|
||||
f float64
|
||||
s string
|
||||
}{{1 << 40, "TiB"}, {1 << 30, "GiB"}, {1 << 20, "MiB"}, {1 << 10, "KiB"}} {
|
||||
if f >= p.f {
|
||||
return fmt.Sprintf("%.1f %s", f/p.f, p.s)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d B", v)
|
||||
}
|
||||
|
||||
// formatAge is a rough "3d 4h", "2h 5m", "4m".
|
||||
func formatAge(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return "<1m"
|
||||
}
|
||||
d = d.Round(time.Minute)
|
||||
days, hours, mins := int(d.Hours())/24, int(d.Hours())%24, int(d.Minutes())%60
|
||||
switch {
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
default:
|
||||
return fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
}
|
||||
|
||||
type dashboard struct {
|
||||
Range string
|
||||
Ranges []string
|
||||
Interval int // seconds between live updates
|
||||
Snap Snapshot
|
||||
Leases []Lease
|
||||
Events []Event
|
||||
Charts []Chart
|
||||
Prefs Prefs
|
||||
}
|
||||
|
||||
func (a *app) dashboardData(rangeName string) dashboard {
|
||||
rg := findRange(rangeName)
|
||||
now := time.Now()
|
||||
s, _ := loadSettings(a.sys.settingsPath())
|
||||
f := s.form()
|
||||
d := dashboard{Range: rg.Name, Interval: 5, Snap: a.metrics.Snapshot(), Prefs: a.sys.loadPrefs()}
|
||||
// Newest first, the last few.
|
||||
ev := d.Snap.Uplink.Events
|
||||
d.Events = make([]Event, 0, 10)
|
||||
for i := len(ev) - 1; i >= 0 && len(d.Events) < 10; i-- {
|
||||
d.Events = append(d.Events, ev[i])
|
||||
}
|
||||
if rg.Tier > 0 {
|
||||
d.Interval = 60
|
||||
}
|
||||
for _, r := range ranges {
|
||||
d.Ranges = append(d.Ranges, r.Name)
|
||||
}
|
||||
d.Leases = readLeases(a.metrics.leasesPath, f.LANs, now)
|
||||
|
||||
traffic := func(title, dev string) Chart {
|
||||
return a.chart(title, "bit/s", rg, now, []seriesSpec{
|
||||
{"if:" + dev + ":rx", "down", 8},
|
||||
{"if:" + dev + ":tx", "up", 8},
|
||||
})
|
||||
}
|
||||
up := uplinkDevice(f.WAN)
|
||||
d.Charts = append(d.Charts, traffic("Internet ("+up+")", up))
|
||||
for _, l := range f.LANs {
|
||||
dev := l.Interface
|
||||
c := traffic("LAN "+l.Name+" ("+dev+")", dev)
|
||||
// From the LAN's side: what the router sends is what the LAN downloads.
|
||||
c.Lines[0].Label, c.Lines[1].Label = "to router", "from router"
|
||||
d.Charts = append(d.Charts, c)
|
||||
}
|
||||
for _, wg := range wireguardDevices(s) {
|
||||
d.Charts = append(d.Charts, traffic("Tunnel "+wg, wg))
|
||||
}
|
||||
var rtt, loss []seriesSpec
|
||||
for _, t := range d.Prefs.PingTargets {
|
||||
rtt = append(rtt, seriesSpec{"ping:" + t + ":rtt", t, 1})
|
||||
loss = append(loss, seriesSpec{"ping:" + t + ":loss", t, 1})
|
||||
}
|
||||
if len(rtt) > 0 {
|
||||
d.Charts = append(d.Charts, a.chart("Latency", "ms", rg, now, rtt), a.chart("Packet loss", "%", rg, now, loss))
|
||||
}
|
||||
d.Charts = append(d.Charts,
|
||||
a.chart("CPU and memory", "%", rg, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}}))
|
||||
var temps []seriesSpec
|
||||
for _, t := range d.Snap.System.Sensors {
|
||||
temps = append(temps, seriesSpec{"temp:" + t.Key, t.Label, 1})
|
||||
}
|
||||
if len(temps) > 0 {
|
||||
d.Charts = append(d.Charts, a.chart("Temperature", "°C", rg, now, temps))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func wireguardDevices(s Settings) []string {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
wgs, _ := router["wireguard"].(map[string]any)
|
||||
var devs []string
|
||||
for name, v := range wgs {
|
||||
dev := "wg-" + name
|
||||
if m, ok := v.(map[string]any); ok && str(m, "interface") != "" {
|
||||
dev = str(m, "interface")
|
||||
}
|
||||
devs = append(devs, dev)
|
||||
}
|
||||
slices.Sort(devs)
|
||||
return devs
|
||||
}
|
||||
|
||||
// dashboardFragment is the live part of the overview, fetched by dashboard.js.
|
||||
func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{CSRF: sess.csrf, LoggedIn: true, Data: overviewData{Dashboard: a.dashboardData(r.URL.Query().Get("range"))}}
|
||||
if err := a.page("overview").ExecuteTemplate(w, "dashboard", p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// savePrefs takes the Advanced page's two small forms: the ping targets, or
|
||||
// the theme.
|
||||
func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
_ = r.ParseForm()
|
||||
prefs := a.sys.loadPrefs()
|
||||
what := "Ping targets"
|
||||
var err error
|
||||
if r.PostForm.Has("theme") {
|
||||
what = "Appearance"
|
||||
switch t := r.PostFormValue("theme"); t {
|
||||
case "", "light", "dark":
|
||||
prefs.Theme = t
|
||||
default:
|
||||
err = fmt.Errorf("unknown theme %q", t)
|
||||
}
|
||||
} else {
|
||||
var targets []string
|
||||
if targets, err = parsePingTargets(r.PostFormValue("pingTargets")); err == nil {
|
||||
prefs.PingTargets = targets
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = a.sys.savePrefs(prefs)
|
||||
}
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" not saved: "+err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" saved."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// backup streams the state directory (settings, secrets, certificate, metric
|
||||
// history) as router-backup-<host>-<time>.tar.gz; router-setup can install
|
||||
// from it.
|
||||
func (a *app) backup(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
a.metrics.Save()
|
||||
s, _ := loadSettings(a.sys.settingsPath())
|
||||
host := str(s, "system", "hostName")
|
||||
if host == "" {
|
||||
host = "router"
|
||||
}
|
||||
name := fmt.Sprintf("router-backup-%s-%s.tar.gz", host, time.Now().Format("20060102-1504"))
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
if err := writeBackup(w, a.sys.StateDir); err != nil {
|
||||
// Headers are gone; a truncated archive fails to extract.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
func writeBackup(w io.Writer, dir string) error {
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && !d.Type().IsRegular() {
|
||||
return nil // sockets, symlinks (e.g. a result link)
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, path)
|
||||
hdr, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr.Name = filepath.ToSlash(filepath.Join("router", rel))
|
||||
if d.IsDir() {
|
||||
hdr.Name += "/"
|
||||
}
|
||||
hdr.Uname, hdr.Gname, hdr.Uid, hdr.Gid = "", "", 0, 0
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.CopyN(tw, f, info.Size())
|
||||
return err
|
||||
})
|
||||
return errors.Join(err, tw.Close(), gz.Close())
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTierAveragesAndGaps(t *testing.T) {
|
||||
s := newStore()
|
||||
base := time.Unix(1_800_000_000, 0) // a multiple of every step
|
||||
for i := range 12 { // one minute of 5 s samples
|
||||
s.Add("x", base.Add(time.Duration(i)*5*time.Second), float64(i))
|
||||
}
|
||||
// A gap of 10 minutes, then one more sample.
|
||||
s.Add("x", base.Add(11*time.Minute), 100)
|
||||
|
||||
now := base.Add(11 * time.Minute)
|
||||
start, step, vals := s.Read("x", 0, now)
|
||||
if step != 5*time.Second || len(vals) != 720 {
|
||||
t.Fatalf("step %v, %d values", step, len(vals))
|
||||
}
|
||||
at := func(ts time.Time) float64 { return vals[int(ts.Sub(start)/step)] }
|
||||
if at(base) != 0 || at(base.Add(55*time.Second)) != 11 {
|
||||
t.Fatalf("5 s values: %v %v", at(base), at(base.Add(55*time.Second)))
|
||||
}
|
||||
if !math.IsNaN(at(base.Add(5 * time.Minute))) {
|
||||
t.Fatal("the gap is not empty")
|
||||
}
|
||||
if at(now) != 100 || !math.IsNaN(at(base.Add(-5*time.Second))) {
|
||||
t.Fatal("latest bucket or before the first sample")
|
||||
}
|
||||
// The minute tier averages the first minute.
|
||||
start, step, vals = s.Read("x", 1, now)
|
||||
if v := vals[int(base.Sub(start)/step)]; v != 5.5 {
|
||||
t.Fatalf("minute average %v", v)
|
||||
}
|
||||
|
||||
// An hour later the 5 s ring has turned over: the old values are gone.
|
||||
later := now.Add(2 * time.Hour)
|
||||
s.Add("x", later, 1)
|
||||
_, _, vals = s.Read("x", 0, later)
|
||||
for i, v := range vals[:len(vals)-1] {
|
||||
if !math.IsNaN(v) {
|
||||
t.Fatalf("stale value %v at %d", v, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSurvivesSaving(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "m.gob")
|
||||
s := newStore()
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
s.Add("x", now, 42)
|
||||
s.SetConnected(true, now, "198.51.100.7")
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := loadStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, vals := l.Read("x", 0, now)
|
||||
connected, since, events := l.Uplink()
|
||||
if vals[len(vals)-1] != 42 || !connected || !since.Equal(now) || len(events) != 1 {
|
||||
t.Fatalf("loaded %v %v %v %v", vals[len(vals)-1], connected, since, events)
|
||||
}
|
||||
}
|
||||
|
||||
const leaseFile = `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
|
||||
192.168.10.101,52:54:00:12:34:56,01:52,3600,%d,1,0,0,laptop,0,,0
|
||||
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,old,name,0,,0
|
||||
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,phone,0,,0
|
||||
192.168.10.102,52:54:00:dd:ee:ff,01:52,0,%d,1,0,0,gone,0,,0
|
||||
192.168.10.103,52:54:00:dd:ee:00,01:52,3600,%d,1,0,0,expired,0,,0
|
||||
`
|
||||
|
||||
func TestReadLeases(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
future, past := now.Unix()+1800, now.Unix()-10
|
||||
path := filepath.Join(t.TempDir(), "dhcp4.leases")
|
||||
os.WriteFile(path, []byte(fmt.Sprintf(leaseFile, future, future, future, future, past)), 0o600)
|
||||
lans := []LANForm{{Name: "main", Address: "192.168.10.1"}}
|
||||
got := readLeases(path, lans, now)
|
||||
if len(got) != 2 || got[0].Address != "192.168.10.100" || got[0].Hostname != "phone" ||
|
||||
got[1].Hostname != "laptop" || got[1].LAN != "main" {
|
||||
t.Fatalf("leases: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfFrom(t *testing.T, page string) string {
|
||||
t.Helper()
|
||||
m := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
t.Fatalf("no CSRF token in:\n%s", page)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func TestParsePingTargets(t *testing.T) {
|
||||
got, err := parsePingTargets("gateway, 1.1.1.1\n2606:4700:4700::1111 1.1.1.1")
|
||||
if err != nil || strings.Join(got, " ") != "gateway 1.1.1.1 2606:4700:4700::1111" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if _, err := parsePingTargets("example.com"); err == nil {
|
||||
t.Fatal("host names are not accepted")
|
||||
}
|
||||
if _, err := parsePingTargets("1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4 1.1.1.5 1.1.1.6"); err == nil {
|
||||
t.Fatal("too many targets accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func login(t *testing.T, h http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login: %d", rec.Code)
|
||||
}
|
||||
return rec.Result().Cookies()[0]
|
||||
}
|
||||
|
||||
func TestDashboardRenders(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
now := time.Now()
|
||||
for i := range 20 {
|
||||
at := now.Add(time.Duration(i-20) * 5 * time.Second)
|
||||
a.metrics.store.Add("if:eth1:rx", at, 1e6)
|
||||
a.metrics.store.Add("if:eth1:tx", at, 2e5)
|
||||
}
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
for _, path := range []string{"/", "/?range=7d", "/dashboard?range=24h", "/advanced"} {
|
||||
rec := do(h, "GET", path, nil, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: %d\n%s", path, rec.Code, rec.Body)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if path == "/" && (!strings.Contains(body, "Internet (eth1)") || !strings.Contains(body, "8.0 Mbit/s") ||
|
||||
!strings.Contains(body, "/static/dashboard.js")) {
|
||||
t.Fatalf("overview lacks the traffic chart:\n%s", body)
|
||||
}
|
||||
if strings.HasPrefix(path, "/dashboard") && strings.Contains(body, "<html") {
|
||||
t.Fatal("the fragment is a whole page")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackup(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
page := do(h, "GET", "/advanced", nil, cookie).Body.String()
|
||||
token := csrfFrom(t, page)
|
||||
rec := do(h, "POST", "/backup", url.Values{"csrf": {token}}, cookie)
|
||||
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), `attachment; filename="router-backup-`) {
|
||||
t.Fatalf("backup: %d %v", rec.Code, rec.Header())
|
||||
}
|
||||
gz, err := gzip.NewReader(rec.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := map[string]string{}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := io.ReadAll(tr)
|
||||
files[hdr.Name] = string(data)
|
||||
}
|
||||
settings, _ := os.ReadFile(sys.settingsPath())
|
||||
if files["router/config/settings.json"] != string(settings) || !strings.HasPrefix(files["router/webui-password"], "pbkdf2") {
|
||||
t.Fatalf("backup content: %v", keys(files))
|
||||
}
|
||||
if _, ok := files["router/webui/metrics.gob"]; !ok {
|
||||
t.Fatalf("no metric history in the backup: %v", keys(files))
|
||||
}
|
||||
if rec := do(h, "POST", "/backup", url.Values{}, cookie); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("backup without CSRF token: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]string) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTheme(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
token := csrfFrom(t, do(h, "GET", "/advanced", nil, cookie).Body.String())
|
||||
has := func(path, want string, ck *http.Cookie) bool {
|
||||
return strings.Contains(do(h, "GET", path, nil, ck).Body.String(), want)
|
||||
}
|
||||
if has("/advanced", "data-theme", cookie) || !has("/login", "<body class=\"bare\"", nil) {
|
||||
t.Fatal("default must follow the browser; the login page has no sidebar")
|
||||
}
|
||||
save := func(theme string) {
|
||||
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "theme": {theme}}, cookie)
|
||||
}
|
||||
save("dark")
|
||||
if sys.loadPrefs().Theme != "dark" || !has("/", `data-theme="dark"`, cookie) || !has("/login", `data-theme="dark"`, nil) {
|
||||
t.Fatal("dark not applied")
|
||||
}
|
||||
save("neon")
|
||||
if sys.loadPrefs().Theme != "dark" {
|
||||
t.Fatal("an unknown theme was stored")
|
||||
}
|
||||
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "pingTargets": {"1.1.1.1"}}, cookie)
|
||||
if sys.loadPrefs().Theme != "dark" {
|
||||
t.Fatal("saving the ping targets dropped the theme")
|
||||
}
|
||||
save("")
|
||||
if has("/advanced", "data-theme", cookie) {
|
||||
t.Fatal("automatic not restored")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DDNSForm is the dynamic DNS section of the settings (router.ddns).
|
||||
type DDNSForm struct {
|
||||
Enabled bool
|
||||
Provider string // a connection of router.dnsProviders
|
||||
Zone string
|
||||
Domains string // one per line or comma separated
|
||||
IPv4 bool
|
||||
IPv6 bool
|
||||
SSL bool
|
||||
Interval string
|
||||
}
|
||||
|
||||
var (
|
||||
protocolRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||
ddnsHostRe = regexp.MustCompile(`^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
intervalRe = regexp.MustCompile(`^[0-9]+ ?(s|sec|m|min|h)$`)
|
||||
serverRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._/-]*)?$`)
|
||||
)
|
||||
|
||||
func (s Settings) ddns() DDNSForm {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
d, _ := router["ddns"].(map[string]any)
|
||||
flag := func(k string, def bool) bool {
|
||||
if v, ok := d[k].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
f := DDNSForm{
|
||||
Enabled: flag("enable", false), Provider: str(d, "provider"), Zone: str(d, "zone"),
|
||||
Domains: strings.Join(stringList(d, "domains"), "\n"), IPv4: flag("ipv4", true), IPv6: flag("ipv6", false),
|
||||
SSL: flag("ssl", true), Interval: str(d, "interval"),
|
||||
}
|
||||
if f.Interval == "" {
|
||||
f.Interval = "5min"
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// applyDDNS writes the form into the settings.
|
||||
func (s Settings) applyDDNS(f DDNSForm) error {
|
||||
errs := FormErrors{}
|
||||
f.Provider, f.Zone, f.Interval = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Zone), strings.TrimSpace(f.Interval)
|
||||
var domains []any
|
||||
for _, d := range splitList(f.Domains) {
|
||||
if !ddnsHostRe.MatchString(d) {
|
||||
errs["domains"] = d + " is not a host name"
|
||||
break
|
||||
}
|
||||
domains = append(domains, strings.ToLower(d))
|
||||
}
|
||||
if f.Provider != "" {
|
||||
p, ok := s.dnsProviderMap()[f.Provider].(map[string]any)
|
||||
switch {
|
||||
case !ok:
|
||||
errs["provider"] = "choose one of the DNS providers"
|
||||
case !slices.Contains(ddnsTypes, str(p, "type")):
|
||||
errs["provider"] = "dynamic DNS does not know " + str(p, "type") + "; it is for certificates"
|
||||
case str(p, "secretFile") == "":
|
||||
errs["provider"] = "this account has no password or token (add it on the DNS providers page)"
|
||||
}
|
||||
}
|
||||
if f.Interval != "" && !intervalRe.MatchString(f.Interval) {
|
||||
errs["interval"] = "like 5min, 30s or 1h"
|
||||
}
|
||||
if f.Enabled {
|
||||
if f.Provider == "" {
|
||||
errs["provider"] = "choose a DNS provider (add one on the DNS providers page)"
|
||||
}
|
||||
if len(domains) == 0 {
|
||||
errs["domains"] = "name at least one host"
|
||||
}
|
||||
if !f.IPv4 && !f.IPv6 {
|
||||
errs["ipv4"] = "choose IPv4, IPv6 or both"
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
d := obj(obj(s, "router"), "ddns")
|
||||
d["enable"] = f.Enabled
|
||||
d["ipv4"], d["ipv6"], d["ssl"] = f.IPv4, f.IPv6, f.SSL
|
||||
if f.Interval != "" {
|
||||
d["interval"] = f.Interval
|
||||
}
|
||||
set := func(k, v string) {
|
||||
if v == "" {
|
||||
delete(d, k)
|
||||
} else {
|
||||
d[k] = v
|
||||
}
|
||||
}
|
||||
set("provider", f.Provider)
|
||||
set("zone", f.Zone)
|
||||
if len(domains) > 0 {
|
||||
d["domains"] = domains
|
||||
} else {
|
||||
delete(d, "domains")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DDNSStatus is what ddclient last did.
|
||||
type DDNSStatus struct {
|
||||
State string // "inactive", "active", ... of ddclient.service
|
||||
Last string // when it last ran
|
||||
Log string
|
||||
}
|
||||
|
||||
func (s System) ddnsStatus() DDNSStatus {
|
||||
st := DDNSStatus{State: unitProperty("ddclient.service", "ActiveState"),
|
||||
Last: unitProperty("ddclient.service", "ExecMainExitTimestamp")}
|
||||
st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "20", "-u", "ddclient.service")
|
||||
return st
|
||||
}
|
||||
|
||||
type ddnsData struct {
|
||||
Form DDNSForm
|
||||
Errors FormErrors
|
||||
Status DDNSStatus
|
||||
Providers []DNSProvider
|
||||
}
|
||||
|
||||
func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "Dynamic DNS", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "ddns", p)
|
||||
return
|
||||
}
|
||||
p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)}
|
||||
a.render(w, "ddns", p)
|
||||
}
|
||||
|
||||
func (a *app) ddnsSave(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "Dynamic DNS", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "ddns", p)
|
||||
return
|
||||
}
|
||||
v := r.PostFormValue
|
||||
f := DDNSForm{Enabled: v("enabled") == "on", Provider: v("provider"), Zone: v("zone"), Domains: v("domains"),
|
||||
IPv4: v("ipv4") == "on", IPv6: v("ipv6") == "on", SSL: v("ssl") == "on", Interval: v("interval")}
|
||||
if err := s.applyDDNS(f); err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = ddnsData{Form: f, Errors: fe, Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "ddns", p)
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "ddns", p)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withProvider adds a connection to the settings.
|
||||
func withProvider(s Settings, name, typ string) Settings {
|
||||
obj(obj(s, "router"), "dnsProviders")[name] = map[string]any{"type": typ, "secretFile": "/var/lib/router/dns-secrets/" + name}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDDNSRoundTrip(t *testing.T) {
|
||||
s := withProvider(mustParse(t), "cf", "cloudflare")
|
||||
if f := s.ddns(); f.Enabled || f.Provider != "" || f.Interval != "5min" || !f.IPv4 || f.IPv6 {
|
||||
t.Fatalf("defaults = %+v", f)
|
||||
}
|
||||
f := DDNSForm{Enabled: true, Provider: "cf", Zone: "example.org",
|
||||
Domains: "Home.example.org, *.k8s.example.org\nvpn.example.org", IPv4: true, IPv6: true, SSL: true, Interval: "10min"}
|
||||
if err := s.applyDDNS(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(mustMarshal(t, s))
|
||||
for _, want := range []string{`"provider": "cf"`, `"home.example.org"`, `"*.k8s.example.org"`, `"ipv6": true`, `"zone": "example.org"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("%s missing:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
got := s.ddns()
|
||||
if got.Provider != "cf" || got.Domains != "home.example.org\n*.k8s.example.org\nvpn.example.org" {
|
||||
t.Errorf("read back = %+v", got)
|
||||
}
|
||||
f.Enabled = false // switching off keeps the rest
|
||||
if err := s.applyDDNS(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.ddns(); got.Enabled || got.Zone != "example.org" || got.Provider != "cf" {
|
||||
t.Errorf("after switching off = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDDNSIsChecked(t *testing.T) {
|
||||
good := DDNSForm{Enabled: true, Provider: "cf", Domains: "a.example.org", IPv4: true, SSL: true, Interval: "5min"}
|
||||
for name, mod := range map[string]func(*DDNSForm){
|
||||
"domains": func(f *DDNSForm) { f.Domains = "not a host!" },
|
||||
"provider": func(f *DDNSForm) { f.Provider = "nothing" },
|
||||
"interval": func(f *DDNSForm) { f.Interval = "soon" },
|
||||
"ipv4": func(f *DDNSForm) { f.IPv4 = false },
|
||||
} {
|
||||
f := good
|
||||
mod(&f)
|
||||
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil || err.(FormErrors)[name] == "" {
|
||||
t.Errorf("%s accepted: %v", name, err)
|
||||
}
|
||||
}
|
||||
f := good
|
||||
f.Provider = ""
|
||||
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil || err.(FormErrors)["provider"] == "" {
|
||||
t.Errorf("enabled without an account accepted: %v", err)
|
||||
}
|
||||
f = good
|
||||
f.Domains = ""
|
||||
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil {
|
||||
t.Error("enabled without names accepted")
|
||||
}
|
||||
f.Enabled, f.Provider = false, ""
|
||||
if err := mustParse(t).applyDDNS(f); err != nil {
|
||||
t.Errorf("disabled needs neither names nor an account: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSProvidersRoundTrip(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
dir := t.TempDir()
|
||||
rows := []DNSProvider{
|
||||
{Name: "CF", Type: "Cloudflare", Username: "token", Secret: " s3cret\n", New: true},
|
||||
{Name: "dyn", Type: "dyndns2", Server: "dynupdate.no-ip.com", Username: "u", Secret: "p", New: true},
|
||||
{New: true, Type: "cloudflare"}, // the empty add row
|
||||
}
|
||||
writes, deletes, err := s.applyDNSProviders(rows, dir)
|
||||
if err != nil || len(writes) != 2 || len(deletes) != 0 {
|
||||
t.Fatalf("writes=%v deletes=%v err=%v", writes, deletes, err)
|
||||
}
|
||||
if writes[0].Path != filepath.Join(dir, "cf") || writes[0].Secret != "s3cret" {
|
||||
t.Errorf("writes = %+v", writes)
|
||||
}
|
||||
out := string(mustMarshal(t, s))
|
||||
if strings.Contains(out, "s3cret") || !strings.Contains(out, `"secretFile": "`+dir+`/cf"`) || !strings.Contains(out, `"server": "dynupdate.no-ip.com"`) {
|
||||
t.Errorf("settings:\n%s", out)
|
||||
}
|
||||
// Edited without a new secret: the file stays. A used connection cannot go.
|
||||
obj(obj(s, "router"), "ddns")["provider"] = "cf"
|
||||
rows = []DNSProvider{{Name: "cf", Type: "cloudflare", Username: "token"}, {Name: "dyn", Type: "dyndns2", Remove: true}}
|
||||
if writes, deletes, err = s.applyDNSProviders(rows, dir); err != nil || len(writes) != 0 || len(deletes) != 1 {
|
||||
t.Fatalf("writes=%v deletes=%v err=%v", writes, deletes, err)
|
||||
}
|
||||
rows = []DNSProvider{{Name: "cf", Type: "cloudflare", Remove: true}}
|
||||
if _, _, err = s.applyDNSProviders(rows, dir); err == nil || !strings.Contains(err.(FormErrors)["prov[0].name"], "Dynamic DNS") {
|
||||
t.Errorf("a connection in use was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSProvidersAreChecked(t *testing.T) {
|
||||
good := DNSProvider{Name: "cf", Type: "cloudflare", Secret: "x", New: true}
|
||||
for name, mod := range map[string]func(*DNSProvider){
|
||||
"name": func(r *DNSProvider) { r.Name = "Not A Name!" },
|
||||
"type": func(r *DNSProvider) { r.Type = "no way" },
|
||||
"server": func(r *DNSProvider) { r.Server = "https://x.example" },
|
||||
"secret": func(r *DNSProvider) { r.Secret = "" },
|
||||
} {
|
||||
r := good
|
||||
mod(&r)
|
||||
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{r}, "/x"); err == nil || err.(FormErrors)["prov[0]."+name] == "" {
|
||||
t.Errorf("%s accepted: %v", name, err)
|
||||
}
|
||||
}
|
||||
s := withProvider(mustParse(t), "cf", "cloudflare")
|
||||
if _, _, err := s.applyDNSProviders([]DNSProvider{good}, "/x"); err == nil {
|
||||
t.Error("a new connection with a taken name accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSProvidersAndDDNSPages(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/dnsproviders", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `name="providers" value="1"`) {
|
||||
t.Fatalf("page:\n%s", body)
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
form := url.Values{"csrf": {token}, "providers": {"1"}, "prov.0.new": {"1"}, "prov.0.name": {"cf"}, "prov.0.type": {"cloudflare"}, "prov.0.username": {"token"}}
|
||||
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "API token") {
|
||||
t.Fatalf("no secret: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
form.Set("prov.0.secret", "tok3n")
|
||||
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
path := filepath.Join(sys.StateDir, "dns-secrets", "cf")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != "tok3n" {
|
||||
t.Fatalf("secret file: %q %v", data, err)
|
||||
}
|
||||
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("mode %v", fi.Mode())
|
||||
}
|
||||
if body := do(h, "GET", "/dnsproviders", nil, cookie).Body.String(); strings.Contains(body, "tok3n") || !strings.Contains(body, "leave empty to keep") {
|
||||
t.Errorf("the page shows the secret or lacks the hint:\n%s", body)
|
||||
}
|
||||
// Dynamic DNS picks the connection.
|
||||
body = do(h, "GET", "/ddns", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `<option value="cf"`) {
|
||||
t.Fatalf("the account is not offered:\n%s", body)
|
||||
}
|
||||
token = regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
dd := url.Values{"csrf": {token}, "enabled": {"on"}, "provider": {"cf"}, "zone": {"example.org"}, "domains": {"home.example.org"},
|
||||
"ipv4": {"on"}, "ssl": {"on"}, "interval": {"5min"}}
|
||||
if rec := do(h, "POST", "/ddns", dd, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if f := s.ddns(); !f.Enabled || f.Provider != "cf" || f.Domains != "home.example.org" {
|
||||
t.Fatalf("not saved: %+v", f)
|
||||
}
|
||||
// ... and then it cannot be removed.
|
||||
form = url.Values{"csrf": {token}, "providers": {"1"}, "prov.0.name": {"cf"}, "prov.0.type": {"cloudflare"}, "prov.0.remove": {"on"}}
|
||||
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "used by Dynamic DNS") {
|
||||
t.Fatalf("remove: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentProviders(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
dir := t.TempDir()
|
||||
rows := []DNSProvider{{Name: "aws", Type: "route53", Env: "AWS_ACCESS_KEY_ID=a\r\nAWS_SECRET_ACCESS_KEY=b\n", New: true}}
|
||||
writes, _, err := s.applyDNSProviders(rows, dir)
|
||||
if err != nil || len(writes) != 1 || writes[0].Path != filepath.Join(dir, "aws.env") || writes[0].Secret != "AWS_ACCESS_KEY_ID=a\nAWS_SECRET_ACCESS_KEY=b\n" {
|
||||
t.Fatalf("writes=%+v err=%v", writes, err)
|
||||
}
|
||||
out := string(mustMarshal(t, s))
|
||||
if !strings.Contains(out, `"environmentFile"`) || strings.Contains(out, "secretFile") || strings.Contains(out, "AWS_SECRET") {
|
||||
t.Errorf("settings:\n%s", out)
|
||||
}
|
||||
// Certificates accept it; dynamic DNS does not know route53.
|
||||
good := ACMEForm{Enabled: true, Provider: "aws", Domains: "r.example.org", Email: "a@b.c", Agree: true}
|
||||
if err := s.applyACME(good); err != nil {
|
||||
t.Errorf("certificate through route53: %v", err)
|
||||
}
|
||||
if err := s.applyDDNS(DDNSForm{Enabled: true, Provider: "aws", Domains: "a.example.org", IPv4: true}); err == nil || err.(FormErrors)["provider"] == "" {
|
||||
t.Errorf("dynamic DNS through route53 accepted: %v", err)
|
||||
}
|
||||
// A single-token provider needs the token, not variables.
|
||||
cf := withProvider(mustParse(t), "cf", "cloudflare")
|
||||
delete(cf.dnsProviderMap()["cf"].(map[string]any), "secretFile")
|
||||
if err := cf.applyACME(ACMEForm{Enabled: true, Provider: "cf", Domains: "r.example.org", Email: "a@b.c", Agree: true}); err == nil {
|
||||
t.Error("cloudflare without a token accepted")
|
||||
}
|
||||
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "route53", Env: "not variables", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].env"] == "" {
|
||||
t.Errorf("bad variables accepted: %v", err)
|
||||
}
|
||||
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "nonesuch", Secret: "s", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].type"] == "" {
|
||||
t.Errorf("an unknown provider accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDevAssetsAreReloaded(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.CopyFS(dir, os.DirFS(".")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := assets
|
||||
assets = os.DirFS(dir)
|
||||
defer func() { assets = old }()
|
||||
|
||||
sys := System{ConfigDir: filepath.Join(dir, "config"), StateDir: dir}
|
||||
a := newApp(sys, newCollector(sys, ""), true)
|
||||
h := a.routes()
|
||||
if strings.Contains(do(h, "GET", "/login", nil, nil).Body.String(), "reloaded-marker") {
|
||||
t.Fatal("marker already there")
|
||||
}
|
||||
|
||||
login := filepath.Join(dir, "templates", "login.html")
|
||||
data, _ := os.ReadFile(login)
|
||||
i := strings.LastIndex(string(data), "{{end}}")
|
||||
os.WriteFile(login, []byte(string(data)[:i]+"<p>reloaded-marker</p>"+string(data)[i:]), 0o600)
|
||||
if !strings.Contains(do(h, "GET", "/login", nil, nil).Body.String(), "reloaded-marker") {
|
||||
t.Fatal("an edited template is not picked up")
|
||||
}
|
||||
|
||||
// A typo keeps the last good version.
|
||||
os.WriteFile(login, []byte("{{define \"content\"}}{{ broken"), 0o600)
|
||||
if !strings.Contains(do(h, "GET", "/login", nil, nil).Body.String(), "reloaded-marker") {
|
||||
t.Fatal("a broken template replaced the working one")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reservation is a fixed DHCP lease of a LAN, keyed by host name in the
|
||||
// settings (the name is also published in DNS).
|
||||
type Reservation struct {
|
||||
LAN string
|
||||
Name string
|
||||
MAC string
|
||||
Address string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
var (
|
||||
hostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
macRe = regexp.MustCompile(`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$`)
|
||||
)
|
||||
|
||||
// dhcpLANs returns the LANs that serve DHCP (reservations need it), sorted.
|
||||
func (s Settings) dhcpLANs() []string {
|
||||
var out []string
|
||||
for _, l := range s.form().LANs {
|
||||
if l.DHCP {
|
||||
out = append(out, l.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s Settings) reservations() []Reservation {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
lans, _ := router["lans"].(map[string]any)
|
||||
var out []Reservation
|
||||
for lan, v := range lans {
|
||||
l, _ := v.(map[string]any)
|
||||
dhcp, _ := l["dhcp"].(map[string]any)
|
||||
res, _ := dhcp["reservations"].(map[string]any)
|
||||
for name, rv := range res {
|
||||
r, _ := rv.(map[string]any)
|
||||
out = append(out, Reservation{LAN: lan, Name: name, MAC: str(r, "mac"), Address: str(r, "address")})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Reservation) int {
|
||||
if c := strings.Compare(a.LAN, b.LAN); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// applyReservations replaces the reservations with rows (an empty row, the
|
||||
// "add" row, is skipped). Everything else of the LANs stays as it is.
|
||||
func (s Settings) applyReservations(rows []Reservation) error {
|
||||
errs := FormErrors{}
|
||||
router := obj(s, "router")
|
||||
lans := obj(router, "lans")
|
||||
|
||||
type lanInfo struct {
|
||||
prefix netip.Prefix
|
||||
router netip.Addr
|
||||
dhcp bool
|
||||
}
|
||||
info := map[string]lanInfo{}
|
||||
for _, l := range s.form().LANs {
|
||||
a, ok := ipv4(l.Address)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bits := 24
|
||||
if n, ok, _ := optionalInt(l.Prefix, 8, 30); ok {
|
||||
bits = n
|
||||
}
|
||||
info[l.Name] = lanInfo{prefix: netip.PrefixFrom(a, bits).Masked(), router: a, dhcp: l.DHCP}
|
||||
}
|
||||
|
||||
names, macs, addrs := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
next := map[string]map[string]any{}
|
||||
for i, r := range rows {
|
||||
field := func(n string) string { return fmt.Sprintf("res[%d].%s", i, n) }
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.MAC = strings.ToLower(strings.TrimSpace(r.MAC))
|
||||
r.Address = strings.TrimSpace(r.Address)
|
||||
if r.Name == "" && r.MAC == "" && r.Address == "" {
|
||||
continue
|
||||
}
|
||||
if r.Remove {
|
||||
continue
|
||||
}
|
||||
li, ok := info[r.LAN]
|
||||
switch {
|
||||
case !ok:
|
||||
errs[field("lan")] = "choose a LAN"
|
||||
case !li.dhcp:
|
||||
errs[field("lan")] = "this LAN does not serve DHCP"
|
||||
}
|
||||
if !hostRe.MatchString(r.Name) {
|
||||
errs[field("name")] = "letters, digits and dashes only"
|
||||
} else if names[strings.ToLower(r.Name)] {
|
||||
errs[field("name")] = "used twice"
|
||||
}
|
||||
names[strings.ToLower(r.Name)] = true
|
||||
if !macRe.MatchString(r.MAC) {
|
||||
errs[field("mac")] = "like aa:bb:cc:dd:ee:ff"
|
||||
} else if macs[r.MAC] {
|
||||
errs[field("mac")] = "used twice"
|
||||
}
|
||||
macs[r.MAC] = true
|
||||
a, aok := ipv4(r.Address)
|
||||
switch {
|
||||
case !aok:
|
||||
errs[field("address")] = "an IPv4 address is required"
|
||||
case ok && !li.prefix.Contains(a):
|
||||
errs[field("address")] = "not inside " + li.prefix.String()
|
||||
case ok && a == li.router:
|
||||
errs[field("address")] = "the router's own address"
|
||||
case addrs[r.Address]:
|
||||
errs[field("address")] = "used twice"
|
||||
}
|
||||
addrs[r.Address] = true
|
||||
if next[r.LAN] == nil {
|
||||
next[r.LAN] = map[string]any{}
|
||||
}
|
||||
next[r.LAN][r.Name] = map[string]any{"mac": r.MAC, "address": a.String()}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
for name, v := range lans {
|
||||
l, _ := v.(map[string]any)
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if res := next[name]; len(res) > 0 {
|
||||
obj(l, "dhcp")["reservations"] = res
|
||||
} else if dhcp, ok := l["dhcp"].(map[string]any); ok {
|
||||
delete(dhcp, "reservations")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type dhcpData struct {
|
||||
Rows []Reservation
|
||||
Errors FormErrors
|
||||
LANs []string
|
||||
Clients []Lease
|
||||
}
|
||||
|
||||
// dhcpClients are the active leases that have no reservation yet.
|
||||
func (a *app) dhcpClients(s Settings) []Lease {
|
||||
if a.metrics == nil {
|
||||
return nil
|
||||
}
|
||||
reserved := map[string]bool{}
|
||||
for _, r := range s.reservations() {
|
||||
reserved[strings.ToLower(r.MAC)] = true
|
||||
}
|
||||
var out []Lease
|
||||
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
|
||||
if !reserved[strings.ToLower(l.MAC)] {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) dhcpPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DHCP", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dhcp", p)
|
||||
return
|
||||
}
|
||||
rows := s.reservations()
|
||||
// "Reserve" on a client prefills the row to add.
|
||||
add := Reservation{LAN: r.URL.Query().Get("lan"), Name: r.URL.Query().Get("name"),
|
||||
MAC: r.URL.Query().Get("mac"), Address: r.URL.Query().Get("address")}
|
||||
p.Data = dhcpData{Rows: append(rows, add), LANs: s.dhcpLANs(), Clients: a.dhcpClients(s)}
|
||||
a.render(w, "dhcp", p)
|
||||
}
|
||||
|
||||
func (a *app) dhcp(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DHCP", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dhcp", p)
|
||||
return
|
||||
}
|
||||
n, _ := strconv.Atoi(r.PostFormValue("rows"))
|
||||
var rows []Reservation
|
||||
for i := range min(n, 512) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("res.%d.%s", i, k)) }
|
||||
rows = append(rows, Reservation{LAN: v("lan"), Name: v("name"), MAC: v("mac"), Address: v("address"), Remove: v("remove") == "on"})
|
||||
}
|
||||
if err := s.applyReservations(rows); err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = dhcpData{Rows: rows, Errors: fe, LANs: s.dhcpLANs(), Clients: a.dhcpClients(s)}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "dhcp", p)
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "dhcp", p)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReservationsRoundTrip(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
rows := s.reservations()
|
||||
if len(rows) != 1 || rows[0] != (Reservation{LAN: "main", Name: "nas", MAC: "aa:bb:cc:dd:ee:ff", Address: "192.168.10.5"}) {
|
||||
t.Fatalf("reservations = %+v", rows)
|
||||
}
|
||||
rows = append(rows, Reservation{LAN: "main", Name: "printer", MAC: "AA:BB:CC:00:11:22", Address: "192.168.10.6"},
|
||||
Reservation{LAN: "main"}) // the empty add row
|
||||
if err := s.applyReservations(rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := str(s, "router", "lans", "main", "dhcp", "reservations", "printer", "mac"); got != "aa:bb:cc:00:11:22" {
|
||||
t.Errorf("mac = %q (should be lower case)", got)
|
||||
}
|
||||
// What else the LAN has survives.
|
||||
out, _ := s.marshal()
|
||||
for _, want := range []string{`"fd00::1/64"`, `"pool"`, `"wireguard"`} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Errorf("%s lost:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
rows[0].Remove = true
|
||||
rows[1].Remove = true
|
||||
if err := s.applyReservations(rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.reservations(); len(got) != 0 {
|
||||
t.Errorf("not removed: %+v", got)
|
||||
}
|
||||
if !strings.Contains(string(mustMarshal(t, s)), `"pool"`) {
|
||||
t.Error("the pool went with the reservations")
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, s Settings) []byte {
|
||||
t.Helper()
|
||||
out, err := s.marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestReservationsAreChecked(t *testing.T) {
|
||||
good := Reservation{LAN: "main", Name: "tv", MAC: "aa:bb:cc:dd:ee:01", Address: "192.168.10.7"}
|
||||
for name, mod := range map[string]func(*Reservation){
|
||||
"name": func(r *Reservation) { r.Name = "tv room" },
|
||||
"mac": func(r *Reservation) { r.MAC = "aa:bb:cc" },
|
||||
"address": func(r *Reservation) { r.Address = "192.168.11.7" },
|
||||
"router": func(r *Reservation) { r.Address = "192.168.10.1" },
|
||||
"lan": func(r *Reservation) { r.LAN = "nope" },
|
||||
"dupmac": func(r *Reservation) { r.MAC = "AA:BB:CC:DD:EE:FF" },
|
||||
"dupaddr": func(r *Reservation) { r.Address = "192.168.10.5" },
|
||||
"dupname": func(r *Reservation) { r.Name = "NAS" },
|
||||
} {
|
||||
s := mustParse(t)
|
||||
r := good
|
||||
mod(&r)
|
||||
rows := append(s.reservations(), r)
|
||||
if err := s.applyReservations(rows); err == nil {
|
||||
t.Errorf("%s: accepted %+v", name, r)
|
||||
}
|
||||
}
|
||||
s := mustParse(t)
|
||||
if err := s.applyReservations(append(s.reservations(), good)); err != nil {
|
||||
t.Errorf("a good reservation was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHCPPage(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
body := do(h, "GET", "/dhcp?lan=main&mac=aa:bb:cc:00:00:09&address=192.168.10.9&name=cam", nil, cookie).Body.String()
|
||||
for _, want := range []string{`value="nas"`, `value="aa:bb:cc:00:00:09"`, `value="cam"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page lacks %s:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
|
||||
// A lease of an unreserved client is offered for reservation.
|
||||
os.WriteFile(filepath.Join(sys.StateDir, "leases"), []byte(
|
||||
"address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id\n"+
|
||||
"192.168.10.120,aa:bb:cc:00:00:20,,3600,4102444800,1,0,0,phone,0,,0\n"), 0o600)
|
||||
if body := do(h, "GET", "/dhcp", nil, cookie).Body.String(); !strings.Contains(body, "aa:bb:cc:00:00:20") {
|
||||
t.Errorf("the active client is not listed:\n%s", body)
|
||||
}
|
||||
|
||||
form := url.Values{"csrf": {token}, "rows": {"2"},
|
||||
"res.0.lan": {"main"}, "res.0.name": {"nas"}, "res.0.mac": {"aa:bb:cc:dd:ee:ff"}, "res.0.address": {"192.168.10.5"},
|
||||
"res.1.lan": {"main"}, "res.1.name": {"cam"}, "res.1.mac": {"aa:bb:cc:00:00:09"}, "res.1.address": {"192.168.10.300"}}
|
||||
if rec := do(h, "POST", "/dhcp", form, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bad address: %d", rec.Code)
|
||||
}
|
||||
form.Set("res.1.address", "192.168.10.9")
|
||||
if rec := do(h, "POST", "/dhcp", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if str(s, "router", "lans", "main", "dhcp", "reservations", "cam", "address") != "192.168.10.9" {
|
||||
t.Fatalf("not saved: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceDropdownsAndVLANs(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
token := func() string {
|
||||
body := do(h, "GET", "/network", nil, cookie).Body.String()
|
||||
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
}
|
||||
|
||||
body := do(h, "GET", "/network", nil, cookie).Body.String()
|
||||
for _, want := range []string{`<select name="wan.interface">`, `<option value="eth1" selected>`, `<select name="lan.0.interface">`, `<option value="eth2" selected>`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("network page lacks %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `list="`) {
|
||||
t.Error("an interface is still a free text field")
|
||||
}
|
||||
|
||||
add := func(parent, id string) int {
|
||||
return do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "add": {"1"}, "parent": {parent}, "id": {id}}, cookie).Code
|
||||
}
|
||||
if add("eth2", "20") != http.StatusSeeOther || add("eth2", "99999") != http.StatusSeeOther {
|
||||
t.Fatal("add")
|
||||
}
|
||||
if got := sys.loadPrefs().VLANs; len(got) != 1 || got[0] != "eth2.20" {
|
||||
t.Fatalf("defined VLANs = %v", got)
|
||||
}
|
||||
body = do(h, "GET", "/network", nil, cookie).Body.String()
|
||||
if strings.Count(body, `<option value="eth2.20"`) != 3 { // the WAN, the LAN and the add-a-LAN row
|
||||
t.Errorf("the VLAN is not offered in both dropdowns:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `name="remove" value="eth2.20"`) {
|
||||
t.Error("an unused VLAN cannot be removed")
|
||||
}
|
||||
|
||||
// The ping targets keep the VLANs.
|
||||
do(h, "POST", "/prefs", url.Values{"csrf": {token()}, "pingTargets": {"1.1.1.1"}}, cookie)
|
||||
if len(sys.loadPrefs().VLANs) != 1 {
|
||||
t.Error("saving the ping targets dropped the VLANs")
|
||||
}
|
||||
|
||||
// Use it for a LAN: it is then in use and cannot be removed.
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
f := s.form()
|
||||
f.LANs[0].Interface = "eth2.20"
|
||||
if _, err := s.apply(f, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.save(sys.settingsPath())
|
||||
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
|
||||
if len(sys.loadPrefs().VLANs) != 1 {
|
||||
t.Error("a VLAN in use was removed")
|
||||
}
|
||||
f.LANs[0].Interface = "eth2"
|
||||
s.apply(f, "/pw")
|
||||
s.save(sys.settingsPath())
|
||||
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
|
||||
if len(sys.loadPrefs().VLANs) != 0 {
|
||||
t.Error("an unused VLAN was not removed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// diffLines is a line diff of two texts in the style of diff -u without the
|
||||
// hunk headers: " " unchanged lines near a change, "-" removed, "+" added.
|
||||
// It returns "" when the texts are equal.
|
||||
func diffLines(a, b string) string {
|
||||
x, y := strings.Split(a, "\n"), strings.Split(b, "\n")
|
||||
// Longest common subsequence table.
|
||||
lcs := make([][]int, len(x)+1)
|
||||
for i := range lcs {
|
||||
lcs[i] = make([]int, len(y)+1)
|
||||
}
|
||||
for i := len(x) - 1; i >= 0; i-- {
|
||||
for j := len(y) - 1; j >= 0; j-- {
|
||||
if x[i] == y[j] {
|
||||
lcs[i][j] = lcs[i+1][j+1] + 1
|
||||
} else {
|
||||
lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
type line struct {
|
||||
op byte
|
||||
text string
|
||||
}
|
||||
var all []line
|
||||
i, j := 0, 0
|
||||
for i < len(x) || j < len(y) {
|
||||
switch {
|
||||
case i < len(x) && j < len(y) && x[i] == y[j]:
|
||||
all = append(all, line{' ', x[i]})
|
||||
i++
|
||||
j++
|
||||
case i < len(x) && (j == len(y) || lcs[i+1][j] >= lcs[i][j+1]):
|
||||
all = append(all, line{'-', x[i]})
|
||||
i++
|
||||
default:
|
||||
all = append(all, line{'+', y[j]})
|
||||
j++
|
||||
}
|
||||
}
|
||||
// Keep two lines of context around changes.
|
||||
const ctx = 2
|
||||
keep := make([]bool, len(all))
|
||||
changed := false
|
||||
for k, l := range all {
|
||||
if l.op != ' ' {
|
||||
changed = true
|
||||
for d := -ctx; d <= ctx; d++ {
|
||||
if k+d >= 0 && k+d < len(all) {
|
||||
keep[k+d] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
skipped := false
|
||||
for k, l := range all {
|
||||
if !keep[k] {
|
||||
skipped = true
|
||||
continue
|
||||
}
|
||||
if skipped {
|
||||
out.WriteString("…\n")
|
||||
skipped = false
|
||||
}
|
||||
out.WriteString(string(l.op) + " " + l.text + "\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// pretty re-indents JSON with sorted keys, so two spellings of the same
|
||||
// settings compare equal.
|
||||
func pretty(data []byte) string {
|
||||
var v any
|
||||
if json.Unmarshal(data, &v) != nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
out, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// pendingChanges is the diff from the confirmed settings to the saved ones.
|
||||
func (s System) pendingChanges() string {
|
||||
cur, err1 := os.ReadFile(s.settingsPath())
|
||||
conf, err2 := os.ReadFile(s.confirmedPath())
|
||||
if err1 != nil || err2 != nil {
|
||||
return ""
|
||||
}
|
||||
return diffLines(pretty(conf), pretty(cur))
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DNSRecord is a row of the DNS page: a name and the addresses it answers
|
||||
// with (A for IPv4, AAAA for IPv6, as the addresses are). A name that starts
|
||||
// with "*." is a wildcard: the name below it and everything under it.
|
||||
type DNSRecord struct {
|
||||
Name string
|
||||
Addresses string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
var dnsNameRe = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*$`)
|
||||
|
||||
func (s Settings) dnsMap(key string) map[string]any {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
dns, _ := router["dns"].(map[string]any)
|
||||
m, _ := dns[key].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
func rowsOf(m map[string]any, prefix string) []DNSRecord {
|
||||
var out []DNSRecord
|
||||
for name, v := range m {
|
||||
addrs, _ := v.([]any)
|
||||
var list []string
|
||||
for _, a := range addrs {
|
||||
if x, ok := a.(string); ok {
|
||||
list = append(list, x)
|
||||
}
|
||||
}
|
||||
out = append(out, DNSRecord{Name: prefix + name, Addresses: strings.Join(list, ", ")})
|
||||
}
|
||||
slices.SortFunc(out, func(a, b DNSRecord) int {
|
||||
return strings.Compare(strings.TrimPrefix(a.Name, "*."), strings.TrimPrefix(b.Name, "*."))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// dnsRecords are the hosts and the wildcards (dns.hosts and dns.overrides).
|
||||
func (s Settings) dnsRecords() []DNSRecord {
|
||||
return append(rowsOf(s.dnsMap("hosts"), ""), rowsOf(s.dnsMap("overrides"), "*.")...)
|
||||
}
|
||||
|
||||
func (s Settings) forwardZones() []DNSRecord { return rowsOf(s.dnsMap("forwardZones"), "") }
|
||||
|
||||
func (s Settings) domain() string {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
dns, _ := router["dns"].(map[string]any)
|
||||
return str(dns, "domain")
|
||||
}
|
||||
|
||||
// addressList parses a row's addresses: plain IPs, no prefix length.
|
||||
func addressList(v string) ([]any, error) {
|
||||
items := splitList(v)
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("at least one IPv4 or IPv6 address")
|
||||
}
|
||||
out := make([]any, len(items))
|
||||
for i, a := range items {
|
||||
if !ipOK(a) {
|
||||
return nil, fmt.Errorf("%q is not an IP address", a)
|
||||
}
|
||||
out[i] = a
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// applyDNS replaces the records and forward zones. Names are checked like the
|
||||
// module does; a record named like a DHCP reservation is refused (the
|
||||
// reservation publishes it already).
|
||||
func (s Settings) applyDNS(records, zones []DNSRecord) error {
|
||||
errs := FormErrors{}
|
||||
domain := s.domain()
|
||||
reserved := map[string]bool{}
|
||||
for _, r := range s.reservations() {
|
||||
reserved[r.Name] = true
|
||||
}
|
||||
hosts, overrides, forward := map[string]any{}, map[string]any{}, map[string]any{}
|
||||
seen := map[string]bool{}
|
||||
for i, r := range records {
|
||||
field := func(n string) string { return fmt.Sprintf("rec[%d].%s", i, n) }
|
||||
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(r.Name)), ".")
|
||||
if name == "" && strings.TrimSpace(r.Addresses) == "" {
|
||||
continue
|
||||
}
|
||||
if r.Remove {
|
||||
continue
|
||||
}
|
||||
wild := strings.HasPrefix(name, "*.")
|
||||
base := strings.TrimPrefix(name, "*.")
|
||||
if len(base) > 253 || !dnsNameRe.MatchString(base) {
|
||||
errs[field("name")] = "a DNS name like nas.example.org, or *.example.org for everything below it"
|
||||
continue
|
||||
}
|
||||
addrs, err := addressList(r.Addresses)
|
||||
if err != nil {
|
||||
errs[field("addresses")] = err.Error()
|
||||
}
|
||||
// The key the module sees: a host without a dot lives in the domain.
|
||||
key := base
|
||||
if !wild && !strings.Contains(base, ".") {
|
||||
if domain == "" {
|
||||
errs[field("name")] = "has no dot: set a local domain on the Network page first"
|
||||
} else {
|
||||
key = base + "." + domain
|
||||
}
|
||||
}
|
||||
if seen[strconv.FormatBool(wild)+key] {
|
||||
errs[field("name")] = "used twice"
|
||||
}
|
||||
seen[strconv.FormatBool(wild)+key] = true
|
||||
if wild {
|
||||
overrides[base] = addrs
|
||||
} else {
|
||||
if reserved[base] {
|
||||
errs[field("name")] = "a DHCP reservation has this name and publishes it already"
|
||||
}
|
||||
hosts[base] = addrs
|
||||
}
|
||||
}
|
||||
for i, z := range zones {
|
||||
field := func(n string) string { return fmt.Sprintf("zone[%d].%s", i, n) }
|
||||
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(z.Name)), ".")
|
||||
if name == "" && strings.TrimSpace(z.Addresses) == "" {
|
||||
continue
|
||||
}
|
||||
if z.Remove {
|
||||
continue
|
||||
}
|
||||
if len(name) > 253 || !dnsNameRe.MatchString(name) {
|
||||
errs[field("name")] = "a domain like branch.example.org"
|
||||
continue
|
||||
}
|
||||
addrs, err := addressList(z.Addresses)
|
||||
if err != nil {
|
||||
errs[field("addresses")] = err.Error()
|
||||
}
|
||||
if _, dup := forward[name]; dup {
|
||||
errs[field("name")] = "used twice"
|
||||
}
|
||||
forward[name] = addrs
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
dns := obj(obj(s, "router"), "dns")
|
||||
for key, m := range map[string]map[string]any{"hosts": hosts, "overrides": overrides, "forwardZones": forward} {
|
||||
if len(m) == 0 {
|
||||
delete(dns, key)
|
||||
} else {
|
||||
dns[key] = m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type dnsData struct {
|
||||
Records []DNSRecord
|
||||
Zones []DNSRecord
|
||||
Errors FormErrors
|
||||
Domain string
|
||||
Auto []string // published without a record here
|
||||
Dynamic bool // router.dns.dynamicLeases
|
||||
Dyn *dynStatus
|
||||
}
|
||||
|
||||
func (s Settings) dynamicLeases() bool {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
dns, _ := router["dns"].(map[string]any)
|
||||
return boolean(dns, false, "dynamicLeases")
|
||||
}
|
||||
|
||||
func (s Settings) setDynamicLeases(on bool) {
|
||||
dns := obj(obj(s, "router"), "dns")
|
||||
if on {
|
||||
dns["dynamicLeases"] = true
|
||||
} else {
|
||||
delete(dns, "dynamicLeases")
|
||||
}
|
||||
}
|
||||
|
||||
// dnsView is the page's data for settings s.
|
||||
func (a *app) dnsView(s Settings, records, zones []DNSRecord, errs FormErrors) dnsData {
|
||||
d := dnsData{Records: records, Zones: zones, Errors: errs, Domain: s.domain(), Auto: s.dnsAuto(), Dynamic: s.dynamicLeases()}
|
||||
if st, ok := readDynStatus(a.sys.leasesDNSDir()); ok {
|
||||
d.Dyn = &st
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (s Settings) dnsAuto() []string {
|
||||
var out []string
|
||||
domain := s.domain()
|
||||
name := func(n string) string {
|
||||
if domain != "" && !strings.Contains(n, ".") {
|
||||
return n + "." + domain
|
||||
}
|
||||
return n
|
||||
}
|
||||
if h := s.form().HostName; h != "" {
|
||||
out = append(out, name(h)+": this router")
|
||||
}
|
||||
for _, r := range s.reservations() {
|
||||
out = append(out, name(r.Name)+": "+r.Address+" (DHCP reservation)")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) dnsPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DNS", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg")}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
p.Data = a.dnsView(s, append(s.dnsRecords(), DNSRecord{}), append(s.forwardZones(), DNSRecord{}), nil)
|
||||
a.render(w, "dns", p)
|
||||
}
|
||||
|
||||
func dnsRows(r *http.Request, prefix string) []DNSRecord {
|
||||
n, _ := strconv.Atoi(r.PostFormValue(prefix + "s"))
|
||||
var out []DNSRecord
|
||||
for i := range min(n, 1024) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d.%s", prefix, i, k)) }
|
||||
out = append(out, DNSRecord{Name: v("name"), Addresses: v("addresses"), Remove: v("remove") == "on"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) dns(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DNS", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
records, zones := dnsRows(r, "rec"), dnsRows(r, "zone")
|
||||
dynamic := r.PostFormValue("dynamic") == "on"
|
||||
if err := s.applyDNS(records, zones); err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = a.dnsView(s, records, zones, fe)
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
s.setDynamicLeases(dynamic)
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withDomain(t *testing.T) Settings {
|
||||
t.Helper()
|
||||
s := mustParse(t)
|
||||
obj(obj(s, "router"), "dns")["domain"] = "home.example.org"
|
||||
obj(obj(s, "router"), "dns")["forwarders"] = []any{"1.1.1.1"}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDNSRecordsRoundTrip(t *testing.T) {
|
||||
s := withDomain(t)
|
||||
err := s.applyDNS([]DNSRecord{
|
||||
{Name: "files", Addresses: "192.168.168.20, fd00::20"},
|
||||
{Name: "photos.example.org", Addresses: "192.168.168.50"},
|
||||
{Name: "*.k8s.stinnesbeck.com", Addresses: "192.168.168.254"},
|
||||
{Name: "git.stinnesbeck.com.", Addresses: "10.43.162.50"}, // a trailing dot is fine
|
||||
{}, // the add row
|
||||
}, []DNSRecord{{Name: "tdf.stinnesbeck.com", Addresses: "10.1.0.254"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The wildcard is an override of the name; hosts stay hosts.
|
||||
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
|
||||
t.Fatalf("overrides = %v", s.dnsMap("overrides"))
|
||||
}
|
||||
if _, ok := s.dnsMap("hosts")["files"]; !ok || len(s.dnsMap("hosts")) != 3 {
|
||||
t.Errorf("hosts = %v", s.dnsMap("hosts"))
|
||||
}
|
||||
rows := s.dnsRecords()
|
||||
var names []string
|
||||
for _, r := range rows {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
if strings.Join(names, " ") != "files git.stinnesbeck.com photos.example.org *.k8s.stinnesbeck.com" {
|
||||
t.Errorf("rows = %v", names)
|
||||
}
|
||||
if r := rows[0]; r.Addresses != "192.168.168.20, fd00::20" {
|
||||
t.Errorf("files = %+v", r)
|
||||
}
|
||||
if z := s.forwardZones(); len(z) != 1 || z[0].Name != "tdf.stinnesbeck.com" {
|
||||
t.Errorf("zones = %+v", z)
|
||||
}
|
||||
// The rest of dns survives, and removing everything removes the keys.
|
||||
if s.domain() != "home.example.org" || str(s, "router", "dns", "forwarders") == "" && s["router"].(map[string]any)["dns"].(map[string]any)["forwarders"] == nil {
|
||||
t.Error("domain or forwarders lost")
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].Remove = true
|
||||
}
|
||||
if err := s.applyDNS(rows, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.dnsRecords()) != 0 || len(s.forwardZones()) != 0 || s.domain() == "" {
|
||||
t.Errorf("after removing: %v %v %q", s.dnsRecords(), s.forwardZones(), s.domain())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSRecordsAreChecked(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
rec DNSRecord
|
||||
want string
|
||||
}{
|
||||
"bad name": {DNSRecord{Name: "no good", Addresses: "10.0.0.1"}, "rec[0].name"},
|
||||
"wildcard mid": {DNSRecord{Name: "a.*.example.org", Addresses: "10.0.0.1"}, "rec[0].name"},
|
||||
"no address": {DNSRecord{Name: "files", Addresses: ""}, "rec[0].addresses"},
|
||||
"bad address": {DNSRecord{Name: "files", Addresses: "10.0.0.999"}, "rec[0].addresses"},
|
||||
"prefix": {DNSRecord{Name: "files", Addresses: "10.0.0.1/24"}, "rec[0].addresses"},
|
||||
// The sample has a DHCP reservation called nas, which publishes it.
|
||||
"reservation": {DNSRecord{Name: "nas", Addresses: "10.0.0.1"}, "rec[0].name"},
|
||||
} {
|
||||
s := withDomain(t)
|
||||
err := s.applyDNS([]DNSRecord{c.rec}, nil)
|
||||
if fe, ok := err.(FormErrors); !ok || fe[c.want] == "" {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// Two names that are the same once placed in the domain.
|
||||
s := withDomain(t)
|
||||
if err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}, {Name: "web.home.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
|
||||
t.Error("web and web.home.example.org were both accepted")
|
||||
}
|
||||
// A short name needs a domain; a name cannot be there twice.
|
||||
s = mustParse(t)
|
||||
err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}}, nil)
|
||||
if fe, ok := err.(FormErrors); !ok || !strings.Contains(fe["rec[0].name"], "local domain") {
|
||||
t.Errorf("short name without a domain: %v", err)
|
||||
}
|
||||
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "a.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
|
||||
t.Error("a name twice was accepted")
|
||||
}
|
||||
// A wildcard and a plain name of the same name are two different things.
|
||||
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "*.a.example.org", Addresses: "10.0.0.2"}}, nil); err != nil {
|
||||
t.Errorf("a name and its wildcard: %v", err)
|
||||
}
|
||||
if err := s.applyDNS(nil, []DNSRecord{{Name: "*.branch.test", Addresses: "10.0.0.1"}}); err == nil {
|
||||
t.Error("a wildcard forward zone was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSPage(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/dns", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, "Forward zones") || !strings.Contains(body, "DHCP reservation") {
|
||||
t.Fatalf("page:\n%s", body)
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
form := url.Values{"csrf": {token}, "recs": {"2"}, "zones": {"0"},
|
||||
"rec.0.name": {"*.k8s.stinnesbeck.com"}, "rec.0.addresses": {"192.168.168.254"},
|
||||
"rec.1.name": {"web"}, "rec.1.addresses": {"192.168.168.9"}}
|
||||
// No local domain in the sample: the short name is refused, nothing is saved.
|
||||
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("short name without domain: %d", rec.Code)
|
||||
}
|
||||
form.Set("rec.1.name", "web.stinnesbeck.com")
|
||||
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
|
||||
t.Errorf("settings: %v", s["router"])
|
||||
}
|
||||
if body := do(h, "GET", "/dns", nil, cookie).Body.String(); !strings.Contains(body, `value="*.k8s.stinnesbeck.com"`) {
|
||||
t.Error("the wildcard is not shown again")
|
||||
}
|
||||
// Saving the network form must keep them.
|
||||
net := do(h, "GET", "/network", nil, cookie).Body.String()
|
||||
nt := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(net)[1]
|
||||
nf := url.Values{"csrf": {nt}, "hostName": {"gw"}, "wan.interface": {"eth1"}, "wan.type": {"dhcp"}, "lans": {"1"},
|
||||
"lan.0.name": {"main"}, "lan.0.interface": {"eth2"}, "lan.0.address": {"192.168.10.1"}, "lan.0.dhcp": {"on"},
|
||||
"lan.0.poolStart": {"192.168.10.100"}, "lan.0.poolEnd": {"192.168.10.199"}}
|
||||
do(h, "POST", "/network", nf, cookie)
|
||||
s, _ = loadSettings(sys.settingsPath())
|
||||
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
|
||||
t.Error("the network form dropped the DNS records")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicLeasesOnThePage(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
a.sys.LeasesDNSDir = t.TempDir()
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
token := func() string {
|
||||
body := do(h, "GET", "/dns", nil, cookie).Body.String()
|
||||
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
}
|
||||
save := func(extra ...string) {
|
||||
form := url.Values{"csrf": {token()}, "recs": {"0"}, "zones": {"0"}}
|
||||
for i := 0; i < len(extra); i += 2 {
|
||||
form.Set(extra[i], extra[i+1])
|
||||
}
|
||||
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
save("dynamic", "on")
|
||||
if s, _ := loadSettings(sys.settingsPath()); !s.dynamicLeases() {
|
||||
t.Fatal("not switched on")
|
||||
}
|
||||
body := do(h, "GET", "/dns", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `name="dynamic" checked`) || !strings.Contains(body, "apply the settings so the service starts") {
|
||||
t.Errorf("the page after switching on:\n%s", body)
|
||||
}
|
||||
|
||||
// What the running service reports is shown.
|
||||
writeDynStatus(a.sys.LeasesDNSDir, dynStatus{
|
||||
Published: []DynRecord{{Name: "phone.home.example.org", Address: "192.168.1.101"}},
|
||||
Skipped: []DynSkip{{Hostname: "nas", Address: "192.168.1.102", Reason: "the configuration has a record with this name"}},
|
||||
Updated: time.Now(),
|
||||
})
|
||||
body = do(h, "GET", "/dns", nil, cookie).Body.String()
|
||||
for _, want := range []string{"phone.home.example.org", "192.168.1.101", "the configuration has a record with this name"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("status lacks %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
save() // unticked
|
||||
if s, _ := loadSettings(sys.settingsPath()); s.dynamicLeases() {
|
||||
t.Error("not switched off")
|
||||
}
|
||||
if _, ok := obj(obj(mustLoad(sys), "router"), "dns")["dynamicLeases"]; ok {
|
||||
t.Error("the key stays after switching off")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// providers.json is shared with the Nix modules: the ddclient protocols
|
||||
// (ddns), the lego DNS providers (acme) and those of them with a single token
|
||||
// (acmeToken), whose secret is that token; the others take lego's variables.
|
||||
//
|
||||
//go:embed providers.json
|
||||
var providersJSON []byte
|
||||
|
||||
var acmeTypes, ddnsTypes []string
|
||||
var acmeTokens map[string]string
|
||||
|
||||
func init() {
|
||||
var p struct {
|
||||
DDNS []string `json:"ddns"`
|
||||
ACME []string `json:"acme"`
|
||||
ACMEToken map[string]string `json:"acmeToken"`
|
||||
}
|
||||
if err := json.Unmarshal(providersJSON, &p); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ddnsTypes, acmeTypes, acmeTokens = p.DDNS, p.ACME, p.ACMEToken
|
||||
}
|
||||
|
||||
// TypeOption is an entry of the provider drop-down.
|
||||
type TypeOption struct {
|
||||
Name, Label string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// providerTypeOptions lists every supported provider, with what each can do;
|
||||
// a type the settings already have but the list lacks stays selectable.
|
||||
func providerTypeOptions(current string) []TypeOption {
|
||||
names := append([]string{}, ddnsTypes...)
|
||||
for _, t := range acmeTypes {
|
||||
if !slices.Contains(names, t) {
|
||||
names = append(names, t)
|
||||
}
|
||||
}
|
||||
if current != "" && !slices.Contains(names, current) {
|
||||
names = append(names, current)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []TypeOption
|
||||
for _, n := range names {
|
||||
var uses []string
|
||||
if slices.Contains(ddnsTypes, n) {
|
||||
uses = append(uses, "dynamic DNS")
|
||||
}
|
||||
if slices.Contains(acmeTypes, n) {
|
||||
uses = append(uses, "certificates")
|
||||
}
|
||||
label := n
|
||||
if len(uses) > 0 {
|
||||
label += " (" + strings.Join(uses, ", ") + ")"
|
||||
}
|
||||
out = append(out, TypeOption{Name: n, Label: label, Selected: n == current})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type providerRow struct {
|
||||
DNSProvider
|
||||
Options []TypeOption
|
||||
}
|
||||
|
||||
func providerRows(rows []DNSProvider) []providerRow {
|
||||
out := make([]providerRow, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = providerRow{r, providerTypeOptions(r.Type)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var envLineRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=.*$`)
|
||||
|
||||
var providerNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
|
||||
// DNSProvider is a connection to a DNS provider (router.dnsProviders).
|
||||
type DNSProvider struct {
|
||||
Name string
|
||||
Type string
|
||||
Username string
|
||||
Server string
|
||||
Secret string // form only, write-only: empty keeps the stored one
|
||||
SecretSet bool
|
||||
Env string // form only, write-only: lego's VARIABLE=value lines
|
||||
EnvSet bool
|
||||
New bool // form only: the name may be chosen
|
||||
Remove bool
|
||||
ACME bool // can order certificates
|
||||
}
|
||||
|
||||
func (s Settings) dnsProviderMap() map[string]any {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
m, _ := router["dnsProviders"].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
func (a *app) providerFileSet(s Settings, name, key string) bool {
|
||||
p, _ := s.dnsProviderMap()[name].(map[string]any)
|
||||
f := str(p, key)
|
||||
if f == "" {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stat(f)
|
||||
return err == nil && fi.Size() > 0
|
||||
}
|
||||
|
||||
func (a *app) dnsProviders(s Settings) []DNSProvider {
|
||||
var out []DNSProvider
|
||||
for name, v := range s.dnsProviderMap() {
|
||||
p, _ := v.(map[string]any)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, DNSProvider{Name: name, Type: str(p, "type"), Username: str(p, "username"), Server: str(p, "server"),
|
||||
SecretSet: a.providerFileSet(s, name, "secretFile"), EnvSet: a.providerFileSet(s, name, "environmentFile"), ACME: slices.Contains(acmeTypes, str(p, "type"))})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// providerUsers lists what refers to the connection.
|
||||
func (s Settings) providerUsers(name string) []string {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
var users []string
|
||||
if d, _ := router["ddns"].(map[string]any); str(d, "provider") == name {
|
||||
users = append(users, "Dynamic DNS")
|
||||
}
|
||||
if c, _ := router["acme"].(map[string]any); str(c, "provider") == name {
|
||||
users = append(users, "the certificate")
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// knownType is a provider of ddclient or lego.
|
||||
func knownType(t string) bool { return slices.Contains(ddnsTypes, t) || slices.Contains(acmeTypes, t) }
|
||||
|
||||
// secretWrite is a secret to store; secretDelete a file to remove once the
|
||||
// settings are saved.
|
||||
type secretWrite struct{ Path, Secret string }
|
||||
|
||||
// applyDNSProviders replaces the connections by the rows. New secrets go to
|
||||
// secretDir/NAME unless the connection has a file already.
|
||||
func (s Settings) applyDNSProviders(rows []DNSProvider, secretDir string) (writes []secretWrite, deletes []string, err error) {
|
||||
errs := FormErrors{}
|
||||
old := s.dnsProviderMap()
|
||||
next := map[string]any{}
|
||||
for name, v := range old { // connections not in the form stay
|
||||
next[name] = v
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, r := range rows {
|
||||
field := func(n string) string { return fmt.Sprintf("prov[%d].%s", i, n) }
|
||||
r.Name, r.Type = strings.ToLower(strings.TrimSpace(r.Name)), strings.ToLower(strings.TrimSpace(r.Type))
|
||||
r.Username, r.Server, r.Secret = strings.TrimSpace(r.Username), strings.TrimSpace(r.Server), strings.TrimSpace(r.Secret)
|
||||
r.Env = strings.TrimSpace(strings.ReplaceAll(r.Env, "\r\n", "\n"))
|
||||
if r.Name == "" && r.Secret == "" && r.Env == "" && r.Server == "" {
|
||||
continue // the add row, left as it was offered (type and login are pre-filled)
|
||||
}
|
||||
oldRow, exists := old[r.Name].(map[string]any)
|
||||
if r.New && exists {
|
||||
errs[field("name")] = "there is a connection with this name"
|
||||
}
|
||||
if !providerNameRe.MatchString(r.Name) {
|
||||
errs[field("name")] = "lower case letters, digits and dashes"
|
||||
}
|
||||
if seen[r.Name] {
|
||||
errs[field("name")] = "the same name twice"
|
||||
}
|
||||
seen[r.Name] = true
|
||||
if r.Remove {
|
||||
if users := s.providerUsers(r.Name); len(users) > 0 {
|
||||
errs[field("name")] = "used by " + strings.Join(users, " and ")
|
||||
continue
|
||||
}
|
||||
for _, k := range []string{"secretFile", "environmentFile"} {
|
||||
if f := str(oldRow, k); f != "" {
|
||||
deletes = append(deletes, f)
|
||||
}
|
||||
}
|
||||
delete(next, r.Name)
|
||||
continue
|
||||
}
|
||||
if !protocolRe.MatchString(r.Type) || !(knownType(r.Type) || r.Type == str(oldRow, "type")) {
|
||||
errs[field("type")] = "choose a provider from the list"
|
||||
}
|
||||
for _, l := range strings.Split(r.Env, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !envLineRe.MatchString(l) {
|
||||
errs[field("env")] = l + " is not VARIABLE=value"
|
||||
break
|
||||
}
|
||||
}
|
||||
if r.Server != "" && !serverRe.MatchString(r.Server) {
|
||||
errs[field("server")] = "a host name, without http://"
|
||||
}
|
||||
if strings.ContainsAny(r.Username, "\n\r") {
|
||||
errs[field("username")] = "one line"
|
||||
}
|
||||
secretFile := str(oldRow, "secretFile")
|
||||
if secretFile == "" {
|
||||
secretFile = filepath.Join(secretDir, r.Name)
|
||||
}
|
||||
envFile := str(oldRow, "environmentFile")
|
||||
if envFile == "" {
|
||||
envFile = filepath.Join(secretDir, r.Name+".env")
|
||||
}
|
||||
hasSecret := r.Secret != "" || str(oldRow, "secretFile") != ""
|
||||
hasEnv := r.Env != "" || str(oldRow, "environmentFile") != ""
|
||||
if !hasSecret && !hasEnv {
|
||||
errs[field("secret")] = "the provider's password or API token, or its variables for certificates"
|
||||
}
|
||||
row := map[string]any{"type": r.Type}
|
||||
if hasSecret {
|
||||
row["secretFile"] = secretFile
|
||||
}
|
||||
if hasEnv {
|
||||
row["environmentFile"] = envFile
|
||||
}
|
||||
if r.Username != "" {
|
||||
row["username"] = r.Username
|
||||
}
|
||||
if r.Server != "" {
|
||||
row["server"] = r.Server
|
||||
}
|
||||
next[r.Name] = row
|
||||
if r.Secret != "" {
|
||||
writes = append(writes, secretWrite{secretFile, r.Secret})
|
||||
}
|
||||
if r.Env != "" {
|
||||
writes = append(writes, secretWrite{envFile, r.Env + "\n"})
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return nil, nil, errs
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(obj(s, "router"), "dnsProviders")
|
||||
} else {
|
||||
obj(s, "router")["dnsProviders"] = next
|
||||
}
|
||||
return writes, deletes, nil
|
||||
}
|
||||
|
||||
type providersData struct {
|
||||
Rows []providerRow
|
||||
Errors FormErrors
|
||||
}
|
||||
|
||||
func (a *app) providersPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DNS providers", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dnsproviders", p)
|
||||
return
|
||||
}
|
||||
rows := append(a.dnsProviders(s), DNSProvider{New: true, Type: "cloudflare", Username: "token"})
|
||||
p.Data = providersData{Rows: providerRows(rows)}
|
||||
a.render(w, "dnsproviders", p)
|
||||
}
|
||||
|
||||
func (a *app) providersSave(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DNS providers", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dnsproviders", p)
|
||||
return
|
||||
}
|
||||
n := 0
|
||||
fmt.Sscanf(r.PostFormValue("providers"), "%d", &n)
|
||||
n = min(max(n, 0), 50)
|
||||
var rows []DNSProvider
|
||||
for i := 0; i < n; i++ {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("prov.%d.%s", i, k)) }
|
||||
rows = append(rows, DNSProvider{Name: v("name"), Type: v("type"), Username: v("username"), Server: v("server"),
|
||||
Secret: v("secret"), Env: v("env"), New: v("new") == "1", Remove: v("remove") == "on"})
|
||||
}
|
||||
writes, deletes, err := s.applyDNSProviders(rows, filepath.Join(a.sys.StateDir, "dns-secrets"))
|
||||
if err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
for i := range rows {
|
||||
rows[i].Secret, rows[i].Env = "", ""
|
||||
_, rows[i].SecretSet = s.dnsProviderMap()[strings.ToLower(strings.TrimSpace(rows[i].Name))].(map[string]any)
|
||||
}
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = providersData{Rows: providerRows(rows), Errors: fe}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "dnsproviders", p)
|
||||
return
|
||||
}
|
||||
for _, wr := range writes {
|
||||
err := os.MkdirAll(filepath.Dir(wr.Path), 0o700)
|
||||
if err == nil {
|
||||
err = writeFileAtomic(wr.Path, []byte(wr.Secret), 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
p.Error = "Storing the secret: " + err.Error()
|
||||
a.render(w, "dnsproviders", p)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "dnsproviders", p)
|
||||
return
|
||||
}
|
||||
for _, f := range deletes {
|
||||
if strings.HasPrefix(f, a.sys.StateDir+"/") {
|
||||
os.Remove(f)
|
||||
}
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FirewallRule is an allow rule between two zones, as the page edits it:
|
||||
// zones, addresses and ports on both sides, and the protocol. Rules with more
|
||||
// (port forwards, rate limits, logging) are kept as they are and only listed.
|
||||
type FirewallRule struct {
|
||||
From string // zone
|
||||
Sources string // addresses or CIDRs, empty: any
|
||||
SourcePorts string
|
||||
To string // zone
|
||||
Destination string // one address or CIDR, empty: the whole zone
|
||||
Ports string // destination ports
|
||||
Protocol string
|
||||
NATOnly bool // only connections that a port forward translated
|
||||
Log bool // log new connections
|
||||
RateLimit string // per source address, like 10/minute
|
||||
Remove bool
|
||||
}
|
||||
|
||||
// ZoneMember is a LAN or a tunnel; its zone is what the firewall sees.
|
||||
type ZoneMember struct {
|
||||
Kind string // "lan" or "wireguard"
|
||||
Name string
|
||||
Device string
|
||||
Zone string
|
||||
}
|
||||
|
||||
var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`)
|
||||
|
||||
// simpleRuleKeys are the settings keys the page edits.
|
||||
var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources", "natOnly", "log", "rateLimit"}
|
||||
|
||||
// zoneMembers are the LANs, then the tunnels, by name. A member is in a zone
|
||||
// named after itself unless it says otherwise.
|
||||
func (s Settings) zoneMembers() []ZoneMember {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
var out []ZoneMember
|
||||
lans := s.form().LANs
|
||||
for _, l := range lans {
|
||||
out = append(out, ZoneMember{Kind: "lan", Name: l.Name, Device: l.Interface, Zone: orName(str(router, "lans", l.Name, "zone"), l.Name)})
|
||||
}
|
||||
wgs, _ := router["wireguard"].(map[string]any)
|
||||
names := make([]string, 0, len(wgs))
|
||||
for n := range wgs {
|
||||
names = append(names, n)
|
||||
}
|
||||
slices.Sort(names)
|
||||
for _, n := range names {
|
||||
dev := orName(str(router, "wireguard", n, "interface"), "wg-"+n)
|
||||
out = append(out, ZoneMember{Kind: "wireguard", Name: n, Device: dev, Zone: orName(str(router, "wireguard", n, "zone"), n)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orName(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// declaredZones are the zones listed in the settings (they may be empty).
|
||||
func (s Settings) declaredZones() []string {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
fw, _ := router["firewall"].(map[string]any)
|
||||
return stringList(fw, "zones")
|
||||
}
|
||||
|
||||
// zones returns every firewall zone, sorted.
|
||||
func (s Settings) zones() []string {
|
||||
out := slices.Clone(s.declaredZones())
|
||||
for _, m := range s.zoneMembers() {
|
||||
out = append(out, m.Zone)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
func ruleSimple(r map[string]any) bool {
|
||||
for k := range r {
|
||||
if !slices.Contains(simpleRuleKeys, k) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func portsText(v any) string {
|
||||
var out []string
|
||||
items, _ := v.([]any)
|
||||
for _, i := range items {
|
||||
switch p := i.(type) {
|
||||
case string:
|
||||
out = append(out, p)
|
||||
case float64:
|
||||
out = append(out, strconv.FormatFloat(p, 'f', -1, 64))
|
||||
case int:
|
||||
out = append(out, strconv.Itoa(p))
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// firewallRules splits the rules into those the page edits and a description
|
||||
// of the others, one line each.
|
||||
func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
fw, _ := router["firewall"].(map[string]any)
|
||||
rules, _ := fw["rules"].([]any)
|
||||
for _, v := range rules {
|
||||
r, _ := v.(map[string]any)
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
if !ruleSimple(r) {
|
||||
desc := str(r, "from") + " → " + str(r, "to")
|
||||
if d := str(r, "destination"); d != "" {
|
||||
desc += " " + d
|
||||
}
|
||||
if d := strings.Join(stringList(r, "sources"), ", "); d != "" {
|
||||
desc += " from " + d
|
||||
}
|
||||
if p := str(r, "protocol"); p != "" && p != "any" {
|
||||
desc += " " + p
|
||||
}
|
||||
if p := portsText(r["ports"]); p != "" {
|
||||
desc += " " + p
|
||||
}
|
||||
other = append(other, desc)
|
||||
continue
|
||||
}
|
||||
proto := str(r, "protocol")
|
||||
if proto == "" {
|
||||
proto = "any"
|
||||
}
|
||||
simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto,
|
||||
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]),
|
||||
Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination"),
|
||||
NATOnly: boolOf(r, "natOnly"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
|
||||
}
|
||||
return simple, other
|
||||
}
|
||||
|
||||
// ZoneForm is what the page changes about the zones: the zone of each member,
|
||||
// zones to add (separated by commas or spaces) and empty zones to drop.
|
||||
type ZoneForm struct {
|
||||
Members []ZoneMember
|
||||
New string
|
||||
Remove []string
|
||||
}
|
||||
|
||||
// portList parses "22, 443 5000-5100" into settings values (numbers, and
|
||||
// strings for ranges).
|
||||
func portList(v string) ([]any, string) {
|
||||
var ports []any
|
||||
for _, p := range splitList(v) {
|
||||
if !portRangeRe.MatchString(p) {
|
||||
return nil, "numbers or ranges like 5000-5100, separated by commas"
|
||||
}
|
||||
lo, hi, _ := strings.Cut(p, "-")
|
||||
l, _ := strconv.Atoi(lo)
|
||||
h := l
|
||||
if hi != "" {
|
||||
h, _ = strconv.Atoi(hi)
|
||||
}
|
||||
if l < 1 || h > 65535 || (hi != "" && h <= l) {
|
||||
return nil, p + " is not a valid port or range"
|
||||
}
|
||||
if hi == "" {
|
||||
ports = append(ports, l)
|
||||
} else {
|
||||
ports = append(ports, p)
|
||||
}
|
||||
}
|
||||
return ports, ""
|
||||
}
|
||||
|
||||
func validAddress(v string) bool {
|
||||
if _, err := netip.ParsePrefix(v); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := netip.ParseAddr(v)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// applyZones assigns the members' zones and declares or drops zones. Errors
|
||||
// are keyed "member[i]" and "newzone".
|
||||
func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
|
||||
router := obj(s, "router")
|
||||
used := map[string]bool{}
|
||||
for i, m := range zf.Members {
|
||||
m.Zone = strings.TrimSpace(m.Zone)
|
||||
switch {
|
||||
case !nameRe.MatchString(m.Zone):
|
||||
errs[fmt.Sprintf("member[%d]", i)] = "letters, digits and dashes, starting with a letter"
|
||||
continue
|
||||
case m.Zone == "wan" || m.Zone == "local":
|
||||
errs[fmt.Sprintf("member[%d]", i)] = m.Zone + " is a reserved zone (the uplink, the router) and cannot be assigned"
|
||||
continue
|
||||
}
|
||||
used[m.Zone] = true
|
||||
kind := map[string]string{"lan": "lans", "wireguard": "wireguard"}[m.Kind]
|
||||
members, _ := router[kind].(map[string]any)
|
||||
v, _ := members[m.Name].(map[string]any)
|
||||
if v == nil {
|
||||
continue // vanished since the page was drawn
|
||||
}
|
||||
if m.Zone == m.Name {
|
||||
delete(v, "zone")
|
||||
} else {
|
||||
v["zone"] = m.Zone
|
||||
}
|
||||
}
|
||||
declared := slices.Clone(s.declaredZones())
|
||||
for _, z := range splitList(zf.New) {
|
||||
switch {
|
||||
case !nameRe.MatchString(z) || z == "wan" || z == "local":
|
||||
errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan and local are taken)"
|
||||
case !slices.Contains(declared, z):
|
||||
declared = append(declared, z)
|
||||
}
|
||||
}
|
||||
declared = slices.DeleteFunc(declared, func(z string) bool {
|
||||
if !slices.Contains(zf.Remove, z) {
|
||||
return false
|
||||
}
|
||||
if used[z] {
|
||||
errs["zone."+z] = "still has members"
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
// What a member already names needs no declaration.
|
||||
declared = slices.DeleteFunc(declared, func(z string) bool { return used[z] })
|
||||
slices.Sort(declared)
|
||||
fw := obj(router, "firewall")
|
||||
if len(declared) == 0 {
|
||||
delete(fw, "zones")
|
||||
} else {
|
||||
list := make([]any, len(declared))
|
||||
for i, z := range declared {
|
||||
list[i] = z
|
||||
}
|
||||
fw["zones"] = list
|
||||
}
|
||||
}
|
||||
|
||||
// applyFirewall changes the zones, then replaces the rules the page edits with
|
||||
// rows (an untouched "add" row is skipped); the other rules stay as they are.
|
||||
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule) error {
|
||||
errs := FormErrors{}
|
||||
s.applyZones(zf, errs)
|
||||
zones := s.zones()
|
||||
var next []any
|
||||
seen := map[FirewallRule]bool{}
|
||||
for i, r := range rows {
|
||||
field := func(n string) string { return fmt.Sprintf("rule[%d].%s", i, n) }
|
||||
r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To)
|
||||
r.Ports, r.SourcePorts = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.SourcePorts), ", ")
|
||||
r.Sources, r.Destination = strings.Join(splitList(r.Sources), ", "), strings.TrimSpace(r.Destination)
|
||||
if r.Protocol == "" {
|
||||
r.Protocol = "any"
|
||||
}
|
||||
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" && r.Destination == "") {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(zones, r.From) && r.From != "wan" {
|
||||
errs[field("from")] = "choose a zone"
|
||||
}
|
||||
if !slices.Contains(zones, r.To) && r.To != "local" {
|
||||
errs[field("to")] = "choose a zone"
|
||||
}
|
||||
if r.To == "local" && r.NATOnly {
|
||||
errs[field("to")] = "a port forward leads to a host in a zone, not to the router"
|
||||
}
|
||||
if r.From == r.To && r.From != "" {
|
||||
errs[field("to")] = "traffic inside a zone is already allowed"
|
||||
}
|
||||
if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) {
|
||||
errs[field("protocol")] = "any, tcp, udp or icmp"
|
||||
}
|
||||
ports, msg := portList(r.Ports)
|
||||
if msg != "" {
|
||||
errs[field("ports")] = msg
|
||||
}
|
||||
sports, msg := portList(r.SourcePorts)
|
||||
if msg != "" {
|
||||
errs[field("sourcePorts")] = msg
|
||||
}
|
||||
if (len(ports) > 0 || len(sports) > 0) && r.Protocol != "tcp" && r.Protocol != "udp" {
|
||||
errs[field("ports")] = "ports need protocol tcp or udp"
|
||||
}
|
||||
var sources []any
|
||||
for _, a := range splitList(r.Sources) {
|
||||
if !validAddress(a) {
|
||||
errs[field("sources")] = a + " is not an address or CIDR"
|
||||
break
|
||||
}
|
||||
sources = append(sources, a)
|
||||
}
|
||||
if r.Destination != "" && !validAddress(r.Destination) {
|
||||
errs[field("destination")] = "one address or CIDR, or empty for the whole zone"
|
||||
}
|
||||
if r.From == "wan" {
|
||||
// What the module requires, so that a typo cannot open a whole zone.
|
||||
if r.Destination == "" && r.To != "local" {
|
||||
errs[field("destination")] = "rules from the internet must name the host or network inside"
|
||||
}
|
||||
if r.Protocol == "any" {
|
||||
errs[field("protocol")] = "rules from the internet must name a protocol"
|
||||
} else if r.Protocol != "icmp" && len(ports) == 0 {
|
||||
errs[field("ports")] = "rules from the internet must list the ports they open"
|
||||
}
|
||||
}
|
||||
r.RateLimit = strings.TrimSpace(r.RateLimit)
|
||||
if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
|
||||
errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
|
||||
}
|
||||
if seen[r] {
|
||||
errs[field("from")] = "the same rule twice"
|
||||
}
|
||||
seen[r] = true
|
||||
rule := map[string]any{"from": r.From, "to": r.To}
|
||||
if r.NATOnly {
|
||||
rule["natOnly"] = true
|
||||
}
|
||||
if r.Log {
|
||||
rule["log"] = true
|
||||
}
|
||||
if r.RateLimit != "" {
|
||||
rule["rateLimit"] = r.RateLimit
|
||||
}
|
||||
if r.Protocol != "any" {
|
||||
rule["protocol"] = r.Protocol
|
||||
}
|
||||
if len(ports) > 0 {
|
||||
rule["ports"] = ports
|
||||
}
|
||||
if len(sports) > 0 {
|
||||
rule["sourcePorts"] = sports
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
rule["sources"] = sources
|
||||
}
|
||||
if r.Destination != "" {
|
||||
rule["destination"] = r.Destination
|
||||
}
|
||||
next = append(next, rule)
|
||||
}
|
||||
natRows, allows := s.natSettings(nats, zones, errs)
|
||||
for _, a := range allows {
|
||||
if !slices.ContainsFunc(next, func(v any) bool { return reflect.DeepEqual(v, a) }) {
|
||||
next = append(next, a)
|
||||
}
|
||||
}
|
||||
router := obj(s, "router")
|
||||
fw := obj(router, "firewall")
|
||||
old, _ := fw["rules"].([]any)
|
||||
for _, v := range old {
|
||||
r, _ := v.(map[string]any)
|
||||
if r == nil || ruleSimple(r) {
|
||||
continue
|
||||
}
|
||||
// Rules with options not edited here must keep their zones.
|
||||
if f := str(r, "from"); f != "wan" && !slices.Contains(zones, f) {
|
||||
errs["zone."+f] = "a rule that is not editable here still uses this zone"
|
||||
}
|
||||
if t := str(r, "to"); !slices.Contains(zones, t) {
|
||||
errs["zone."+t] = "a rule that is not editable here still uses this zone"
|
||||
}
|
||||
next = append(next, v)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(fw, "rules")
|
||||
} else {
|
||||
fw["rules"] = next
|
||||
}
|
||||
if len(natRows) == 0 {
|
||||
delete(fw, "nat")
|
||||
} else {
|
||||
fw["nat"] = natRows
|
||||
}
|
||||
if len(fw) == 0 {
|
||||
delete(router, "firewall")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type firewallData struct {
|
||||
Rows []FirewallRule
|
||||
NATs []NATRule
|
||||
FromZones []string // the zones and wan
|
||||
ToZones []string // the zones and local (the router itself)
|
||||
Other []string
|
||||
Zones []string
|
||||
Members []ZoneMember
|
||||
Empty []string // zones without members, which can be removed
|
||||
New string
|
||||
Errors FormErrors
|
||||
}
|
||||
|
||||
func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []ZoneMember, errs FormErrors) firewallData {
|
||||
_, other := s.firewallRules()
|
||||
d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
|
||||
d.FromZones = append([]string{"wan"}, d.Zones...)
|
||||
d.ToZones = append(slices.Clone(d.Zones), "local")
|
||||
if d.Members == nil {
|
||||
d.Members = s.zoneMembers()
|
||||
}
|
||||
for _, z := range s.declaredZones() {
|
||||
if !slices.ContainsFunc(s.zoneMembers(), func(m ZoneMember) bool { return m.Zone == z }) {
|
||||
d.Empty = append(d.Empty, z)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "Firewall", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "firewall", p)
|
||||
return
|
||||
}
|
||||
rows, _ := s.firewallRules()
|
||||
add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"}
|
||||
p.Data = s.firewallData(append(rows, add), append(s.natRules(), NATRule{Type: "dnat", Protocol: "tcp", Allow: true}), nil, nil)
|
||||
a.render(w, "firewall", p)
|
||||
}
|
||||
|
||||
func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "Firewall", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "firewall", p)
|
||||
return
|
||||
}
|
||||
zf := ZoneForm{New: r.PostFormValue("newzones"), Remove: r.PostForm["rmzone"]}
|
||||
nm, _ := strconv.Atoi(r.PostFormValue("members"))
|
||||
for i := range min(nm, 512) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("member.%d.%s", i, k)) }
|
||||
zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")})
|
||||
}
|
||||
n, _ := strconv.Atoi(r.PostFormValue("rows"))
|
||||
nn, _ := strconv.Atoi(r.PostFormValue("nats"))
|
||||
var nats []NATRule
|
||||
for i := range min(nn, 512) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("nat.%d.%s", i, k)) }
|
||||
nats = append(nats, NATRule{Type: v("type"), From: v("from"), To: v("to"), Protocol: v("protocol"), Ports: v("ports"),
|
||||
SourcePorts: v("sourceports"), Sources: v("sources"), Destination: v("destination"), ToAddress: v("toaddress"),
|
||||
ToPort: v("toport"), Hairpin: v("hairpin") == "on", Allow: v("allow") == "on", Remove: v("remove") == "on"})
|
||||
}
|
||||
var rows []FirewallRule
|
||||
for i := range min(n, 512) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) }
|
||||
rows = append(rows, FirewallRule{From: v("from"), Sources: v("sources"), SourcePorts: v("sourceports"),
|
||||
To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"),
|
||||
NATOnly: v("natonly") == "on", Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
|
||||
}
|
||||
if err := s.applyFirewall(zf, rows, nats); err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
// Show what was entered; the zone lists are those before this attempt.
|
||||
s, _ = loadSettings(a.sys.settingsPath())
|
||||
p.Error = "Please correct the marked fields."
|
||||
d := s.firewallData(rows, nats, zf.Members, fe)
|
||||
d.New = zf.New
|
||||
p.Data = d
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "firewall", p)
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "firewall", p)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFirewallRulesRoundTrip(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
|
||||
t.Fatalf("zones = %v", got)
|
||||
}
|
||||
// A rule the page does not edit (it has a key the page lacks) must survive.
|
||||
obj(obj(s, "router"), "firewall")["rules"] = []any{
|
||||
map[string]any{"from": "main", "to": "vpn", "extra": true},
|
||||
}
|
||||
rows, other := s.firewallRules()
|
||||
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") {
|
||||
t.Fatalf("rows=%+v other=%v", rows, other)
|
||||
}
|
||||
err := s.applyRules(ZoneForm{}, []FirewallRule{
|
||||
{From: "main", To: "vpn", Protocol: "any"},
|
||||
{From: "vpn", To: "main", Protocol: "tcp", Ports: "22, 443 5000-5100", Destination: "192.168.10.0/24"},
|
||||
{Protocol: "any"}, // the empty add row
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, other = s.firewallRules()
|
||||
if len(rows) != 2 || len(other) != 1 {
|
||||
t.Fatalf("rows=%+v other=%v", rows, other)
|
||||
}
|
||||
if rows[1].Ports != "22, 443, 5000-5100" || rows[1].Protocol != "tcp" || rows[0].Protocol != "any" {
|
||||
t.Errorf("rows = %+v", rows)
|
||||
}
|
||||
out := string(mustMarshal(t, s))
|
||||
for _, want := range []string{`"extra": true`, `"5000-5100"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("%s missing:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
rows[0].Remove, rows[1].Remove = true, true
|
||||
if err := s.applyRules(ZoneForm{}, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows, other = s.firewallRules(); len(rows) != 0 || len(other) != 1 {
|
||||
t.Errorf("rows=%+v other=%v", rows, other)
|
||||
}
|
||||
if err := s.applyRules(ZoneForm{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok {
|
||||
t.Error("the rule that is not editable was removed with the allow rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallRulesAreChecked(t *testing.T) {
|
||||
good := FirewallRule{From: "main", To: "vpn", Protocol: "tcp", Ports: "80"}
|
||||
for name, mod := range map[string]func(*FirewallRule){
|
||||
"from": func(r *FirewallRule) { r.From = "nowhere" },
|
||||
"to": func(r *FirewallRule) { r.To = "main" },
|
||||
"protocol": func(r *FirewallRule) { r.Protocol = "gre" },
|
||||
"ports": func(r *FirewallRule) { r.Ports = "80-70" },
|
||||
"destination": func(r *FirewallRule) { r.Destination = "the nas" },
|
||||
} {
|
||||
r := good
|
||||
mod(&r)
|
||||
err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r})
|
||||
fe, ok := err.(FormErrors)
|
||||
if !ok || fe["rule[0]."+name] == "" {
|
||||
t.Errorf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
r := good
|
||||
r.Protocol = "any"
|
||||
if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}); err == nil {
|
||||
t.Error("ports without tcp/udp accepted")
|
||||
}
|
||||
if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good, good}); err == nil {
|
||||
t.Error("the same rule twice accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallPage(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/firewall?from=main&to=vpn", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `value="vpn"`) {
|
||||
t.Errorf("zones missing:\n%s", body)
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
form := url.Values{"csrf": {token}, "rows": {"1"}, "rule.0.from": {"main"}, "rule.0.to": {"nowhere"}, "rule.0.protocol": {"any"}}
|
||||
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("unknown zone: %d", rec.Code)
|
||||
}
|
||||
form.Set("rule.0.to", "vpn")
|
||||
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if rows, _ := s.firewallRules(); len(rows) != 1 || rows[0].From != "main" || rows[0].To != "vpn" {
|
||||
t.Fatalf("not saved: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallAddressesAndSourcePorts(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
err := s.applyRules(ZoneForm{}, []FirewallRule{{From: "vpn", To: "main", Protocol: "udp",
|
||||
Sources: "198.51.100.0/24, 2001:db8::1", SourcePorts: "53 1024-2048", Destination: "192.168.10.5", Ports: "5353"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _ := s.firewallRules()
|
||||
want := FirewallRule{From: "vpn", To: "main", Protocol: "udp", Sources: "198.51.100.0/24, 2001:db8::1",
|
||||
SourcePorts: "53, 1024-2048", Destination: "192.168.10.5", Ports: "5353"}
|
||||
if len(rows) != 1 || rows[0] != want {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
for name, mod := range map[string]func(*FirewallRule){
|
||||
"sources": func(r *FirewallRule) { r.Sources = "the internet" },
|
||||
"sourcePorts": func(r *FirewallRule) { r.SourcePorts = "70000" },
|
||||
"ports": func(r *FirewallRule) { r.Protocol = "icmp" },
|
||||
} {
|
||||
r := want
|
||||
mod(&r)
|
||||
if fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" {
|
||||
t.Errorf("%s not refused: %v", name, fe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZonesAreAssignedAddedAndRemoved(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
// A new zone, empty at first.
|
||||
if err := s.applyRules(ZoneForm{New: "guests, iot"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(s.zones(), ","); got != "guests,iot,main,vpn" {
|
||||
t.Fatalf("zones = %s", got)
|
||||
}
|
||||
// Two interfaces into one zone; the tunnel's old zone disappears, the
|
||||
// LAN's keeps its name by default.
|
||||
members := s.zoneMembers()
|
||||
if len(members) != 2 || members[0].Kind != "lan" || members[1].Device != "wg-vpn" {
|
||||
t.Fatalf("members = %+v", members)
|
||||
}
|
||||
members[1].Zone = "guests"
|
||||
if err := s.applyRules(ZoneForm{Members: members}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if str(s, "router", "wireguard", "vpn", "zone") != "guests" || str(s, "router", "lans", "main", "zone") != "" {
|
||||
t.Errorf("zones not assigned: %s", mustMarshal(t, s))
|
||||
}
|
||||
if got := strings.Join(s.declaredZones(), ","); got != "iot" {
|
||||
t.Errorf("declared = %s (guests has a member now)", got)
|
||||
}
|
||||
// Back to the default name drops the key; an empty zone can be removed.
|
||||
members[1].Zone = "vpn"
|
||||
if err := s.applyRules(ZoneForm{Members: members, Remove: []string{"iot"}}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s["router"].(map[string]any)["wireguard"].(map[string]any)["vpn"].(map[string]any)["zone"]; ok {
|
||||
t.Error("the default zone is written out")
|
||||
}
|
||||
if len(s.declaredZones()) != 0 {
|
||||
t.Errorf("declared = %v", s.declaredZones())
|
||||
}
|
||||
if _, ok := s["router"].(map[string]any)["firewall"]; ok {
|
||||
t.Error("an empty firewall section is left behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneChangesAreChecked(t *testing.T) {
|
||||
for name, zf := range map[string]func(*Settings) ZoneForm{
|
||||
"newzone": func(*Settings) ZoneForm { return ZoneForm{New: "Not Ok"} },
|
||||
"newzone ": func(*Settings) ZoneForm { return ZoneForm{New: "wan"} },
|
||||
"member[0]": func(s *Settings) ZoneForm { m := s.zoneMembers(); m[0].Zone = "wan"; return ZoneForm{Members: m} },
|
||||
} {
|
||||
s := mustParse(t)
|
||||
fe, ok := s.applyRules(zf(&s), nil).(FormErrors)
|
||||
if !ok || fe[strings.TrimSpace(name)] == "" {
|
||||
t.Errorf("%s accepted: %v", name, fe)
|
||||
}
|
||||
}
|
||||
// A zone with members stays; a rule left without its zone is refused.
|
||||
s := mustParse(t)
|
||||
if err := s.applyRules(ZoneForm{}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := s.zoneMembers()
|
||||
m[1].Zone = "other"
|
||||
fe, ok := s.applyRules(ZoneForm{Members: m}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}).(FormErrors)
|
||||
if !ok || fe["rule[0].to"] == "" {
|
||||
t.Errorf("rule to a vanished zone accepted: %v", fe)
|
||||
}
|
||||
fe, ok = mustParse(t).applyRules(ZoneForm{Remove: []string{"main"}, Members: mustParse(t).zoneMembers()}, nil).(FormErrors)
|
||||
if ok && len(fe) > 0 {
|
||||
t.Errorf("removing a zone that is not declared is not an error: %v", fe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRangeIs15Minutes(t *testing.T) {
|
||||
if findRange("").Name != "15m" || findRange("1h").Name != "1h" || findRange("bogus").Name != "15m" {
|
||||
t.Fatal("ranges", ranges)
|
||||
}
|
||||
a, _ := newTestApp(t)
|
||||
now := time.Now()
|
||||
a.metrics.store.Add("sys:cpu", now, 50)
|
||||
c15 := a.chart("t", "%", findRange("15m"), now, []seriesSpec{{"sys:cpu", "cpu", 1}})
|
||||
c1h := a.chart("t", "%", findRange("1h"), now, []seriesSpec{{"sys:cpu", "cpu", 1}})
|
||||
if c15.Empty || c1h.Empty || len(c15.Cols) != chartCols {
|
||||
t.Fatalf("charts: %+v", c15)
|
||||
}
|
||||
// The ends of the axis are 15 and 60 minutes apart.
|
||||
span := func(c Chart) time.Duration {
|
||||
a, _ := time.ParseInLocation("15:04", c.XTicks[0].Label, time.Local)
|
||||
b, _ := time.ParseInLocation("15:04", c.XTicks[4].Label, time.Local)
|
||||
return (b.Sub(a) + 24*time.Hour) % (24 * time.Hour)
|
||||
}
|
||||
if got := span(c15); got < 14*time.Minute || got > 16*time.Minute {
|
||||
t.Errorf("15m axis spans %v", got)
|
||||
}
|
||||
if got := span(c1h); got < 59*time.Minute || got > 61*time.Minute {
|
||||
t.Errorf("1h axis spans %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// applyRules is applyFirewall without port forwards.
|
||||
func (s Settings) applyRules(zf ZoneForm, rows []FirewallRule) error {
|
||||
return s.applyFirewall(zf, rows, nil)
|
||||
}
|
||||
|
||||
func TestNATRoundTrip(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
obj(obj(s, "router"), "firewall")["nat"] = []any{
|
||||
map[string]any{"type": "dnat", "from": "wan", "to": "main", "toAddress": "192.168.10.5", "protocol": "tcp",
|
||||
"ports": []any{float64(8080)}, "toPort": float64(80), "hairpin": true},
|
||||
}
|
||||
got := s.natRules()
|
||||
if len(got) != 1 || got[0].Type != "dnat" || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].Hairpin {
|
||||
t.Fatalf("nat = %+v", got)
|
||||
}
|
||||
add := []NATRule{
|
||||
got[0],
|
||||
{Type: "dnat", From: "wan", To: "main", Protocol: "udp", Ports: "51000-51010", ToAddress: "192.168.10.5", Sources: "198.51.100.0/24", Allow: true},
|
||||
{Type: "masquerade", From: "main", To: "wan", Sources: "192.168.10.0/24"},
|
||||
{Type: "snat", To: "wan", ToAddress: "203.0.113.7", Destination: "10.0.0.0/8"},
|
||||
{Type: "dnat", Protocol: "tcp"}, // the empty add row
|
||||
}
|
||||
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = s.natRules()
|
||||
if len(got) != 4 || got[1].Ports != "51000-51010" || got[2].Type != "masquerade" || got[3].ToAddress != "203.0.113.7" {
|
||||
t.Fatalf("nat = %+v", got)
|
||||
}
|
||||
// "Also allow" added a filter rule; saving again does not add it twice.
|
||||
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(string(mustMarshal(t, s)), `"natOnly": true`); n != 1 {
|
||||
t.Errorf("%d natOnly rules, want 1:\n%s", n, mustMarshal(t, s))
|
||||
}
|
||||
for i := range add {
|
||||
add[i].Remove = true
|
||||
}
|
||||
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.natRules(); len(got) != 0 {
|
||||
t.Errorf("not removed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATRowsAreChecked(t *testing.T) {
|
||||
good := NATRule{Type: "dnat", From: "wan", To: "main", Protocol: "tcp", Ports: "8080", ToAddress: "192.168.10.5"}
|
||||
for name, mod := range map[string]func(*NATRule){
|
||||
"type": func(r *NATRule) { r.Type = "bogus" },
|
||||
"from": func(r *NATRule) { r.From = "" },
|
||||
"to": func(r *NATRule) { r.To = "nowhere" },
|
||||
"protocol": func(r *NATRule) { r.Protocol = "any" },
|
||||
"ports": func(r *NATRule) { r.Ports = "" },
|
||||
"toaddress": func(r *NATRule) { r.ToAddress = "10.9.9.9" }, // outside the LAN
|
||||
"sources": func(r *NATRule) { r.Sources = "2001:db8::/32" },
|
||||
"toport": func(r *NATRule) { r.ToPort = "80"; r.Ports = "80, 81" },
|
||||
"hairpin": func(r *NATRule) { r.From = "main"; r.To = "main"; r.ToAddress = "192.168.10.5"; r.Hairpin = true },
|
||||
} {
|
||||
r := good
|
||||
mod(&r)
|
||||
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{r}).(FormErrors)
|
||||
if !ok || fe["nat[0]."+name] == "" {
|
||||
t.Errorf("%s accepted: %v", name, fe)
|
||||
}
|
||||
}
|
||||
m := NATRule{Type: "masquerade", From: "main", To: "main"}
|
||||
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].to"] == "" {
|
||||
t.Errorf("masquerade into the same zone accepted: %v", fe)
|
||||
}
|
||||
m = NATRule{Type: "masquerade", From: "main", To: "wan", ToAddress: "192.0.2.1"}
|
||||
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].toaddress"] == "" {
|
||||
t.Errorf("masquerade with an address accepted: %v", fe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWANFilterRule(t *testing.T) {
|
||||
good := FirewallRule{From: "wan", To: "main", Destination: "192.168.10.5", Protocol: "tcp", Ports: "443", NATOnly: true, Log: true, RateLimit: "10/minute"}
|
||||
if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mod := range map[string]func(*FirewallRule){
|
||||
"destination": func(r *FirewallRule) { r.Destination = "" },
|
||||
"protocol": func(r *FirewallRule) { r.Protocol = "any" },
|
||||
"ports": func(r *FirewallRule) { r.Ports = "" },
|
||||
"ratelimit": func(r *FirewallRule) { r.RateLimit = "lots" },
|
||||
} {
|
||||
r := good
|
||||
mod(&r)
|
||||
fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors)
|
||||
if !ok || fe["rule[0]."+name] == "" {
|
||||
t.Errorf("%s accepted: %v", name, fe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATPage(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/firewall", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `name="nats" value="1"`) || !strings.Contains(body, "nat.0.type") {
|
||||
t.Fatalf("no row to add a NAT rule:\n%s", body)
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
form := url.Values{"csrf": {token}, "nats": {"1"}, "nat.0.type": {"dnat"}, "nat.0.from": {"wan"}, "nat.0.protocol": {"tcp"},
|
||||
"nat.0.ports": {"8080"}, "nat.0.to": {"main"}, "nat.0.toaddress": {"10.9.9.9"}, "nat.0.toport": {"80"},
|
||||
"nat.0.hairpin": {"on"}, "nat.0.allow": {"on"}}
|
||||
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not inside a network") {
|
||||
t.Fatalf("a forward outside the LAN: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
form.Set("nat.0.toaddress", "192.168.10.5")
|
||||
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if f := s.natRules(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin {
|
||||
t.Fatalf("not saved: %+v", f)
|
||||
}
|
||||
if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) || !strings.Contains(body, `name="rule.0.natonly" checked`) {
|
||||
t.Errorf("the forward or its allow rule is not listed:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module router-webui
|
||||
|
||||
go 1.27
|
||||
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Names for network cards and VLAN devices. A card is named by its MAC address
|
||||
// (router.interfaces.<name>.mac), a VLAN device by its LAN (lans.<l>.deviceName)
|
||||
// or the PPPoE session (wan.ipv4.pppoe.vlanName). Linux cannot rename a device
|
||||
// that is up, so the router only starts using a new name after a reboot;
|
||||
// router-apply stages such a change instead of activating it.
|
||||
|
||||
// reservedNameRe matches the names the kernel and udev hand out themselves:
|
||||
// a rename onto one could collide with a card that gets it at boot.
|
||||
// Keep in step with chosenNameOk in modules/router/assertions.nix.
|
||||
var reservedNameRe = regexp.MustCompile(`^((eth|wlan|ppp|wg|veth|bond|tun|tap|dummy|ifb|docker|virbr|br|lo)[0-9]*|(en|wl|ww|sl)[opsx][0-9a-f].*)$`)
|
||||
|
||||
// chosenName says what is wrong with a name given to a card or VLAN device.
|
||||
func chosenName(n string) string {
|
||||
switch {
|
||||
case !nameRe.MatchString(n):
|
||||
return "lower case letters, digits and dashes, starting with a letter, at most 15 characters"
|
||||
case reservedNameRe.MatchString(n):
|
||||
return "looks like a name the kernel hands out (eth0, wlan0, enp2s0, ...)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NIC is a physical network card as the kernel names it now.
|
||||
type NIC struct {
|
||||
Kernel string
|
||||
MAC string
|
||||
}
|
||||
|
||||
// NICs lists the physical interfaces with their MAC addresses.
|
||||
func NICs() []NIC {
|
||||
var out []NIC
|
||||
for _, n := range Interfaces() {
|
||||
addr, _ := os.ReadFile(filepath.Join("/sys/class/net", n, "address"))
|
||||
out = append(out, NIC{Kernel: n, MAC: strings.ToLower(strings.TrimSpace(string(addr)))})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cardNames returns router.interfaces as name -> MAC.
|
||||
func (s Settings) cardNames() map[string]string {
|
||||
out := map[string]string{}
|
||||
router, _ := s["router"].(map[string]any)
|
||||
cards, _ := router["interfaces"].(map[string]any)
|
||||
for name, v := range cards {
|
||||
if c, ok := v.(map[string]any); ok {
|
||||
out[name] = str(c, "mac")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// vlanNames returns the chosen VLAN device names, keyed "parent.id".
|
||||
func (s Settings) vlanNames() map[string]string {
|
||||
out := map[string]string{}
|
||||
router, _ := s["router"].(map[string]any)
|
||||
lans, _ := router["lans"].(map[string]any)
|
||||
for _, v := range lans {
|
||||
if l, ok := v.(map[string]any); ok && str(l, "deviceName") != "" {
|
||||
out[str(l, "interface")+"."+str(l, "vlan")] = str(l, "deviceName")
|
||||
}
|
||||
}
|
||||
if v := str(router, "wan", "ipv4", "pppoe", "vlanName"); v != "" && str(router, "wan", "ipv4", "type") == "pppoe" {
|
||||
out[str(router, "wan", "interface")+"."+str(router, "wan", "ipv4", "pppoe", "vlan")] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveName is what the settings call a card: the name chosen for its MAC,
|
||||
// else the kernel's.
|
||||
func effectiveName(names map[string]string, n NIC) string {
|
||||
for name, mac := range names {
|
||||
if mac != "" && mac == n.MAC {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return n.Kernel
|
||||
}
|
||||
|
||||
// Rename is one edited row of the interface table.
|
||||
type Rename struct {
|
||||
Dev string // the device as the settings call it: "lan", or "lan.1002" for a VLAN
|
||||
MAC string // of a physical card; empty for a VLAN
|
||||
From string // the name shown before editing: the card's name, the VLAN device's custom name or ""
|
||||
To string // what the user typed; empty: back to the kernel's name / the default
|
||||
}
|
||||
|
||||
// rename applies the edited rows to the settings. kernel gives the kernel's
|
||||
// name for a MAC address (to undo a rename). The changes take effect after a
|
||||
// reboot, so references to a card in the settings follow its new name at once.
|
||||
// It returns the references to rewrite in the UI's own preferences too:
|
||||
// old device name -> new.
|
||||
func (s Settings) rename(rows []Rename, kernel func(mac string) string) (map[string]string, error) {
|
||||
errs := FormErrors{}
|
||||
moved := map[string]string{}
|
||||
router := obj(s, "router")
|
||||
for i, r := range rows {
|
||||
field := fmt.Sprintf("name[%d]", i)
|
||||
to := strings.TrimSpace(r.To)
|
||||
if to == r.From {
|
||||
continue
|
||||
}
|
||||
if r.MAC == "" && !strings.Contains(r.Dev, ".") {
|
||||
errs[field] = "this interface is not on this machine"
|
||||
continue
|
||||
}
|
||||
if to != "" {
|
||||
if why := chosenName(to); why != "" {
|
||||
errs[field] = why
|
||||
continue
|
||||
}
|
||||
}
|
||||
if r.MAC != "" {
|
||||
s.renameCard(router, r, to, kernel, moved, errs, field)
|
||||
} else {
|
||||
s.renameVLAN(router, r, to, errs, field)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return nil, errs
|
||||
}
|
||||
// Two names must not be the same, whatever was typed.
|
||||
if dup := s.duplicateName(); dup != "" {
|
||||
return nil, FormErrors{"name": dup + " is used twice"}
|
||||
}
|
||||
return moved, nil
|
||||
}
|
||||
|
||||
func (s Settings) renameCard(router map[string]any, r Rename, to string, kernel func(string) string, moved map[string]string, errs FormErrors, field string) {
|
||||
cards := obj(router, "interfaces")
|
||||
if to == "" {
|
||||
to = kernel(r.MAC)
|
||||
if to == "" {
|
||||
errs[field] = "the kernel's name for this card is unknown"
|
||||
return
|
||||
}
|
||||
delete(cards, r.Dev)
|
||||
} else {
|
||||
if c, ok := cards[to].(map[string]any); ok && str(c, "mac") != r.MAC {
|
||||
errs[field] = to + " is the name of another interface"
|
||||
return
|
||||
}
|
||||
delete(cards, r.Dev) // it may have a name already
|
||||
obj(cards, to)["mac"] = r.MAC
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
delete(router, "interfaces")
|
||||
}
|
||||
moved[r.Dev] = to
|
||||
if str(router, "wan", "interface") == r.Dev {
|
||||
obj(router, "wan")["interface"] = to
|
||||
}
|
||||
lans, _ := router["lans"].(map[string]any)
|
||||
for _, v := range lans {
|
||||
if l, ok := v.(map[string]any); ok && str(l, "interface") == r.Dev {
|
||||
l["interface"] = to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s Settings) renameVLAN(router map[string]any, r Rename, to string, errs FormErrors, field string) {
|
||||
parent, id := splitVLAN(r.Dev)
|
||||
if id == 0 {
|
||||
errs[field] = "not a VLAN"
|
||||
return
|
||||
}
|
||||
used := false
|
||||
set := func(m map[string]any, key string) {
|
||||
used = true
|
||||
if to == "" {
|
||||
delete(m, key)
|
||||
} else {
|
||||
m[key] = to
|
||||
}
|
||||
}
|
||||
lans, _ := router["lans"].(map[string]any)
|
||||
for _, v := range lans {
|
||||
if l, ok := v.(map[string]any); ok && str(l, "interface") == parent && str(l, "vlan") == strconv.Itoa(id) {
|
||||
set(l, "deviceName")
|
||||
}
|
||||
}
|
||||
if str(router, "wan", "interface") == parent && str(router, "wan", "ipv4", "type") == "pppoe" &&
|
||||
str(router, "wan", "ipv4", "pppoe", "vlan") == strconv.Itoa(id) {
|
||||
set(obj(obj(obj(router, "wan"), "ipv4"), "pppoe"), "vlanName")
|
||||
}
|
||||
if !used {
|
||||
errs[field] = "a VLAN that nothing uses has no device to name"
|
||||
}
|
||||
}
|
||||
|
||||
// duplicateName returns a name that two cards or VLAN devices share.
|
||||
func (s Settings) duplicateName() string {
|
||||
var all []string
|
||||
for n := range s.cardNames() {
|
||||
all = append(all, n)
|
||||
}
|
||||
for _, n := range s.vlanNames() {
|
||||
all = append(all, n)
|
||||
}
|
||||
slices.Sort(all)
|
||||
for i := 1; i < len(all); i++ {
|
||||
if all[i] == all[i-1] {
|
||||
return all[i]
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChosenName(t *testing.T) {
|
||||
for _, ok := range []string{"wan", "lan", "dmz", "guest-wifi", "isp1", "a"} {
|
||||
if why := chosenName(ok); why != "" {
|
||||
t.Errorf("%q refused: %s", ok, why)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "Wan", "1wan", "wan_1", "eth0", "eth", "wlan0", "enp2s0", "eno1", "ens3", "wlp1s0", "ppp0", "wg0", "veth12", "br0", "lo", "this-name-is-too-long"} {
|
||||
if chosenName(bad) == "" {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameSettings(t *testing.T) Settings {
|
||||
t.Helper()
|
||||
s, err := parseSettings([]byte(`{"router": {
|
||||
"wan": {"interface": "enp1s0", "ipv4": {"type": "pppoe", "pppoe": {"vlan": 7}}},
|
||||
"lans": {"lan": {"interface": "enp2s0"}, "dmz": {"interface": "enp2s0", "vlan": 1002}, "iot": {"interface": "enp3s0", "vlan": 5}}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func kernel(mac string) string { return map[string]string{"aa:aa": "enp1s0", "bb:bb": "enp2s0"}[mac] }
|
||||
|
||||
func TestRenameCard(t *testing.T) {
|
||||
s := renameSettings(t)
|
||||
moved, err := s.rename([]Rename{{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "lan"}}, kernel)
|
||||
if err != nil || moved["enp2s0"] != "lan" {
|
||||
t.Fatalf("moved %v, err %v", moved, err)
|
||||
}
|
||||
if s.cardNames()["lan"] != "bb:bb" {
|
||||
t.Errorf("cards = %v", s.cardNames())
|
||||
}
|
||||
f := s.form()
|
||||
for _, l := range f.LANs {
|
||||
if (l.Name == "lan" || l.Name == "dmz") && !strings.HasPrefix(l.Interface, "lan") {
|
||||
t.Errorf("LAN %s still on %s", l.Name, l.Interface)
|
||||
}
|
||||
if l.Name == "iot" && l.Interface != "enp3s0.5" {
|
||||
t.Errorf("an unrelated LAN moved: %s", l.Interface)
|
||||
}
|
||||
}
|
||||
// The card is now called lan; calling it wan renames the entry, not adds one.
|
||||
if _, err := s.rename([]Rename{{Dev: "lan", MAC: "bb:bb", From: "lan", To: "uplink2"}}, kernel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.cardNames(); len(got) != 1 || got["uplink2"] != "bb:bb" {
|
||||
t.Errorf("cards = %v", got)
|
||||
}
|
||||
// Clearing the name goes back to the kernel's.
|
||||
if _, err := s.rename([]Rename{{Dev: "uplink2", MAC: "bb:bb", From: "uplink2", To: ""}}, kernel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s["router"].(map[string]any)["interfaces"]; ok {
|
||||
t.Error("an empty interfaces table is kept")
|
||||
}
|
||||
if got := s.form().LANs[0].Interface; got != "enp2s0" && got != "enp2s0.1002" {
|
||||
t.Errorf("LAN back on %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameRefused(t *testing.T) {
|
||||
for name, row := range map[string]Rename{
|
||||
"reserved": {Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "eth5"},
|
||||
"not there": {Dev: "enp9s0", From: "enp9s0", To: "lan"},
|
||||
"unused": {Dev: "enp9s0.4", From: "", To: "x"},
|
||||
} {
|
||||
s := renameSettings(t)
|
||||
if _, err := s.rename([]Rename{row}, kernel); err == nil {
|
||||
t.Errorf("%s accepted", name)
|
||||
}
|
||||
}
|
||||
s := renameSettings(t)
|
||||
_, err := s.rename([]Rename{
|
||||
{Dev: "enp1s0", MAC: "aa:aa", From: "enp1s0", To: "wan"},
|
||||
{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "wan"},
|
||||
}, kernel)
|
||||
if err == nil {
|
||||
t.Error("the same name for two cards")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameVLAN(t *testing.T) {
|
||||
s := renameSettings(t)
|
||||
if _, err := s.rename([]Rename{
|
||||
{Dev: "enp2s0.1002", From: "", To: "dmz"},
|
||||
{Dev: "enp1s0.7", From: "", To: "isp"},
|
||||
}, kernel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.vlanNames(); got["enp2s0.1002"] != "dmz" || got["enp1s0.7"] != "isp" || len(got) != 2 {
|
||||
t.Errorf("vlan names = %v", got)
|
||||
}
|
||||
// The identifier in the form is unchanged.
|
||||
for _, l := range s.form().LANs {
|
||||
if l.Name == "dmz" && l.Interface != "enp2s0.1002" {
|
||||
t.Errorf("form identifier = %s", l.Interface)
|
||||
}
|
||||
}
|
||||
// Saving the network form keeps the names; dropping the VLAN drops its name.
|
||||
f := s.form()
|
||||
if _, err := s.apply(f, "/pw"); err != nil && !strings.Contains(err.Error(), "lans") {
|
||||
_ = err
|
||||
}
|
||||
if s.vlanNames()["enp2s0.1002"] != "dmz" {
|
||||
t.Error("the form dropped the VLAN's name")
|
||||
}
|
||||
for i, l := range f.LANs {
|
||||
if l.Name == "dmz" {
|
||||
f.LANs[i].Interface = "enp2s0"
|
||||
}
|
||||
}
|
||||
_, _ = s.apply(f, "/pw")
|
||||
if s.vlanNames()["enp2s0.1002"] != "" {
|
||||
t.Error("an untagged LAN keeps a VLAN device name")
|
||||
}
|
||||
// A name already used by a card is refused.
|
||||
s = renameSettings(t)
|
||||
if _, err := s.rename([]Rename{{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "dmz"}, {Dev: "enp2s0.1002", From: "", To: "dmz"}}, kernel); err == nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The DHCP clients' names in DNS. Kea hands out the leases and Unbound
|
||||
// answers, and nothing connects them, so this does: `router-webui leases-dns`
|
||||
// reads Kea's lease file and publishes `<hostname>.<domain>` (and the reverse
|
||||
// record) in the running Unbound through unbound-control.
|
||||
//
|
||||
// A client chooses its own host name, so it is not trusted: only the first
|
||||
// label is used, only under the router's local domain, and a name that the
|
||||
// configuration publishes (hosts, DHCP reservations, the router itself, a
|
||||
// wildcard or a forward zone it falls under) is never taken over. Of two
|
||||
// clients claiming one name the newer lease wins.
|
||||
|
||||
// staticNames is what the running configuration publishes itself
|
||||
// (/etc/router/dns-static.json, written by the router module).
|
||||
type staticNames struct {
|
||||
Domain string `json:"domain"`
|
||||
Names []string `json:"names"` // full names with records
|
||||
Overrides []string `json:"overrides"` // wildcard bases: the name and everything below
|
||||
ForwardZones []string `json:"forwardZones"` // answered by other resolvers
|
||||
}
|
||||
|
||||
// DynRecord is a published client name.
|
||||
type DynRecord struct {
|
||||
Name string `json:"name"` // without the trailing dot
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// DynSkip is a client that was not published, and why.
|
||||
type DynSkip struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Address string `json:"address"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
func under(name, zone string) bool {
|
||||
return name == zone || strings.HasSuffix(name, "."+zone)
|
||||
}
|
||||
|
||||
// planDynamic decides which leases are published.
|
||||
func planDynamic(leases []Lease, st staticNames, now time.Time) (map[string]DynRecord, []DynSkip) {
|
||||
want := map[string]DynRecord{}
|
||||
var skipped []DynSkip
|
||||
if st.Domain == "" {
|
||||
return want, skipped
|
||||
}
|
||||
domain := strings.ToLower(strings.TrimSuffix(st.Domain, "."))
|
||||
static := map[string]bool{}
|
||||
for _, n := range st.Names {
|
||||
static[strings.ToLower(strings.TrimSuffix(n, "."))] = true
|
||||
}
|
||||
var active []Lease
|
||||
for _, l := range leases {
|
||||
if l.Hostname != "" && l.Expires.After(now) {
|
||||
active = append(active, l)
|
||||
}
|
||||
}
|
||||
// Newest lease first: it wins a name.
|
||||
slices.SortStableFunc(active, func(a, b Lease) int {
|
||||
if c := b.Start.Compare(a.Start); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Address, b.Address)
|
||||
})
|
||||
for _, l := range active {
|
||||
label, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(l.Hostname)), ".")
|
||||
skip := func(reason string) {
|
||||
skipped = append(skipped, DynSkip{Hostname: l.Hostname, Address: l.Address, Reason: reason})
|
||||
}
|
||||
if !labelRe.MatchString(label) {
|
||||
skip("not a usable host name")
|
||||
continue
|
||||
}
|
||||
if a, err := netip.ParseAddr(l.Address); err != nil || !a.Is4() {
|
||||
skip("not an IPv4 address")
|
||||
continue
|
||||
}
|
||||
name := label + "." + domain
|
||||
switch {
|
||||
case static[name]:
|
||||
skip("the configuration has a record with this name")
|
||||
case slices.ContainsFunc(st.Overrides, func(z string) bool { return under(name, strings.ToLower(strings.TrimSuffix(z, "."))) }):
|
||||
skip("below a wildcard record")
|
||||
case slices.ContainsFunc(st.ForwardZones, func(z string) bool { return under(name, strings.ToLower(strings.TrimSuffix(z, "."))) }):
|
||||
skip("below a forwarded zone")
|
||||
default:
|
||||
if other, ok := want[name]; ok {
|
||||
skip("a newer lease (" + other.Address + ") has this name")
|
||||
continue
|
||||
}
|
||||
want[name] = DynRecord{Name: name, Address: l.Address}
|
||||
}
|
||||
}
|
||||
return want, skipped
|
||||
}
|
||||
|
||||
// reverseName is the in-addr.arpa name of an IPv4 address.
|
||||
func reverseName(addr string) string {
|
||||
a, err := netip.ParseAddr(addr)
|
||||
if err != nil || !a.Is4() {
|
||||
return ""
|
||||
}
|
||||
b := a.As4()
|
||||
return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa", b[3], b[2], b[1], b[0])
|
||||
}
|
||||
|
||||
// controller runs unbound-control commands.
|
||||
type controller interface {
|
||||
run(args ...string) (string, error)
|
||||
}
|
||||
|
||||
type unboundControl struct{ socket, config string }
|
||||
|
||||
func (u unboundControl) run(args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
pre := []string{"-s", u.socket}
|
||||
if u.config != "" { // else unbound-control reads /etc/unbound/unbound.conf
|
||||
pre = append(pre, "-c", u.config)
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "unbound-control", append(pre, args...)...).CombinedOutput()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
const dynTTL = "300"
|
||||
|
||||
func publish(ctl controller, r DynRecord) error {
|
||||
if _, err := ctl.run("local_data", r.Name+".", dynTTL, "IN", "A", r.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
if rev := reverseName(r.Address); rev != "" {
|
||||
_, err := ctl.run("local_data", rev+".", dynTTL, "IN", "PTR", r.Name+".")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unpublish(ctl controller, r DynRecord) error {
|
||||
var first error
|
||||
for _, n := range []string{r.Name, reverseName(r.Address)} {
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := ctl.run("local_data_remove", n+"."); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// syncDynamic brings Unbound from what was published to what is wanted. With
|
||||
// fresh set (a new Unbound, or a restarted daemon) everything published
|
||||
// before is removed first and the wanted records are all added.
|
||||
func syncDynamic(ctl controller, published, want map[string]DynRecord, fresh bool) (map[string]DynRecord, error) {
|
||||
now := map[string]DynRecord{}
|
||||
var firstErr error
|
||||
note := func(err error) {
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
for name, r := range published {
|
||||
if w, ok := want[name]; fresh || !ok || w.Address != r.Address {
|
||||
note(unpublish(ctl, r))
|
||||
} else {
|
||||
now[name] = r
|
||||
}
|
||||
}
|
||||
for name, w := range want {
|
||||
if _, ok := now[name]; ok {
|
||||
continue
|
||||
}
|
||||
if err := publish(ctl, w); err != nil {
|
||||
note(err)
|
||||
continue
|
||||
}
|
||||
now[name] = w
|
||||
}
|
||||
return now, firstErr
|
||||
}
|
||||
|
||||
// dynStatus is what the daemon leaves for the web UI.
|
||||
type dynStatus struct {
|
||||
Published []DynRecord `json:"published"`
|
||||
Skipped []DynSkip `json:"skipped"`
|
||||
Updated time.Time `json:"updated"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func readDynStatus(dir string) (dynStatus, bool) {
|
||||
var st dynStatus
|
||||
data, err := os.ReadFile(filepath.Join(dir, "status.json"))
|
||||
if err != nil || json.Unmarshal(data, &st) != nil {
|
||||
return st, false
|
||||
}
|
||||
return st, true
|
||||
}
|
||||
|
||||
func writeDynStatus(dir string, st dynStatus) {
|
||||
data, _ := json.Marshal(st)
|
||||
if err := writeFileAtomic(filepath.Join(dir, "status.json"), data, 0o644); err != nil {
|
||||
log.Printf("status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordsOf(m map[string]DynRecord) []DynRecord {
|
||||
out := make([]DynRecord, 0, len(m))
|
||||
for _, r := range m {
|
||||
out = append(out, r)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b DynRecord) int { return strings.Compare(a.Name, b.Name) })
|
||||
return out
|
||||
}
|
||||
|
||||
func loadStatic(path string) staticNames {
|
||||
var st staticNames
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
json.Unmarshal(data, &st)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// unboundPID tells a restarted Unbound (which lost what was published) from
|
||||
// the same one.
|
||||
func unboundPID(ctl controller) (string, error) {
|
||||
out, err := ctl.run("status")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseUnboundPID(out)
|
||||
}
|
||||
|
||||
var pidRe = regexp.MustCompile(`\(pid ([0-9]+)\)`)
|
||||
|
||||
// parseUnboundPID reads the last line of `unbound-control status`:
|
||||
// "unbound (pid 1234) is running...".
|
||||
func parseUnboundPID(status string) (string, error) {
|
||||
if m := pidRe.FindStringSubmatch(status); m != nil {
|
||||
return m[1], nil
|
||||
}
|
||||
return "", fmt.Errorf("no pid in unbound-control status")
|
||||
}
|
||||
|
||||
// leasesDNS is `router-webui leases-dns`.
|
||||
func leasesDNS(args []string) {
|
||||
fs := flag.NewFlagSet("leases-dns", flag.ExitOnError)
|
||||
leases := fs.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
|
||||
staticPath := fs.String("static", "/etc/router/dns-static.json", "the names the configuration publishes")
|
||||
socket := fs.String("socket", "/run/unbound/unbound.ctl", "Unbound's control socket")
|
||||
config := fs.String("unbound-config", "", "Unbound's config file (default: unbound-control's own)")
|
||||
run := fs.String("run", "/run/router-leases-dns", "directory for the status the web UI shows")
|
||||
interval := fs.Duration("interval", 5*time.Second, "how often the leases are read")
|
||||
once := fs.Bool("once", false, "do one round and exit")
|
||||
fs.Parse(args)
|
||||
|
||||
ctl := unboundControl{socket: *socket, config: *config}
|
||||
if err := os.MkdirAll(*run, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
// What a previous run published is removed first: the daemon may have
|
||||
// been restarted while Unbound kept its data.
|
||||
published := map[string]DynRecord{}
|
||||
if st, ok := readDynStatus(*run); ok {
|
||||
for _, r := range st.Published {
|
||||
published[r.Name] = r
|
||||
}
|
||||
}
|
||||
pid, fresh := "", true
|
||||
var lastErr string
|
||||
for {
|
||||
st := loadStatic(*staticPath)
|
||||
want, skipped := planDynamic(readLeasesAt(*leases, time.Now()), st, time.Now())
|
||||
var err error
|
||||
if cur, perr := unboundPID(ctl); perr != nil {
|
||||
err = perr
|
||||
} else {
|
||||
if cur != pid {
|
||||
fresh = true // a new Unbound has none of it
|
||||
}
|
||||
var now map[string]DynRecord
|
||||
if now, err = syncDynamic(ctl, published, want, fresh); err == nil {
|
||||
fresh, pid = false, cur
|
||||
}
|
||||
published = now
|
||||
}
|
||||
status := dynStatus{Published: recordsOf(published), Skipped: skipped, Updated: time.Now()}
|
||||
if err != nil {
|
||||
status.Error = err.Error()
|
||||
if status.Error != lastErr {
|
||||
log.Printf("unbound-control: %v", err)
|
||||
}
|
||||
}
|
||||
lastErr = status.Error
|
||||
writeDynStatus(*run, status)
|
||||
if *once {
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Leave nothing behind: the names would outlive the daemon.
|
||||
syncDynamic(ctl, published, nil, false)
|
||||
writeDynStatus(*run, dynStatus{Updated: time.Now()})
|
||||
return
|
||||
case <-time.After(*interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLeasesAt reads the active leases of the lease file (without the LAN
|
||||
// names the dashboard adds).
|
||||
func readLeasesAt(path string, now time.Time) []Lease {
|
||||
return readLeases(path, nil, now)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var t0 = time.Unix(1_800_000_000, 0)
|
||||
|
||||
func lease(host, addr string, startAgo time.Duration) Lease {
|
||||
return Lease{Hostname: host, Address: addr, Start: t0.Add(-startAgo), Expires: t0.Add(time.Hour - startAgo)}
|
||||
}
|
||||
|
||||
var testStatic = staticNames{
|
||||
Domain: "home.example.org",
|
||||
Names: []string{"router.home.example.org", "nas.home.example.org"},
|
||||
Overrides: []string{"apps.home.example.org"},
|
||||
ForwardZones: []string{"branch.home.example.org"},
|
||||
}
|
||||
|
||||
func TestPlanDynamic(t *testing.T) {
|
||||
leases := []Lease{
|
||||
lease("Phone", "192.168.1.101", time.Minute),
|
||||
lease("laptop.evil.example", "192.168.1.102", time.Minute), // only the first label
|
||||
lease("nas", "192.168.1.103", time.Minute), // a static name
|
||||
lease("router", "192.168.1.104", time.Minute), // the router itself
|
||||
lease("web.apps", "192.168.1.105", time.Minute), // first label "web": fine
|
||||
lease("x_y", "192.168.1.106", time.Minute), // not a host name
|
||||
lease("", "192.168.1.107", time.Minute), // none sent
|
||||
lease("tv", "192.168.1.108", 2*time.Hour), // expired (started 2h ago)
|
||||
lease("printer", "192.168.1.110", 10*time.Minute),
|
||||
lease("printer", "192.168.1.111", time.Minute), // newer: wins
|
||||
}
|
||||
want, skipped := planDynamic(leases, testStatic, t0)
|
||||
var names []string
|
||||
for n := range want {
|
||||
names = append(names, n)
|
||||
}
|
||||
slices.Sort(names)
|
||||
if strings.Join(names, " ") != "laptop.home.example.org phone.home.example.org printer.home.example.org web.home.example.org" {
|
||||
t.Errorf("published = %v", names)
|
||||
}
|
||||
if want["printer.home.example.org"].Address != "192.168.1.111" {
|
||||
t.Errorf("the newer lease did not win: %+v", want["printer.home.example.org"])
|
||||
}
|
||||
reasons := map[string]string{}
|
||||
for _, s := range skipped {
|
||||
reasons[s.Address] = s.Reason
|
||||
}
|
||||
for addr, part := range map[string]string{"192.168.1.103": "configuration", "192.168.1.104": "configuration",
|
||||
"192.168.1.106": "not a usable", "192.168.1.110": "newer lease"} {
|
||||
if !strings.Contains(reasons[addr], part) {
|
||||
t.Errorf("%s: skipped for %q, want %q", addr, reasons[addr], part)
|
||||
}
|
||||
}
|
||||
// Names below a wildcard or a forward zone are not taken.
|
||||
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "apps.home.example.org", Overrides: []string{"apps.home.example.org"}}, t0)
|
||||
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "wildcard") {
|
||||
t.Errorf("wildcard: %+v", skipped)
|
||||
}
|
||||
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "b.example.org", ForwardZones: []string{"b.example.org"}}, t0)
|
||||
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "forwarded") {
|
||||
t.Errorf("forward zone: %+v", skipped)
|
||||
}
|
||||
// Without a domain nothing is published.
|
||||
if w, _ := planDynamic(leases, staticNames{}, t0); len(w) != 0 {
|
||||
t.Error("published without a domain")
|
||||
}
|
||||
// Not IPv4.
|
||||
if w, s := planDynamic([]Lease{lease("v6", "fd00::1", 0)}, testStatic, t0); len(w) != 0 || len(s) != 1 {
|
||||
t.Errorf("IPv6 lease: %v %v", w, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseName(t *testing.T) {
|
||||
if got := reverseName("192.168.1.101"); got != "101.1.168.192.in-addr.arpa" {
|
||||
t.Errorf("reverse = %q", got)
|
||||
}
|
||||
if reverseName("fd00::1") != "" || reverseName("x") != "" {
|
||||
t.Error("a non-IPv4 address has no reverse name here")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCtl struct {
|
||||
cmds []string
|
||||
fail string
|
||||
}
|
||||
|
||||
func (f *fakeCtl) run(args ...string) (string, error) {
|
||||
cmd := strings.Join(args, " ")
|
||||
f.cmds = append(f.cmds, cmd)
|
||||
if f.fail != "" && strings.Contains(cmd, f.fail) {
|
||||
return "", errors.New("boom")
|
||||
}
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
func TestSyncDynamic(t *testing.T) {
|
||||
a := DynRecord{Name: "a.home.example.org", Address: "192.168.1.101"}
|
||||
b := DynRecord{Name: "b.home.example.org", Address: "192.168.1.102"}
|
||||
ctl := &fakeCtl{}
|
||||
got, err := syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("first sync: %v %v", got, err)
|
||||
}
|
||||
if !slices.Contains(ctl.cmds, "local_data a.home.example.org. 300 IN A 192.168.1.101") ||
|
||||
!slices.Contains(ctl.cmds, "local_data 101.1.168.192.in-addr.arpa. 300 IN PTR a.home.example.org.") {
|
||||
t.Errorf("commands = %v", ctl.cmds)
|
||||
}
|
||||
|
||||
// Nothing changed: nothing is sent.
|
||||
ctl.cmds = nil
|
||||
if _, err := syncDynamic(ctl, got, map[string]DynRecord{a.Name: a, b.Name: b}, false); err != nil || len(ctl.cmds) != 0 {
|
||||
t.Errorf("no change sent %v (%v)", ctl.cmds, err)
|
||||
}
|
||||
|
||||
// b went away, a moved: b is removed, a replaced.
|
||||
a2 := DynRecord{Name: a.Name, Address: "192.168.1.150"}
|
||||
got, err = syncDynamic(ctl, got, map[string]DynRecord{a.Name: a2}, false)
|
||||
if err != nil || len(got) != 1 || got[a.Name].Address != "192.168.1.150" {
|
||||
t.Fatalf("second sync: %v %v", got, err)
|
||||
}
|
||||
for _, want := range []string{"local_data_remove b.home.example.org.", "local_data_remove 102.1.168.192.in-addr.arpa.",
|
||||
"local_data_remove a.home.example.org.", "local_data a.home.example.org. 300 IN A 192.168.1.150"} {
|
||||
if !slices.Contains(ctl.cmds, want) {
|
||||
t.Errorf("missing %q in %v", want, ctl.cmds)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh Unbound (or daemon): everything is removed, then added again.
|
||||
ctl.cmds = nil
|
||||
if _, err := syncDynamic(ctl, got, got, true); err != nil || len(ctl.cmds) != 4 {
|
||||
t.Errorf("fresh sync: %v %v", ctl.cmds, err)
|
||||
}
|
||||
|
||||
// A failing add is not recorded as published, so it is retried.
|
||||
ctl = &fakeCtl{fail: "local_data a."}
|
||||
got, err = syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
|
||||
if err == nil || len(got) != 1 || got[b.Name].Name == "" {
|
||||
t.Errorf("failing add: %v %v", got, err)
|
||||
}
|
||||
|
||||
// Stopping removes everything.
|
||||
ctl = &fakeCtl{}
|
||||
if got, _ := syncDynamic(ctl, map[string]DynRecord{a.Name: a, b.Name: b}, nil, false); len(got) != 0 || len(ctl.cmds) != 4 {
|
||||
t.Errorf("stop: %v %v", got, ctl.cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnboundPID(t *testing.T) {
|
||||
out := "version: 1.26.1\nverbosity: 1\nthreads: 1\nuptime: 0 seconds\nunbound (pid 1458081) is running...\n"
|
||||
if pid, err := parseUnboundPID(out); err != nil || pid != "1458081" {
|
||||
t.Errorf("pid = %q %v", pid, err)
|
||||
}
|
||||
if _, err := parseUnboundPID("error: connection refused"); err == nil {
|
||||
t.Error("a pid out of nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseStartIsRead(t *testing.T) {
|
||||
csv := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id\n" +
|
||||
"192.168.1.101,aa:bb:cc:00:00:01,,3600,4102444800,1,0,0,phone,0,,0\n"
|
||||
latest := map[string]Lease{}
|
||||
parseLeases(strings.NewReader(csv), latest, time.Unix(1_800_000_000, 0))
|
||||
l := latest["192.168.1.101"]
|
||||
if l.Expires.Unix() != 4102444800 || l.Start.Unix() != 4102444800-3600 {
|
||||
t.Errorf("lease = %+v", l)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// get_logs: the system journal, read-only, through journalctl with its
|
||||
// arguments built here (no shell), so that nothing a caller sends can become
|
||||
// an option.
|
||||
|
||||
var (
|
||||
unitRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@:._-]{0,79}$`)
|
||||
timeRe = regexp.MustCompile(`^[0-9A-Za-z :.+/_,-]{1,40}$`)
|
||||
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
|
||||
logSize = 256 << 10
|
||||
)
|
||||
|
||||
// runJournal runs journalctl (replaced in tests).
|
||||
var runJournal = command
|
||||
|
||||
type logQuery struct {
|
||||
Unit string `json:"unit"`
|
||||
Lines int `json:"lines"`
|
||||
Since string `json:"since"`
|
||||
Until string `json:"until"`
|
||||
Priority string `json:"priority"`
|
||||
Grep string `json:"grep"`
|
||||
Boot *int `json:"boot"`
|
||||
}
|
||||
|
||||
// journalArgs turns a query into journalctl's arguments.
|
||||
func journalArgs(q logQuery) ([]string, error) {
|
||||
if q.Lines == 0 {
|
||||
q.Lines = 100
|
||||
}
|
||||
if q.Lines < 1 || q.Lines > maxLogs {
|
||||
return nil, argError(fmt.Sprintf("lines must be 1 to %d", maxLogs))
|
||||
}
|
||||
args := []string{"--no-pager", "-o", "short-iso", "--lines=" + strconv.Itoa(q.Lines)}
|
||||
switch {
|
||||
case q.Unit == "kernel":
|
||||
args = append(args, "-k")
|
||||
case q.Unit == "":
|
||||
return nil, argError("unit is required: kernel or one of " + strings.Join(logUnits, ", "))
|
||||
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)
|
||||
}
|
||||
for _, t := range []struct{ flag, v string }{{"--since=", q.Since}, {"--until=", q.Until}} {
|
||||
if t.v == "" {
|
||||
continue
|
||||
}
|
||||
if !timeRe.MatchString(t.v) {
|
||||
return nil, argError(strings.TrimPrefix(strings.TrimSuffix(t.flag, "="), "--") + ": a time like \"1 hour ago\", \"-2h\", \"yesterday\" or \"2026-10-31 10:00:00\"")
|
||||
}
|
||||
args = append(args, t.flag+t.v)
|
||||
}
|
||||
if q.Priority != "" {
|
||||
if !prioRe.MatchString(q.Priority) {
|
||||
return nil, argError("priority: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (that level and worse)")
|
||||
}
|
||||
args = append(args, "--priority="+q.Priority)
|
||||
}
|
||||
if q.Grep != "" {
|
||||
if len(q.Grep) > 200 {
|
||||
return nil, argError("grep: at most 200 characters")
|
||||
}
|
||||
args = append(args, "--grep="+q.Grep, "--case-sensitive=false")
|
||||
}
|
||||
if q.Boot != nil {
|
||||
if *q.Boot > 0 || *q.Boot < -50 {
|
||||
return nil, argError("boot: 0 (this boot), -1 (the one before), ...")
|
||||
}
|
||||
args = append(args, "--boot="+strconv.Itoa(*q.Boot))
|
||||
}
|
||||
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{
|
||||
name: "get_logs", scope: scopeRead,
|
||||
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{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"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."},
|
||||
"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."},
|
||||
"priority": map[string]any{"type": "string", "description": "This level and worse: 0-7 or emerg, alert, crit, err, warning, notice, info, debug."},
|
||||
"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."},
|
||||
},
|
||||
"required": []string{"unit"},
|
||||
},
|
||||
run: func(a *app, raw json.RawMessage) (any, error) {
|
||||
var q logQuery
|
||||
if err := json.Unmarshal(raw, &q); err != nil {
|
||||
return nil, argError("bad arguments: " + err.Error())
|
||||
}
|
||||
args, err := journalArgs(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := runJournal("journalctl", args...)
|
||||
if err != nil && out == "" {
|
||||
return nil, fmt.Errorf("journalctl: %w", err)
|
||||
}
|
||||
if len(out) > logSize {
|
||||
out = "[…]\n" + out[len(out)-logSize:]
|
||||
}
|
||||
if out == "" || strings.HasPrefix(out, "-- No entries --") {
|
||||
return "No matching entries.", nil
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
// router-webui: a small web UI for a nixos-router configured by settings.json.
|
||||
//
|
||||
// It edits the settings, then asks systemd to run router-apply (build and
|
||||
// activate with commit-confirm) and router-confirm. It never builds anything
|
||||
// itself.
|
||||
//
|
||||
// router-webui [-listen :443] [-config DIR] [-state DIR] [-leases FILE]
|
||||
// router-webui passwd FILE (reads the new password from stdin)
|
||||
// router-webui unbundle FILE DIR (stages an update bundle; FILE - is stdin)
|
||||
// router-webui leases-dns (publishes DHCP clients' names in Unbound)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"embed"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var embedded embed.FS
|
||||
|
||||
// assets is what the UI serves: the embedded files, or with -assets DIR (for
|
||||
// development) a directory whose templates are re-read on every request.
|
||||
var assets fs.FS = embedded
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "leases-dns" {
|
||||
leasesDNS(os.Args[2:])
|
||||
return
|
||||
}
|
||||
if len(os.Args) == 3 && os.Args[1] == "passwd" {
|
||||
if err := setPassword(os.Args[2]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) == 4 && os.Args[1] == "unbundle" {
|
||||
var in io.Reader = os.Stdin
|
||||
if os.Args[2] != "-" {
|
||||
f, err := os.Open(os.Args[2])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
if err := unbundle(in, os.Args[3]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
listen := flag.String("listen", ":443", "address to serve HTTPS on")
|
||||
httpListen := flag.String("http", "", "address that redirects HTTP to HTTPS (empty: none)")
|
||||
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
|
||||
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
|
||||
acmeDir := flag.String("acme-dir", "", "directory of a certificate from an ACME client (fullchain.pem, key.pem), served when present")
|
||||
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
|
||||
options := flag.String("options", "", "JSON documentation of the router options (offered to MCP clients)")
|
||||
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
|
||||
flag.Parse()
|
||||
if *dev != "" {
|
||||
assets = os.DirFS(*dev)
|
||||
}
|
||||
|
||||
sys := System{ConfigDir: *configDir, StateDir: *stateDir, ACMEDir: *acmeDir}
|
||||
// What was installed is confirmed; later router-confirm keeps this copy.
|
||||
if _, err := os.Stat(sys.confirmedPath()); errors.Is(err, fs.ErrNotExist) {
|
||||
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
|
||||
if err := writeFileAtomic(sys.confirmedPath(), data, 0o600); err != nil {
|
||||
log.Printf("recording confirmed settings: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cert, err := loadOrCreateCert(filepath.Join(*stateDir, "webui"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
metrics := newCollector(sys, *leases)
|
||||
srv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: withOptions(newApp(sys, metrics, *dev != ""), *options).routes(),
|
||||
TLSConfig: &tls.Config{GetCertificate: (&certSource{fallback: cert, dir: *acmeDir}).get, MinVersion: tls.VersionTLS12},
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// On stop (a switch restarts the UI) the metric history is saved.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
collected := make(chan struct{})
|
||||
go func() {
|
||||
metrics.Run(ctx)
|
||||
close(collected)
|
||||
}()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(shutdown)
|
||||
}()
|
||||
if *httpListen != "" {
|
||||
plain := &http.Server{Addr: *httpListen, Handler: redirectToHTTPS(*listen), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
if err := plain.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("http redirect: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
plain.Close()
|
||||
}()
|
||||
}
|
||||
log.Printf("listening on %s", *listen)
|
||||
if err := srv.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
<-collected
|
||||
}
|
||||
|
||||
func setPassword(path string) error {
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return fmt.Errorf("reading the password from stdin: %w", err)
|
||||
}
|
||||
password := strings.TrimRight(line, "\r\n")
|
||||
if len(password) < 8 {
|
||||
return errors.New("the password must have at least 8 characters")
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(path, []byte(hash+"\n"), 0o600)
|
||||
}
|
||||
|
||||
// loadOrCreateCert keeps a self-signed certificate in dir; browsers will warn
|
||||
// once, then the fingerprint stays the same.
|
||||
func loadOrCreateCert(dir string) (tls.Certificate, error) {
|
||||
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
if cert, err := tls.LoadX509KeyPair(certFile, keyFile); err == nil {
|
||||
return cert, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: host},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(20, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{host, "localhost"},
|
||||
}
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ipnet.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := writeFileAtomic(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.LoadX509KeyPair(certFile, keyFile)
|
||||
}
|
||||
|
||||
type app struct {
|
||||
sys System
|
||||
metrics *Collector
|
||||
sessions *sessions
|
||||
dev bool // re-read the templates on every request
|
||||
funcs template.FuncMap
|
||||
pagesMu sync.Mutex
|
||||
pages map[string]*template.Template
|
||||
loginMu sync.Mutex // one login attempt at a time
|
||||
|
||||
tokens *tokenStore
|
||||
settingsMu sync.Mutex // edits through the API read, patch and write the settings file
|
||||
schemaOnce sync.Once
|
||||
schemaVal map[string]any // the settings schema, from optionsFile (nil if none)
|
||||
optionsFile string // JSON documentation of the router options, for the MCP resource ("" if none)
|
||||
}
|
||||
|
||||
func withOptions(a *app, file string) *app {
|
||||
a.optionsFile = file
|
||||
return a
|
||||
}
|
||||
|
||||
func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute), tokens: newTokenStore(sys.tokensPath())}
|
||||
a.funcs = template.FuncMap{
|
||||
"short": func(p string) string {
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
|
||||
return p[i+34:] // drop /nix/store/<hash>-
|
||||
}
|
||||
return p
|
||||
},
|
||||
"duration": func(d time.Duration) string { return d.Round(time.Second).String() },
|
||||
"seconds": func(d time.Duration) int { return int(d.Seconds()) },
|
||||
"list": func(xs ...string) []string { return xs },
|
||||
"bytes": formatBytes,
|
||||
"age": formatAge,
|
||||
"ago": func(t time.Time) string { return formatAge(time.Since(t)) },
|
||||
"until": func(t time.Time) string { return formatAge(time.Until(t)) },
|
||||
"value": formatValue,
|
||||
"ms": func(d time.Duration) string { return formatValue(float64(d.Microseconds())/1000, "ms") },
|
||||
"isNaN": math.IsNaN,
|
||||
"chartW": func() float64 { return chartW },
|
||||
"chartH": func() float64 { return chartH },
|
||||
"join": strings.Join,
|
||||
"choices": choices,
|
||||
"devLabel": devLabel,
|
||||
"tform": func(t WGTunnel, csrf string, isNew bool) map[string]any {
|
||||
return map[string]any{"T": t, "CSRF": csrf, "New": isNew}
|
||||
},
|
||||
}
|
||||
pages, err := a.parseTemplates()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
a.pages = pages
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "upgrade", "tokens", "changes"} {
|
||||
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages[page] = t
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// page returns a template; in development it first re-reads them (a typo
|
||||
// keeps the previous version and is logged).
|
||||
func (a *app) page(name string) *template.Template {
|
||||
a.pagesMu.Lock()
|
||||
defer a.pagesMu.Unlock()
|
||||
if a.dev {
|
||||
if pages, err := a.parseTemplates(); err != nil {
|
||||
log.Printf("templates: %v", err)
|
||||
} else {
|
||||
a.pages = pages
|
||||
}
|
||||
}
|
||||
return a.pages[name]
|
||||
}
|
||||
|
||||
func (a *app) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
static, _ := fs.Sub(assets, "static")
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
|
||||
mux.HandleFunc("GET /login", a.loginPage)
|
||||
mux.HandleFunc("POST /login", a.login)
|
||||
mux.HandleFunc("POST /logout", a.auth(a.logout))
|
||||
mux.HandleFunc("GET /{$}", a.auth(a.overview))
|
||||
mux.HandleFunc("POST /apply", a.auth(a.apply))
|
||||
mux.HandleFunc("POST /confirm", a.auth(a.confirm))
|
||||
mux.HandleFunc("POST /discard", a.auth(a.discard))
|
||||
mux.HandleFunc("POST /reboot", a.auth(a.reboot))
|
||||
mux.HandleFunc("POST /password", a.auth(a.password))
|
||||
mux.HandleFunc("GET /network", a.auth(a.networkPage))
|
||||
mux.HandleFunc("POST /network", a.auth(a.network))
|
||||
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
||||
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
|
||||
mux.HandleFunc("POST /vlan", a.auth(a.vlan))
|
||||
mux.HandleFunc("POST /interfaces", a.auth(a.interfaces))
|
||||
mux.HandleFunc("GET /dns", a.auth(a.dnsPage))
|
||||
mux.HandleFunc("POST /dns", a.auth(a.dns))
|
||||
mux.HandleFunc("GET /wireguard", a.auth(a.wireguardPage))
|
||||
mux.HandleFunc("POST /wireguard/save", a.auth(a.wireguardSave))
|
||||
mux.HandleFunc("POST /wireguard/delete", a.auth(a.wireguardDelete))
|
||||
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
|
||||
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
|
||||
mux.HandleFunc("GET /ddns", a.auth(a.ddnsPage))
|
||||
mux.HandleFunc("POST /ddns", a.auth(a.ddnsSave))
|
||||
mux.HandleFunc("GET /dnsproviders", a.auth(a.providersPage))
|
||||
mux.HandleFunc("POST /dnsproviders", a.auth(a.providersSave))
|
||||
mux.HandleFunc("GET /certificate", a.auth(a.certificatePage))
|
||||
mux.HandleFunc("POST /certificate", a.auth(a.certificateSave))
|
||||
mux.HandleFunc("GET /firewall", a.auth(a.firewallPage))
|
||||
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
|
||||
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
|
||||
mux.HandleFunc("POST /dhcp", a.auth(a.dhcp))
|
||||
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
|
||||
mux.HandleFunc("POST /prefs", a.auth(a.savePrefs))
|
||||
mux.HandleFunc("POST /backup", a.auth(a.backup))
|
||||
mux.HandleFunc("GET /update", a.auth(a.updatePage))
|
||||
mux.HandleFunc("POST /update/url", a.auth(a.updateSaveURL))
|
||||
mux.HandleFunc("POST /update/check", a.auth(a.updateCheck))
|
||||
mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
|
||||
mux.HandleFunc("POST /update/upload", a.updateUpload)
|
||||
mux.HandleFunc("GET /upgrade", a.auth(a.upgradePage))
|
||||
mux.HandleFunc("POST /upgrade/channel", a.auth(a.upgradeChannel))
|
||||
mux.HandleFunc("POST /upgrade/{step}", a.auth(a.upgradeStep))
|
||||
mux.HandleFunc("/mcp", a.mcp)
|
||||
a.registerAPI(mux)
|
||||
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
|
||||
mux.HandleFunc("GET /apireference", a.auth(a.apiReference))
|
||||
mux.HandleFunc("POST /tokens", a.auth(a.tokenCreate))
|
||||
mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke))
|
||||
mux.HandleFunc("GET /changes", a.auth(a.changesPage))
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
// Not no-referrer: under it browsers send "Origin: null" with form posts.
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type page struct {
|
||||
Title string
|
||||
CSRF string
|
||||
Refresh int // seconds; 0 = none
|
||||
Flash string
|
||||
Error string
|
||||
LoggedIn bool
|
||||
Data any
|
||||
Theme string // set by render: "", "light" or "dark"
|
||||
}
|
||||
|
||||
func (a *app) render(w http.ResponseWriter, name string, p page) {
|
||||
p.Theme = a.sys.loadPrefs().Theme
|
||||
if err := a.page(name).Execute(w, p); err != nil {
|
||||
log.Printf("rendering %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
const cookieName = "router-session"
|
||||
|
||||
// auth requires a session; for POSTs also a matching CSRF token and, if the
|
||||
// browser sends one, a same-origin Origin header.
|
||||
func (a *app) session(r *http.Request) (session, bool) {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return session{}, false
|
||||
}
|
||||
return a.sessions.get(c.Value)
|
||||
}
|
||||
|
||||
func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := a.session(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
if !sameOrigin(r) || r.PostFormValue("csrf") != sess.csrf {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
h(w, r, sess)
|
||||
}
|
||||
}
|
||||
|
||||
// Sec-Fetch-Site first (browsers may send "Origin: null" for same-origin
|
||||
// form posts), then Origin; requests with neither (curl) pass.
|
||||
var crossOrigin = http.NewCrossOriginProtection()
|
||||
|
||||
// redirectToHTTPS sends plain HTTP requests to the same host and path over
|
||||
// HTTPS; httpsAddr is where the HTTPS server listens.
|
||||
func redirectToHTTPS(httpsAddr string) http.Handler {
|
||||
_, port, _ := net.SplitHostPort(httpsAddr)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if strings.Contains(host, ":") { // an IPv6 literal
|
||||
host = "[" + strings.Trim(host, "[]") + "]"
|
||||
}
|
||||
if port != "" && port != "443" {
|
||||
host += ":" + port
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
func sameOrigin(r *http.Request) bool {
|
||||
return crossOrigin.Check(r) == nil
|
||||
}
|
||||
|
||||
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
p := page{Title: "Log in"}
|
||||
if _, err := os.Stat(a.sys.passwordPath()); err != nil {
|
||||
p.Error = "No password is set. Set one on the router: router-webui passwd " + a.sys.passwordPath()
|
||||
}
|
||||
a.render(w, "login", p)
|
||||
}
|
||||
|
||||
func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !sameOrigin(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
a.loginMu.Lock()
|
||||
defer a.loginMu.Unlock()
|
||||
stored, err := os.ReadFile(a.sys.passwordPath())
|
||||
ok := false
|
||||
if err == nil {
|
||||
ok, err = checkPassword(string(stored), r.PostFormValue("password"))
|
||||
if err != nil {
|
||||
log.Printf("password file: %v", err)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
time.Sleep(time.Second)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
a.render(w, "login", page{Title: "Log in", Error: "Wrong password."})
|
||||
return
|
||||
}
|
||||
id, _ := a.sessions.create()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName, Value: id, Path: "/",
|
||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) logout(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
a.sessions.delete(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Path: "/", MaxAge: -1, Secure: true, HttpOnly: true})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type overviewData struct {
|
||||
Status Status
|
||||
Dashboard dashboard
|
||||
}
|
||||
|
||||
func (a *app) overview(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
st := a.sys.Status()
|
||||
p := page{Title: "Overview", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
|
||||
Data: overviewData{Status: st, Dashboard: a.dashboardData(r.URL.Query().Get("range"))}}
|
||||
switch {
|
||||
case st.Applying || st.ConfirmRunning:
|
||||
p.Refresh = 2
|
||||
case st.RollbackIn > 0:
|
||||
p.Refresh = 10
|
||||
}
|
||||
a.render(w, "overview", p)
|
||||
}
|
||||
|
||||
func back(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
http.Redirect(w, r, "/?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) apply(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
if err := a.sys.Apply(); err != nil {
|
||||
back(w, r, "Could not start applying: "+err.Error())
|
||||
return
|
||||
}
|
||||
back(w, r, "Applying the settings.")
|
||||
}
|
||||
|
||||
func (a *app) confirm(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
if err := a.sys.Confirm(); err != nil {
|
||||
back(w, r, "Could not confirm: "+err.Error())
|
||||
return
|
||||
}
|
||||
back(w, r, "Confirming.")
|
||||
}
|
||||
|
||||
func (a *app) discard(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
if err := a.sys.Discard(); err != nil {
|
||||
back(w, r, "Could not discard: "+err.Error())
|
||||
return
|
||||
}
|
||||
back(w, r, "Changes discarded; the settings match the confirmed system again.")
|
||||
}
|
||||
|
||||
func (a *app) reboot(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
if err := a.sys.Reboot(); err != nil {
|
||||
back(w, r, "Could not reboot: "+err.Error())
|
||||
return
|
||||
}
|
||||
back(w, r, "Rebooting in a few seconds. This page works again when the router is back.")
|
||||
}
|
||||
|
||||
// password changes the web UI's password and root's, which are one.
|
||||
func (a *app) password(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
a.loginMu.Lock()
|
||||
defer a.loginMu.Unlock()
|
||||
stored, err := os.ReadFile(a.sys.passwordPath())
|
||||
ok := false
|
||||
if err == nil {
|
||||
ok, _ = checkPassword(string(stored), r.PostFormValue("current"))
|
||||
}
|
||||
next := r.PostFormValue("new")
|
||||
switch {
|
||||
case !ok:
|
||||
time.Sleep(time.Second)
|
||||
back(w, r, "The current password is wrong.")
|
||||
case len(next) < 8:
|
||||
back(w, r, "The new password must have at least 8 characters.")
|
||||
case next != r.PostFormValue("confirm"):
|
||||
back(w, r, "The new password and its confirmation differ.")
|
||||
default:
|
||||
if err := setRootPassword(a.sys, next); err != nil {
|
||||
back(w, r, "Could not change the password: "+err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(next)
|
||||
if err == nil {
|
||||
err = writeFileAtomic(a.sys.passwordPath(), []byte(hash+"\n"), 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
back(w, r, "Root's password changed, the web UI's did not: "+err.Error())
|
||||
return
|
||||
}
|
||||
back(w, r, "Password changed for the web UI and for root.")
|
||||
}
|
||||
}
|
||||
|
||||
type networkData struct {
|
||||
Form Form
|
||||
Errors FormErrors
|
||||
Interfaces []string
|
||||
Devices []Device
|
||||
}
|
||||
|
||||
// devices lists what the interface dropdowns offer for the form f.
|
||||
func (a *app) devices(s Settings, f Form) []Device {
|
||||
names := s.cardNames()
|
||||
var physical []string
|
||||
nics := map[string]NIC{}
|
||||
for _, n := range NICs() {
|
||||
eff := effectiveName(names, n)
|
||||
physical = append(physical, eff)
|
||||
nics[eff] = n
|
||||
}
|
||||
devs := f.devices(physical, a.sys.loadPrefs().VLANs)
|
||||
custom := s.vlanNames()
|
||||
for i, d := range devs {
|
||||
if d.Kind == "VLAN" {
|
||||
devs[i].Custom = custom[d.Name]
|
||||
} else if n, ok := nics[d.Name]; ok {
|
||||
devs[i].Kernel, devs[i].MAC = n.Kernel, n.MAC
|
||||
}
|
||||
}
|
||||
return devs
|
||||
}
|
||||
|
||||
// kernelName is the kernel's name for the card with this MAC address.
|
||||
func kernelName(mac string) string {
|
||||
for _, n := range NICs() {
|
||||
if n.MAC == mac {
|
||||
return n.Kernel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// interfaces saves the names typed into the interface table. A new name is
|
||||
// used by the router after the next apply and reboot.
|
||||
func (a *app) interfaces(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
n, _ := strconv.Atoi(r.PostFormValue("rows"))
|
||||
var rows []Rename
|
||||
for i := range min(n, 256) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d", k, i)) }
|
||||
rows = append(rows, Rename{Dev: v("dev"), MAC: v("mac"), From: v("from"), To: v("name")})
|
||||
}
|
||||
moved, err := s.rename(rows, kernelName)
|
||||
msg := ""
|
||||
if err == nil {
|
||||
err = s.save(a.sys.settingsPath())
|
||||
}
|
||||
if err == nil {
|
||||
// The UI's defined VLANs follow a renamed card.
|
||||
prefs := a.sys.loadPrefs()
|
||||
for i, v := range prefs.VLANs {
|
||||
if parent, id := splitVLAN(v); id != 0 && moved[parent] != "" {
|
||||
prefs.VLANs[i] = fmt.Sprintf("%s.%d", moved[parent], id)
|
||||
}
|
||||
}
|
||||
slices.Sort(prefs.VLANs)
|
||||
err = a.sys.savePrefs(prefs)
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
msg = "Names not saved: " + err.Error()
|
||||
case !renamedAny(rows):
|
||||
msg = "Nothing to change."
|
||||
default:
|
||||
msg = "Saved. Apply, then reboot: the new names only exist after a restart."
|
||||
}
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func renamedAny(rows []Rename) bool {
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.To) != r.From {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// vlan defines or removes a VLAN device for the dropdowns. It lives in the
|
||||
// UI's preferences: nothing is rebuilt until a WAN or LAN uses it.
|
||||
func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
prefs := a.sys.loadPrefs()
|
||||
msg := ""
|
||||
switch {
|
||||
case r.PostFormValue("add") != "":
|
||||
parent := strings.TrimSpace(r.PostFormValue("parent"))
|
||||
id, ok, perr := optionalInt(r.PostFormValue("id"), 1, 4094)
|
||||
dev := fmt.Sprintf("%s.%d", parent, id)
|
||||
switch {
|
||||
case !ifaceRe.MatchString(parent) || strings.Contains(parent, "."):
|
||||
msg = "Choose the interface the VLAN is on."
|
||||
case perr != nil || !ok:
|
||||
msg = "The VLAN ID must be a number from 1 to 4094."
|
||||
case slices.Contains(prefs.VLANs, dev):
|
||||
msg = dev + " exists already."
|
||||
default:
|
||||
prefs.VLANs = append(prefs.VLANs, dev)
|
||||
slices.Sort(prefs.VLANs)
|
||||
msg = "Added " + dev + "."
|
||||
}
|
||||
case r.PostFormValue("remove") != "":
|
||||
dev := r.PostFormValue("remove")
|
||||
for _, d := range a.devices(s, s.form()) {
|
||||
if d.Name == dev && !d.Removable {
|
||||
msg = dev + " is in use."
|
||||
}
|
||||
}
|
||||
if msg == "" {
|
||||
prefs.VLANs = slices.DeleteFunc(prefs.VLANs, func(v string) bool { return v == dev })
|
||||
msg = "Removed " + dev + "."
|
||||
}
|
||||
}
|
||||
if err := a.sys.savePrefs(prefs); err != nil {
|
||||
msg = "Saving: " + err.Error()
|
||||
}
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// choices are the dropdown entries for a device: all known devices, plus the
|
||||
// current value if it is not among them (an interface this machine lacks).
|
||||
func choices(devices []Device, current string) []string {
|
||||
var out []string
|
||||
for _, d := range devices {
|
||||
out = append(out, d.Name)
|
||||
}
|
||||
if current != "" && !slices.Contains(out, current) {
|
||||
out = append(out, current)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg")}
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
}
|
||||
f := s.form()
|
||||
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
|
||||
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(s, f)}
|
||||
a.render(w, "network", p)
|
||||
}
|
||||
|
||||
func formFromRequest(r *http.Request) Form {
|
||||
f := Form{
|
||||
HostName: r.PostFormValue("hostName"),
|
||||
Domain: r.PostFormValue("domain"),
|
||||
WAN: WANForm{
|
||||
Interface: strings.TrimSpace(r.PostFormValue("wan.interface")),
|
||||
Type: r.PostFormValue("wan.type"),
|
||||
PPPoEUser: r.PostFormValue("wan.pppoe.username"),
|
||||
IPv6: r.PostFormValue("wan.ipv6") == "on",
|
||||
PPPoEPassword: r.PostFormValue("wan.pppoe.password"),
|
||||
},
|
||||
}
|
||||
n, _ := strconv.Atoi(r.PostFormValue("lans"))
|
||||
for i := range min(n, 64) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("lan.%d.%s", i, k)) }
|
||||
f.LANs = append(f.LANs, LANForm{
|
||||
Name: v("name"),
|
||||
Interface: strings.TrimSpace(v("interface")),
|
||||
Address: v("address"),
|
||||
Prefix: v("prefix"),
|
||||
Internet: v("internet") == "on",
|
||||
IPv6PD: v("ipv6pd") == "on",
|
||||
DHCP: v("dhcp") == "on",
|
||||
PoolStart: v("poolStart"),
|
||||
PoolEnd: v("poolEnd"),
|
||||
Remove: v("remove") == "on",
|
||||
})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
}
|
||||
f := formFromRequest(r)
|
||||
password, err := s.apply(f, a.sys.pppoePath())
|
||||
if err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
f.WAN.PPPoEPassword = ""
|
||||
f.WAN.PasswordSet = s.form().WAN.PasswordSet
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(s, f)}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
}
|
||||
if password != "" {
|
||||
if err := writeFileAtomic(a.sys.pppoePath(), []byte(password), 0o600); err != nil {
|
||||
p.Error = "Storing the PPPoE password: " + err.Error()
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
|
||||
type advancedData struct {
|
||||
Settings string
|
||||
PingTargets string
|
||||
Theme string
|
||||
}
|
||||
|
||||
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
p := page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
|
||||
Data: a.advancedData(string(data))}
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
}
|
||||
a.render(w, "advanced", p)
|
||||
}
|
||||
|
||||
func (a *app) advancedData(settings string) advancedData {
|
||||
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", "), Theme: a.sys.loadPrefs().Theme}
|
||||
}
|
||||
|
||||
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
text := r.PostFormValue("settings")
|
||||
s, err := parseSettings([]byte(text))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestApp(t *testing.T) (*app, System) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
sys := System{ConfigDir: filepath.Join(dir, "config"), StateDir: dir}
|
||||
os.MkdirAll(sys.ConfigDir, 0o700)
|
||||
os.WriteFile(sys.settingsPath(), []byte(sample), 0o600)
|
||||
h, _ := hashPassword("hunter2hunter2")
|
||||
os.WriteFile(sys.passwordPath(), []byte(h), 0o600)
|
||||
return newApp(sys, newCollector(sys, filepath.Join(dir, "leases")), false), sys
|
||||
}
|
||||
|
||||
func do(h http.Handler, method, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if form != nil {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestLoginAndSave(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
|
||||
if rec := do(h, "GET", "/network", nil, nil); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unauthenticated access: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "POST", "/login", url.Values{"password": {"nope"}}, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong password: %d", rec.Code)
|
||||
}
|
||||
// What Firefox and Chrome send for a same-origin form post (seen as 403).
|
||||
req := httptest.NewRequest("POST", "https://192.168.1.1/login", strings.NewReader("password=hunter2hunter2"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "null")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) == 0 {
|
||||
t.Fatalf("browser login: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "https://192.168.1.1/login", strings.NewReader("password=hunter2hunter2"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-site login: %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
|
||||
if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) == 0 {
|
||||
t.Fatalf("login failed: %d", rec.Code)
|
||||
}
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
page := do(h, "GET", "/network", nil, cookie).Body.String()
|
||||
m := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
t.Fatalf("no CSRF token in:\n%s", page)
|
||||
}
|
||||
form := url.Values{
|
||||
"hostName": {"gw"}, "wan.interface": {"eth1"}, "wan.type": {"dhcp"},
|
||||
"lans": {"1"}, "lan.0.name": {"main"}, "lan.0.interface": {"eth2"},
|
||||
"lan.0.address": {"192.168.10.1"}, "lan.0.internet": {"on"}, "lan.0.dhcp": {"on"},
|
||||
"lan.0.poolStart": {"192.168.10.100"}, "lan.0.poolEnd": {"192.168.10.120"},
|
||||
}
|
||||
if rec := do(h, "POST", "/network", form, cookie); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST without CSRF token: %d", rec.Code)
|
||||
}
|
||||
form.Set("csrf", m[1])
|
||||
if rec := do(h, "POST", "/network", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if str(s, "system", "hostName") != "gw" || str(s, "router", "lans", "main", "dhcp", "pool", "end") != "192.168.10.120" {
|
||||
t.Fatalf("not saved: %v", s)
|
||||
}
|
||||
if str(s, "router", "lans", "main", "dhcp", "reservations", "nas", "address") != "192.168.10.5" {
|
||||
t.Fatal("the form dropped the reservation")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("POST", "/advanced", strings.NewReader(url.Values{"csrf": {m[1]}, "settings": {"{}"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin POST: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvancedPageHasRebootButton(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
if body := do(h, "GET", "/advanced", nil, cookie).Body.String(); !strings.Contains(body, `action="/reboot"`) {
|
||||
t.Fatalf("no reboot button:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordChange(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
var root string
|
||||
setRootPassword = func(_ System, pw string) error { root = pw; return nil }
|
||||
t.Cleanup(func() { setRootPassword = (func(System, string) error)(nil) })
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/advanced", nil, cookie).Body.String()
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
try := func(cur, next, confirm string) string {
|
||||
rec := do(h, "POST", "/password", url.Values{"csrf": {token}, "current": {cur}, "new": {next}, "confirm": {confirm}}, cookie)
|
||||
return rec.Header().Get("Location")
|
||||
}
|
||||
for name, loc := range map[string]string{
|
||||
"wrong": try("nope", "newpassword1", "newpassword1"),
|
||||
"short": try("hunter2hunter2", "short", "short"),
|
||||
"mismatch": try("hunter2hunter2", "newpassword1", "newpassword2"),
|
||||
} {
|
||||
if root != "" || !strings.Contains(loc, "msg=") {
|
||||
t.Fatalf("%s: root=%q location=%q", name, root, loc)
|
||||
}
|
||||
}
|
||||
if stored, _ := os.ReadFile(sys.passwordPath()); func() bool { ok, _ := checkPassword(string(stored), "hunter2hunter2"); return !ok }() {
|
||||
t.Fatal("a refused change altered the password")
|
||||
}
|
||||
if loc := try("hunter2hunter2", "newpassword1", "newpassword1"); !strings.Contains(loc, "Password+changed") {
|
||||
t.Fatalf("location %q", loc)
|
||||
}
|
||||
stored, _ := os.ReadFile(sys.passwordPath())
|
||||
if ok, _ := checkPassword(string(stored), "newpassword1"); !ok || root != "newpassword1" {
|
||||
t.Errorf("web UI password not changed or root=%q", root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectToHTTPS(t *testing.T) {
|
||||
for _, c := range []struct{ addr, host, want string }{
|
||||
{":443", "192.168.10.1", "https://192.168.10.1/login?x=1"},
|
||||
{":443", "router.home:80", "https://router.home/login?x=1"},
|
||||
{":8443", "router.home", "https://router.home:8443/login?x=1"},
|
||||
{":443", "[fd00::1]:80", "https://[fd00::1]/login?x=1"},
|
||||
} {
|
||||
req := httptest.NewRequest("GET", "http://"+c.host+"/login?x=1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
redirectToHTTPS(c.addr).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != c.want {
|
||||
t.Errorf("%s %s: %d %q, want %q", c.addr, c.host, rec.Code, rec.Header().Get("Location"), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEIsChecked(t *testing.T) {
|
||||
good := ACMEForm{Enabled: true, Provider: "cf", Domains: "router.example.org\n*.home.example.org", Email: "me@example.org", Staging: true, Agree: true}
|
||||
mk := func() Settings {
|
||||
return withProvider(withProvider(mustParse(t), "cf", "cloudflare"), "dyn", "dyndns2")
|
||||
}
|
||||
if err := mk().applyACME(good); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mod := range map[string]func(*ACMEForm){
|
||||
"provider": func(f *ACMEForm) { f.Provider = "dyn" }, // cannot do DNS-01
|
||||
"domains": func(f *ACMEForm) { f.Domains = "" },
|
||||
"email": func(f *ACMEForm) { f.Email = "" },
|
||||
"agree": func(f *ACMEForm) { f.Agree = false },
|
||||
} {
|
||||
f := good
|
||||
mod(&f)
|
||||
if err := mk().applyACME(f); err == nil || err.(FormErrors)[name] == "" {
|
||||
t.Errorf("%s accepted: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := mk().applyACME(ACMEForm{}); err != nil {
|
||||
t.Errorf("disabled and empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificatePage(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
withProvider(s, "cf", "cloudflare")
|
||||
if err := s.save(sys.settingsPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/certificate", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `<option value="cf"`) {
|
||||
t.Fatalf("the account is not offered:\n%s", body)
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
form := url.Values{"csrf": {token}, "enabled": {"on"}, "provider": {"cf"}, "domains": {"router.example.org"}, "email": {"a@example.org"}}
|
||||
if rec := do(h, "POST", "/certificate", form, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("without the agreement: %d", rec.Code)
|
||||
}
|
||||
form.Set("agree", "on")
|
||||
if rec := do(h, "POST", "/certificate", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ = loadSettings(sys.settingsPath())
|
||||
if f := s.acme(); !f.Enabled || f.Provider != "cf" || !f.Agree || f.Staging {
|
||||
t.Fatalf("not saved: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertSource(t *testing.T) {
|
||||
self, err := loadOrCreateCert(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
c := &certSource{fallback: self, dir: dir}
|
||||
got, _ := c.get(nil)
|
||||
if got != &c.fallback {
|
||||
t.Fatal("served something else without files")
|
||||
}
|
||||
other := t.TempDir()
|
||||
if _, err := loadOrCreateCert(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for from, to := range map[string]string{"cert.pem": "fullchain.pem", "key.pem": "key.pem"} {
|
||||
data, _ := os.ReadFile(filepath.Join(other, from))
|
||||
os.WriteFile(filepath.Join(dir, to), data, 0o600)
|
||||
}
|
||||
if got, _ = c.get(nil); got == &c.fallback || string(got.Certificate[0]) == string(self.Certificate[0]) {
|
||||
t.Fatal("did not pick up the certificate")
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, "key.pem"), []byte("junk"), 0o600)
|
||||
future := time.Now().Add(time.Hour)
|
||||
os.Chtimes(filepath.Join(dir, "key.pem"), future, future)
|
||||
if got, _ = c.get(nil); got != &c.fallback {
|
||||
t.Fatal("served a broken pair")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An MCP server (Model Context Protocol, Streamable HTTP transport) on
|
||||
// POST /mcp, so an assistant can read the router's state and edit its
|
||||
// settings. It is a thin layer over what the web UI does:
|
||||
//
|
||||
// - tokens (tokens.go) instead of the browser session; scope read or change;
|
||||
// - changes only edit settings.json, like the Advanced page: they stay
|
||||
// unapplied until a person applies them in the web UI (commit-confirm);
|
||||
// - secrets are never returned (the settings hold only paths to them), and
|
||||
// file paths in the settings must stay under the state directory;
|
||||
// - SSH keys, the release schema and the state version are out of reach;
|
||||
// - every call is written to the audit log.
|
||||
//
|
||||
// Only requests and responses of JSON-RPC 2.0 over POST are implemented: no
|
||||
// server-initiated messages, no streaming.
|
||||
|
||||
var mcpVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
const mcpInstructions = `This is the management interface of a nixos-router.
|
||||
Read the state with get_status, list_interfaces, list_dhcp_leases, get_settings and get_update_status;
|
||||
get_logs reads the system journal (a unit, a time range, a priority, a text to search for).
|
||||
Settings are one JSON document (the resource router://settings; router://options describes every option).
|
||||
Change them with update_settings (a JSON merge patch; pass the etag from get_settings), then run check_settings,
|
||||
which evaluates the configuration and reports the router's own assertions. Changes are saved but not applied:
|
||||
a person reviews them (get_pending_changes shows the difference) and applies them in the web UI.
|
||||
Never put secrets into the settings: they refer to files (passwordFile, secretFile, ...) that the person provides.`
|
||||
|
||||
// mcp is the HTTP handler of the endpoint.
|
||||
func (a *app) mcp(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "this endpoint takes POST (JSON-RPC)", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
tok, ok := a.bearer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
var req rpcRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil || req.JSONRPC != "2.0" || req.Method == "" {
|
||||
a.rpcReply(w, rpcResponse{ID: json.RawMessage("null"), Error: &rpcError{-32700, "not a JSON-RPC 2.0 request"}})
|
||||
return
|
||||
}
|
||||
if len(req.ID) == 0 { // a notification
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
resp := rpcResponse{ID: req.ID}
|
||||
result, rerr := a.mcpDispatch(tok, req)
|
||||
if rerr != nil {
|
||||
resp.Error = rerr
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
a.rpcReply(w, resp)
|
||||
}
|
||||
|
||||
// bearer authenticates an API request (MCP or REST) by its token; it has
|
||||
// answered the request itself when it returns false.
|
||||
func (a *app) bearer(w http.ResponseWriter, r *http.Request) (Token, bool) {
|
||||
// A browser page must not be able to talk to the router with a token it
|
||||
// does not have, but DNS rebinding could present a foreign Origin.
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
u, err := url.Parse(o)
|
||||
if err != nil || u.Host != r.Host {
|
||||
http.Error(w, "forbidden origin", http.StatusForbidden)
|
||||
return Token{}, false
|
||||
}
|
||||
}
|
||||
secret, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
tok, valid := a.tokens.Lookup(strings.TrimSpace(secret))
|
||||
if !ok || !valid {
|
||||
time.Sleep(time.Second) // a guess costs a second
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="router"`)
|
||||
http.Error(w, "a valid token is required", http.StatusUnauthorized)
|
||||
return Token{}, false
|
||||
}
|
||||
return tok, true
|
||||
}
|
||||
|
||||
func (a *app) rpcReply(w http.ResponseWriter, resp rpcResponse) {
|
||||
resp.JSONRPC = "2.0"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (a *app) mcpDispatch(tok Token, req rpcRequest) (any, *rpcError) {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
version := mcpVersions[0]
|
||||
for _, v := range mcpVersions {
|
||||
if v == p.ProtocolVersion {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"protocolVersion": version,
|
||||
"capabilities": map[string]any{"tools": map[string]any{}, "resources": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": "nixos-router", "version": a.sys.installedRelease().Version},
|
||||
"instructions": mcpInstructions,
|
||||
}, nil
|
||||
case "ping":
|
||||
return map[string]any{}, nil
|
||||
case "tools/list":
|
||||
var out []map[string]any
|
||||
for _, t := range mcpTools {
|
||||
if tok.allows(t.scope) {
|
||||
out = append(out, map[string]any{"name": t.name, "description": t.description, "inputSchema": t.schema})
|
||||
}
|
||||
}
|
||||
return map[string]any{"tools": out}, nil
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
return nil, &rpcError{-32602, "bad parameters"}
|
||||
}
|
||||
return a.mcpCall(tok, p.Name, p.Arguments)
|
||||
case "resources/list":
|
||||
res := []map[string]any{
|
||||
{"uri": "router://settings", "name": "settings", "description": "The saved settings (settings.json).", "mimeType": "application/json"},
|
||||
}
|
||||
if a.optionsFile != "" {
|
||||
res = append(res, map[string]any{"uri": "router://options", "name": "options",
|
||||
"description": "Every router option: type, default, description (what settings.json may contain).", "mimeType": "application/json"})
|
||||
}
|
||||
return map[string]any{"resources": res}, nil
|
||||
case "resources/read":
|
||||
var p struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
return nil, &rpcError{-32602, "bad parameters"}
|
||||
}
|
||||
var text string
|
||||
switch p.URI {
|
||||
case "router://settings":
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, &rpcError{-32603, err.Error()}
|
||||
}
|
||||
text = string(data)
|
||||
case "router://options":
|
||||
data, err := os.ReadFile(a.optionsFile)
|
||||
if a.optionsFile == "" || err != nil {
|
||||
return nil, &rpcError{-32002, "no such resource"}
|
||||
}
|
||||
text = string(data)
|
||||
default:
|
||||
return nil, &rpcError{-32002, "no such resource"}
|
||||
}
|
||||
return map[string]any{"contents": []map[string]any{{"uri": p.URI, "mimeType": "application/json", "text": text}}}, nil
|
||||
}
|
||||
return nil, &rpcError{-32601, "method not found: " + req.Method}
|
||||
}
|
||||
|
||||
type mcpTool struct {
|
||||
name, description, scope string
|
||||
schema map[string]any
|
||||
output any // a value of the result's type (nil: the result is text), for the OpenAPI document
|
||||
run func(a *app, args json.RawMessage) (any, error)
|
||||
}
|
||||
|
||||
// mcpCall runs a tool. A failing tool is not a protocol error: the assistant
|
||||
// is told what went wrong and can correct itself.
|
||||
func (a *app) mcpCall(tok Token, name string, args json.RawMessage) (any, *rpcError) {
|
||||
var tool *mcpTool
|
||||
for i := range mcpTools {
|
||||
if mcpTools[i].name == name {
|
||||
tool = &mcpTools[i]
|
||||
}
|
||||
}
|
||||
if tool == nil || !tok.allows(tool.scope) {
|
||||
return nil, &rpcError{-32602, "unknown tool: " + name}
|
||||
}
|
||||
res, err := a.runTool(tok, tool, args)
|
||||
if err != nil {
|
||||
return map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": err.Error()}}}, nil
|
||||
}
|
||||
text, ok := res.(string)
|
||||
if !ok {
|
||||
data, merr := json.MarshalIndent(res, "", " ")
|
||||
if merr != nil {
|
||||
return nil, &rpcError{-32603, merr.Error()}
|
||||
}
|
||||
text = string(data)
|
||||
}
|
||||
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}}, nil
|
||||
}
|
||||
|
||||
// runTool runs a tool for a token (the scope is checked by the caller) and
|
||||
// writes the call to the audit log.
|
||||
func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, error) {
|
||||
if len(args) == 0 {
|
||||
args = json.RawMessage("{}")
|
||||
}
|
||||
res, err := tool.run(a, args)
|
||||
entry := AuditEntry{Time: time.Now().UTC(), Token: tok.Name, Tool: tool.name, Args: string(args)}
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
a.sys.audit(entry)
|
||||
if err == nil && tool.name == "update_settings" {
|
||||
if r, ok := res.(EditResponse); ok {
|
||||
a.sys.noteTokenEdit(tok.Name, r.ETag)
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// errStale: the settings changed since the caller read them.
|
||||
var errStale = errors.New("the settings changed since you read them (someone else edited, or the etag is wrong): call get_settings again")
|
||||
|
||||
// argError is a tool's complaint about its arguments (HTTP 400 in the REST API).
|
||||
type argError string
|
||||
|
||||
func (e argError) Error() string { return string(e) }
|
||||
|
||||
// etag identifies a version of the settings file.
|
||||
func etag(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
func finite(f float64) *float64 {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return nil
|
||||
}
|
||||
r := math.Round(f*10) / 10
|
||||
return &r
|
||||
}
|
||||
|
||||
// orEmpty makes a nil list an empty one, so that it is [] in the JSON.
|
||||
func orEmpty[T any](l []T) []T {
|
||||
if l == nil {
|
||||
return []T{}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
var emptySchema = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
|
||||
var mcpTools = []mcpTool{
|
||||
{
|
||||
name: "get_status", scope: scopeRead, schema: emptySchema, output: StatusResponse{},
|
||||
description: "The router's state now: uplink (connected, addresses, gateway, pings), system (uptime, load, CPU, memory, disk, temperature, kernel), whether settings are unapplied, and whether an apply is running or waits for confirmation.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
snap := a.metrics.Snapshot()
|
||||
st := a.sys.Status()
|
||||
u, s := snap.Uplink, snap.System
|
||||
pings := []PingInfo{}
|
||||
for _, p := range snap.Pings {
|
||||
pings = append(pings, PingInfo{Target: p.Target, Address: p.Address, OK: p.OK, RttMs: p.RTT.Milliseconds()})
|
||||
}
|
||||
return StatusResponse{
|
||||
Time: snap.Time,
|
||||
Uplink: UplinkInfo{Device: u.Device, Type: u.Type, Connected: u.Connected, Reachable: u.Reachable,
|
||||
IPv4: orEmpty(u.IPv4), IPv6: orEmpty(u.IPv6), Gateway: u.Gateway, Since: u.Since},
|
||||
Pings: pings,
|
||||
System: SystemInfo{UptimeSeconds: int(s.Uptime.Seconds()), Load: s.Load, Kernel: s.Kernel,
|
||||
RebootForKernel: s.KernelNext, RebootForConfiguration: s.BootPending, CPUPercent: finite(s.CPU),
|
||||
MemoryUsedBytes: s.MemUsed, MemoryTotalBytes: s.MemTotal, TemperatureC: finite(s.Temperature),
|
||||
DiskUsedBytes: s.DiskUsed, DiskTotalBytes: s.DiskTotal},
|
||||
Settings: SettingsInfo{Unapplied: st.Unapplied, Applying: st.Applying,
|
||||
LastApplyResult: st.ApplyResult, ConfirmationDueInSeconds: int(st.RollbackIn.Seconds())},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_settings", scope: scopeRead, schema: emptySchema, output: SettingsResponse{},
|
||||
description: "The saved settings (settings.json: `system` and `router`) with the etag to pass to update_settings, and whether they differ from the confirmed (running) system. Secrets are not in it, only paths of files that hold them.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SettingsResponse{ETag: etag(data), Unapplied: a.sys.Status().Unapplied, Settings: v}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_pending_changes", scope: scopeRead, schema: emptySchema,
|
||||
description: "The difference between the confirmed settings and the saved ones (lines starting with - are removed, + added); empty when there is none.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
if d := a.sys.pendingChanges(); d != "" {
|
||||
return d, nil
|
||||
}
|
||||
return "No changes: the saved settings are the confirmed ones.", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_interfaces", scope: scopeRead, schema: emptySchema, output: []InterfaceInfo{},
|
||||
description: "Network interfaces: physical cards (name now, name from the settings, MAC address) and VLAN devices, and how the settings use each (WAN, a LAN).",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []InterfaceInfo{}
|
||||
for _, d := range a.devices(s, s.form()) {
|
||||
out = append(out, InterfaceInfo{Name: d.Name, Kind: d.Kind, UsedAs: d.Role, KernelName: d.Kernel, MAC: d.MAC, DeviceName: d.Custom})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema, output: []LeaseInfo{},
|
||||
description: "The DHCP clients currently holding a lease: address, MAC, hostname, LAN, when the lease ends.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []LeaseInfo{}
|
||||
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
|
||||
out = append(out, LeaseInfo{Address: l.Address, MAC: l.MAC, Hostname: l.Hostname, LAN: l.LAN, Expires: l.Expires})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_update_status", scope: scopeRead, schema: emptySchema, output: UpdateStatusResponse{},
|
||||
description: "The installed release (version, schema) and the state of the last update run.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
st := a.sys.UpdateStatus()
|
||||
return UpdateStatusResponse{Installed: a.sys.installedRelease(), Running: st.Running, LastResult: st.Result, Log: st.Log}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_apply_log", scope: scopeRead, schema: emptySchema, output: ApplyLogResponse{},
|
||||
description: "The log of the last apply (build and activation) and its result.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
st := a.sys.Status()
|
||||
return ApplyLogResponse{Applying: st.Applying, Result: st.ApplyResult, Log: st.Log}, nil
|
||||
},
|
||||
},
|
||||
logsTool,
|
||||
{
|
||||
name: "check_settings", scope: scopeRead,
|
||||
schema: emptySchema, output: CheckResponse{},
|
||||
description: "Evaluates the saved settings the way an apply would (the router's assertions and the option types) without building or activating anything. Takes about a minute; if it is still running, call again to get the result. Reports whether the result is for the current saved settings.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) { return a.checkSettings() },
|
||||
},
|
||||
{
|
||||
name: "update_settings", scope: scopeChange, output: EditResponse{},
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"etag": map[string]any{"type": "string", "description": "The etag from get_settings: the change is refused if the settings changed since."},
|
||||
"patch": map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386) on the settings: objects merge, a value replaces, null removes a key, arrays are replaced whole."},
|
||||
},
|
||||
"required": []string{"etag", "patch"},
|
||||
},
|
||||
description: "Edit the saved settings with a JSON merge patch. The settings are saved but not applied: a person applies them in the web UI. Not allowed: ssh keys, the schema version, the state version, and file paths outside the router's state directory. Run check_settings afterwards.",
|
||||
run: func(a *app, args json.RawMessage) (any, error) { return a.updateSettings(args) },
|
||||
},
|
||||
{
|
||||
name: "discard_changes", scope: scopeChange, schema: emptySchema,
|
||||
description: "Throw away the unapplied changes: the settings go back to the confirmed ones.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
if err := a.sys.Discard(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return "The settings are the confirmed ones again.", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// mergePatch applies a JSON merge patch (RFC 7386).
|
||||
func mergePatch(target, patch any) any {
|
||||
p, ok := patch.(map[string]any)
|
||||
if !ok {
|
||||
return patch
|
||||
}
|
||||
t, ok := target.(map[string]any)
|
||||
if !ok {
|
||||
t = map[string]any{}
|
||||
}
|
||||
for k, v := range p {
|
||||
if v == nil {
|
||||
delete(t, k)
|
||||
} else {
|
||||
t[k] = mergePatch(t[k], v)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// checkMCPSettings refuses what an assistant may not change.
|
||||
func (s System) checkMCPSettings(old, next Settings) error {
|
||||
for k := range next {
|
||||
if k != "version" && k != "system" && k != "router" {
|
||||
return fmt.Errorf("unknown top-level key %q (only system and router)", k)
|
||||
}
|
||||
}
|
||||
same := func(a, b any) bool { x, _ := json.Marshal(a); y, _ := json.Marshal(b); return string(x) == string(y) }
|
||||
if !same(old["version"], next["version"]) {
|
||||
return errors.New("`version` (the settings schema) cannot be changed")
|
||||
}
|
||||
oldSys, newSys := obj(Settings{"system": old["system"]}, "system"), obj(Settings{"system": next["system"]}, "system")
|
||||
for _, k := range []string{"sshKeys", "stateVersion"} {
|
||||
if !same(oldSys[k], newSys[k]) {
|
||||
return fmt.Errorf("`system.%s` cannot be changed through this interface", k)
|
||||
}
|
||||
}
|
||||
for k := range newSys {
|
||||
if k != "hostName" && k != "timeZone" && !same(oldSys[k], newSys[k]) {
|
||||
return fmt.Errorf("`system.%s` cannot be changed through this interface (only hostName and timeZone)", k)
|
||||
}
|
||||
}
|
||||
var walk func(path string, v any) error
|
||||
walk = func(path string, v any) error {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, e := range x {
|
||||
if str, ok := e.(string); ok && strings.HasSuffix(k, "File") {
|
||||
if !strings.HasPrefix(str, s.StateDir+"/") || strings.Contains(str, "..") {
|
||||
return fmt.Errorf("%s.%s: files must be under %s (the person who runs the router puts secrets there)", path, k, s.StateDir)
|
||||
}
|
||||
}
|
||||
if err := walk(path+"."+k, e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i, e := range x {
|
||||
if err := walk(fmt.Sprintf("%s[%d]", path, i), e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return walk("router", next["router"])
|
||||
}
|
||||
|
||||
func (a *app) updateSettings(args json.RawMessage) (any, error) {
|
||||
var in struct {
|
||||
Etag string `json:"etag"`
|
||||
Patch any `json:"patch"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &in); err != nil || in.Patch == nil {
|
||||
return nil, errors.New("give `etag` and `patch` (an object)")
|
||||
}
|
||||
if _, ok := in.Patch.(map[string]any); !ok {
|
||||
return nil, errors.New("`patch` must be an object")
|
||||
}
|
||||
return a.editSettings(in.Etag, func(next Settings) (Settings, error) {
|
||||
patched, _ := mergePatch(map[string]any(next), in.Patch).(map[string]any)
|
||||
return Settings(patched), nil
|
||||
})
|
||||
}
|
||||
|
||||
// editSettings saves what edit makes of the settings, if etag is the current
|
||||
// version of them (so nobody's edit is lost) and the result stays within what
|
||||
// the API may change. The settings stay unapplied.
|
||||
func (a *app) editSettings(wantEtag string, edit func(next Settings) (Settings, error)) (any, error) {
|
||||
a.settingsMu.Lock()
|
||||
defer a.settingsMu.Unlock()
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wantEtag != etag(data) {
|
||||
return nil, errStale
|
||||
}
|
||||
old, err := parseSettings(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next, err := parseSettings(data) // a copy to edit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next, err = edit(next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.sys.checkMCPSettings(old, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := next.save(a.sys.settingsPath()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, _ := os.ReadFile(a.sys.settingsPath())
|
||||
return EditResponse{Saved: true, ETag: etag(after), Changes: diffLines(pretty(data), pretty(after)),
|
||||
Next: "Run check_settings (GET /api/v1/settings/check); a person applies the changes in the web UI."}, nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rpc posts a JSON-RPC request to /mcp.
|
||||
func rpc(h http.Handler, token, method string, params any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(string(body)))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec, out
|
||||
}
|
||||
|
||||
// call runs a tool and returns its text and whether it failed.
|
||||
func call(t *testing.T, h http.Handler, token, tool string, args any) (string, bool) {
|
||||
t.Helper()
|
||||
rec, out := rpc(h, token, "tools/call", map[string]any{"name": tool, "arguments": args})
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("%s: HTTP %d", tool, rec.Code)
|
||||
}
|
||||
if out["error"] != nil {
|
||||
return out["error"].(map[string]any)["message"].(string), true
|
||||
}
|
||||
res := out["result"].(map[string]any)
|
||||
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
failed, _ := res["isError"].(bool)
|
||||
return text, failed
|
||||
}
|
||||
|
||||
func TestMCPAuth(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
if rec, _ := rpc(h, "", "ping", nil); rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatalf("no token: %d", rec.Code)
|
||||
}
|
||||
if rec, _ := rpc(h, "rtr_nonsense", "ping", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong token: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "GET", "/mcp", nil, nil); rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET: %d", rec.Code)
|
||||
}
|
||||
_, secret, err := a.tokens.Create("test", "read")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign origin: %d", rec.Code)
|
||||
}
|
||||
// A session cookie is no token.
|
||||
cookie := login(t, h)
|
||||
req = httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`))
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session cookie: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPProtocol(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
_, read, _ := a.tokens.Create("reader", "read")
|
||||
_, change, _ := a.tokens.Create("writer", "change")
|
||||
|
||||
rec, out := rpc(h, read, "initialize", map[string]any{"protocolVersion": "2025-03-26"})
|
||||
if rec.Code != 200 || out["result"].(map[string]any)["protocolVersion"] != "2025-03-26" {
|
||||
t.Fatalf("initialize: %s", rec.Body)
|
||||
}
|
||||
// A notification gets no body.
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+read)
|
||||
nrec := httptest.NewRecorder()
|
||||
h.ServeHTTP(nrec, req)
|
||||
if nrec.Code != http.StatusAccepted || nrec.Body.Len() != 0 {
|
||||
t.Fatalf("notification: %d %q", nrec.Code, nrec.Body)
|
||||
}
|
||||
if _, out := rpc(h, read, "nonsense", nil); out["error"] == nil {
|
||||
t.Fatal("unknown method accepted")
|
||||
}
|
||||
|
||||
names := func(token string) string {
|
||||
_, out := rpc(h, token, "tools/list", nil)
|
||||
var n []string
|
||||
for _, t := range out["result"].(map[string]any)["tools"].([]any) {
|
||||
n = append(n, t.(map[string]any)["name"].(string))
|
||||
}
|
||||
return strings.Join(n, " ")
|
||||
}
|
||||
if got := names(read); strings.Contains(got, "update_settings") || !strings.Contains(got, "get_settings") {
|
||||
t.Fatalf("read tools: %s", got)
|
||||
}
|
||||
if got := names(change); !strings.Contains(got, "update_settings") || !strings.Contains(got, "discard_changes") {
|
||||
t.Fatalf("change tools: %s", got)
|
||||
}
|
||||
if text, failed := call(t, h, read, "update_settings", map[string]any{}); !failed || !strings.Contains(text, "unknown tool") {
|
||||
t.Fatalf("a read token edited settings: %s", text)
|
||||
}
|
||||
|
||||
// Reading.
|
||||
text, failed := call(t, h, read, "get_settings", nil)
|
||||
if failed {
|
||||
t.Fatal(text)
|
||||
}
|
||||
var got struct {
|
||||
Etag string
|
||||
Settings map[string]any
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &got); err != nil || got.Etag == "" || got.Settings["router"] == nil {
|
||||
t.Fatalf("get_settings: %v %s", err, text)
|
||||
}
|
||||
if _, failed := call(t, h, read, "get_status", nil); failed {
|
||||
t.Fatal("get_status failed")
|
||||
}
|
||||
if text, failed := call(t, h, read, "list_interfaces", nil); failed || !strings.Contains(text, "WAN") {
|
||||
t.Fatalf("list_interfaces: %s", text)
|
||||
}
|
||||
if _, failed := call(t, h, read, "list_dhcp_leases", nil); failed {
|
||||
t.Fatal("list_dhcp_leases failed")
|
||||
}
|
||||
|
||||
// Editing: a patch merges, a stale etag is refused, nothing is applied.
|
||||
patch := map[string]any{"etag": got.Etag, "patch": map[string]any{"system": map[string]any{"hostName": "assistant-was-here"}}}
|
||||
text, failed = call(t, h, change, "update_settings", patch)
|
||||
if failed || !strings.Contains(text, "assistant-was-here") || !strings.Contains(text, `"applied": false`) {
|
||||
t.Fatalf("update_settings: %s", text)
|
||||
}
|
||||
if data, _ := os.ReadFile(sys.settingsPath()); !strings.Contains(string(data), "assistant-was-here") {
|
||||
t.Fatal("not saved")
|
||||
}
|
||||
if text, failed := call(t, h, change, "update_settings", patch); !failed || !strings.Contains(text, "etag") {
|
||||
t.Fatalf("a stale etag was accepted: %s", text)
|
||||
}
|
||||
// A change shows up for review (the confirmed copy is the sample).
|
||||
os.WriteFile(sys.confirmedPath(), []byte(sample), 0o600)
|
||||
if text, _ := call(t, h, read, "get_pending_changes", nil); !strings.Contains(text, "+") || !strings.Contains(text, "assistant-was-here") {
|
||||
t.Fatalf("pending: %s", text)
|
||||
}
|
||||
if text, failed := call(t, h, change, "discard_changes", nil); failed {
|
||||
t.Fatal(text)
|
||||
}
|
||||
if data, _ := os.ReadFile(sys.settingsPath()); strings.Contains(string(data), "assistant-was-here") {
|
||||
t.Fatal("discard did not restore the settings")
|
||||
}
|
||||
|
||||
// Every call was logged, with the token's name.
|
||||
audit := sys.RecentAudit(50)
|
||||
if len(audit) < 5 || audit[0].Tool != "discard_changes" || audit[0].Token != "writer" {
|
||||
t.Fatalf("audit: %+v", audit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPEditLimits(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
_, change, _ := a.tokens.Create("writer", "change")
|
||||
etagNow := func() string {
|
||||
data, _ := os.ReadFile(sys.settingsPath())
|
||||
return etag(data)
|
||||
}
|
||||
for name, patch := range map[string]map[string]any{
|
||||
"ssh keys": {"system": map[string]any{"sshKeys": []string{"ssh-ed25519 AAAA attacker"}}},
|
||||
"state version": {"system": map[string]any{"stateVersion": "20.09"}},
|
||||
"schema": {"version": 99},
|
||||
"top level": {"users": map[string]any{"x": 1}},
|
||||
"secret path": {"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": "/etc/shadow"}}}}},
|
||||
"path escape": {"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": sys.StateDir + "/../etc/shadow"}}}}},
|
||||
"nested file": {"router": map[string]any{"wireguard": map[string]any{"wg0": map[string]any{"privateKeyFile": "/root/.ssh/id_rsa"}}}},
|
||||
} {
|
||||
before, _ := os.ReadFile(sys.settingsPath())
|
||||
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": patch}); !failed {
|
||||
t.Errorf("%s accepted: %s", name, text)
|
||||
}
|
||||
if after, _ := os.ReadFile(sys.settingsPath()); string(after) != string(before) {
|
||||
t.Errorf("%s changed the file", name)
|
||||
}
|
||||
}
|
||||
// Files in the state directory, and null to remove a key, are fine.
|
||||
ok := map[string]any{"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": sys.StateDir + "/pppoe-password"}}}}}
|
||||
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": ok}); failed {
|
||||
t.Fatalf("a file in the state directory refused: %s", text)
|
||||
}
|
||||
rm := map[string]any{"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": nil}}}}
|
||||
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": rm}); failed || strings.Contains(text, "pppoe-password\"") && !strings.Contains(text, "-") {
|
||||
t.Fatalf("null did not remove: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePatchAndDiff(t *testing.T) {
|
||||
got, _ := json.Marshal(mergePatch(
|
||||
map[string]any{"a": map[string]any{"b": 1, "c": 2}, "d": []any{1, 2}, "e": "x"},
|
||||
map[string]any{"a": map[string]any{"b": nil, "z": 3}, "d": []any{9}}))
|
||||
if string(got) != `{"a":{"c":2,"z":3},"d":[9],"e":"x"}` {
|
||||
t.Fatalf("merge: %s", got)
|
||||
}
|
||||
if diffLines("a\nb", "a\nb") != "" {
|
||||
t.Fatal("equal texts differ")
|
||||
}
|
||||
d := diffLines("1\n2\n3\n4\n5\n6\n7\n8\n9", "1\n2\n3\nfour\n5\n6\n7\n8\n9")
|
||||
if !strings.Contains(d, "- 4\n+ four\n") || !strings.Contains(d, "…") {
|
||||
t.Fatalf("diff:\n%s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensPage(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
token := csrfFrom(t, do(h, "GET", "/tokens", nil, cookie).Body.String())
|
||||
rec := do(h, "POST", "/tokens", map[string][]string{"csrf": {token}, "name": {"laptop"}, "scope": {"read"}}, cookie)
|
||||
body := rec.Body.String()
|
||||
i := strings.Index(body, "rtr_")
|
||||
if rec.Code != 200 || i < 0 {
|
||||
t.Fatalf("create: %d\n%s", rec.Code, body)
|
||||
}
|
||||
secret := body[i : i+4+43]
|
||||
if _, ok := a.tokens.Lookup(secret); !ok {
|
||||
t.Fatal("the shown token does not work")
|
||||
}
|
||||
// The secret is not stored or shown again.
|
||||
if data, _ := os.ReadFile(a.sys.tokensPath()); strings.Contains(string(data), secret) {
|
||||
t.Fatal("the secret is stored")
|
||||
}
|
||||
if strings.Contains(do(h, "GET", "/tokens", nil, cookie).Body.String(), secret) {
|
||||
t.Fatal("the secret is shown again")
|
||||
}
|
||||
toks := a.tokens.List()
|
||||
do(h, "POST", "/tokens/revoke", map[string][]string{"csrf": {token}, "id": {toks[0].ID}}, cookie)
|
||||
if _, ok := a.tokens.Lookup(secret); ok {
|
||||
t.Fatal("a revoked token works")
|
||||
}
|
||||
if rec := do(h, "GET", "/changes", nil, cookie); rec.Code != 200 {
|
||||
t.Fatalf("changes page: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"math"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metric history: every series keeps a ring of averages per resolution. A
|
||||
// slot's index follows from its time, so writing needs no head pointer and
|
||||
// gaps (the UI was not running) simply stay NaN.
|
||||
//
|
||||
// 5 s for 1 h, 1 min for 24 h, 15 min for 7 days, 1 h for 30 days.
|
||||
var tiers = []struct {
|
||||
Step int64 // seconds
|
||||
Slots int
|
||||
}{
|
||||
{5, 720},
|
||||
{60, 1440},
|
||||
{900, 672},
|
||||
{3600, 720},
|
||||
}
|
||||
|
||||
// Ranges the dashboard offers: the tier each one reads and how many of its
|
||||
// newest slots it shows (0: all of them). The first one is the default.
|
||||
var ranges = []timeRange{
|
||||
{"15m", 0, 180},
|
||||
{"1h", 0, 0},
|
||||
{"24h", 1, 0},
|
||||
{"7d", 2, 0},
|
||||
{"30d", 3, 0},
|
||||
}
|
||||
|
||||
type timeRange struct {
|
||||
Name string
|
||||
Tier int
|
||||
Slots int
|
||||
}
|
||||
|
||||
// slots is how many slots of its tier the range shows.
|
||||
func (r timeRange) slots() int {
|
||||
if r.Slots > 0 {
|
||||
return r.Slots
|
||||
}
|
||||
return tiers[r.Tier].Slots
|
||||
}
|
||||
|
||||
func findRange(name string) timeRange {
|
||||
for _, r := range ranges {
|
||||
if r.Name == name {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return ranges[0]
|
||||
}
|
||||
|
||||
type Tier struct {
|
||||
Step int64
|
||||
Values []float64 // NaN: no data
|
||||
Last int64 // start of the newest written bucket (unix seconds)
|
||||
// The bucket being filled.
|
||||
Bucket int64
|
||||
Sum float64
|
||||
Count int
|
||||
}
|
||||
|
||||
func newTier(step int64, slots int) *Tier {
|
||||
t := &Tier{Step: step, Values: make([]float64, slots)}
|
||||
for i := range t.Values {
|
||||
t.Values[i] = math.NaN()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Tier) slot(b int64) int { return int((b / t.Step) % int64(len(t.Values))) }
|
||||
|
||||
func (t *Tier) add(at int64, v float64) {
|
||||
b := at - at%t.Step
|
||||
if b != t.Bucket {
|
||||
t.flush()
|
||||
t.Bucket, t.Sum, t.Count = b, 0, 0
|
||||
}
|
||||
t.Sum += v
|
||||
t.Count++
|
||||
}
|
||||
|
||||
// flush writes the bucket being filled into the ring.
|
||||
func (t *Tier) flush() {
|
||||
if t.Count == 0 || t.Bucket < t.Last {
|
||||
return
|
||||
}
|
||||
n := int64(len(t.Values))
|
||||
// Clear the slots skipped since the last write (at most one full turn).
|
||||
for b, i := t.Last+t.Step, int64(0); b < t.Bucket && i < n; b, i = b+t.Step, i+1 {
|
||||
t.Values[t.slot(b)] = math.NaN()
|
||||
}
|
||||
t.Values[t.slot(t.Bucket)] = t.Sum / float64(t.Count)
|
||||
t.Last = t.Bucket
|
||||
t.Count = 0
|
||||
}
|
||||
|
||||
// read returns the buckets of the last full turn of the ring up to now, the
|
||||
// one being filled included, oldest first, and the first bucket's start.
|
||||
func (t *Tier) read(now int64) (int64, []float64) {
|
||||
n := int64(len(t.Values))
|
||||
end := now - now%t.Step
|
||||
start := end - (n-1)*t.Step
|
||||
out := make([]float64, n)
|
||||
for i := range out {
|
||||
b := start + int64(i)*t.Step
|
||||
switch {
|
||||
case b == t.Bucket && t.Count > 0:
|
||||
out[i] = t.Sum / float64(t.Count)
|
||||
case b > t.Last || b <= t.Last-n*t.Step:
|
||||
out[i] = math.NaN()
|
||||
default:
|
||||
out[i] = t.Values[t.slot(b)]
|
||||
}
|
||||
}
|
||||
return start, out
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
Tiers []*Tier
|
||||
}
|
||||
|
||||
func newSeries() *Series {
|
||||
s := &Series{}
|
||||
for _, t := range tiers {
|
||||
s.Tiers = append(s.Tiers, newTier(t.Step, t.Slots))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Event is a change of the uplink's state.
|
||||
type Event struct {
|
||||
Time time.Time
|
||||
Connected bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Store is the history that survives restarts.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
Series map[string]*Series
|
||||
// The uplink's state as last seen, and its recent changes.
|
||||
Connected bool
|
||||
Since time.Time
|
||||
Events []Event
|
||||
}
|
||||
|
||||
const maxEvents = 50
|
||||
|
||||
func newStore() *Store { return &Store{Series: map[string]*Series{}} }
|
||||
|
||||
func (s *Store) Add(name string, at time.Time, v float64) {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ser := s.Series[name]
|
||||
if ser == nil {
|
||||
ser = newSeries()
|
||||
s.Series[name] = ser
|
||||
}
|
||||
for _, t := range ser.Tiers {
|
||||
t.add(at.Unix(), v)
|
||||
}
|
||||
}
|
||||
|
||||
// Read returns a series at the resolution of tier: the start, step and values.
|
||||
func (s *Store) Read(name string, tier int, now time.Time) (time.Time, time.Duration, []float64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t := tiers[tier]
|
||||
ser := s.Series[name]
|
||||
if ser == nil {
|
||||
ser = newSeries() // all NaN
|
||||
}
|
||||
start, vals := ser.Tiers[tier].read(now.Unix())
|
||||
return time.Unix(start, 0), time.Duration(t.Step) * time.Second, vals
|
||||
}
|
||||
|
||||
func (s *Store) Names() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
names := make([]string, 0, len(s.Series))
|
||||
for n := range s.Series {
|
||||
names = append(names, n)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// SetConnected records the uplink's state; a change becomes an event.
|
||||
func (s *Store) SetConnected(connected bool, at time.Time, detail string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if connected == s.Connected && !s.Since.IsZero() {
|
||||
return
|
||||
}
|
||||
s.Connected, s.Since = connected, at
|
||||
s.Events = append(s.Events, Event{Time: at, Connected: connected, Detail: detail})
|
||||
if len(s.Events) > maxEvents {
|
||||
s.Events = s.Events[len(s.Events)-maxEvents:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Uplink() (bool, time.Time, []Event) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.Connected, s.Since, slices.Clone(s.Events)
|
||||
}
|
||||
|
||||
// The on-disk format; bump storeVersion when it changes incompatibly (an old
|
||||
// file is then ignored).
|
||||
const storeVersion = 1
|
||||
|
||||
type storeFile struct {
|
||||
Version int
|
||||
Series map[string]*Series
|
||||
Connected bool
|
||||
Since time.Time
|
||||
Events []Event
|
||||
}
|
||||
|
||||
func (s *Store) Save(path string) error {
|
||||
s.mu.Lock()
|
||||
f := storeFile{storeVersion, s.Series, s.Connected, s.Since, s.Events}
|
||||
var buf bytes.Buffer
|
||||
err := gob.NewEncoder(&buf).Encode(f)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(path, buf.Bytes(), 0o600)
|
||||
}
|
||||
|
||||
func loadStore(path string) (*Store, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return newStore(), err
|
||||
}
|
||||
var f storeFile
|
||||
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&f); err != nil || f.Version != storeVersion {
|
||||
return newStore(), err
|
||||
}
|
||||
s := &Store{Series: map[string]*Series{}, Connected: f.Connected, Since: f.Since, Events: f.Events}
|
||||
for name, ser := range f.Series {
|
||||
// Keep only series whose shape matches the current tiers.
|
||||
ok := len(ser.Tiers) == len(tiers)
|
||||
for i := 0; ok && i < len(tiers); i++ {
|
||||
ok = ser.Tiers[i].Step == tiers[i].Step && len(ser.Tiers[i].Values) == tiers[i].Slots
|
||||
}
|
||||
if ok {
|
||||
s.Series[name] = ser
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NATRule is a row of the address translation table (router.firewall.nat).
|
||||
type NATRule struct {
|
||||
Type string // dnat, snat or masquerade
|
||||
From string // zone, "wan", or empty (any) for snat and masquerade
|
||||
To string // dnat: the host's zone; snat and masquerade: the zone or wan it leaves through
|
||||
Protocol string
|
||||
Ports string // destination ports to match
|
||||
SourcePorts string
|
||||
Sources string // IPv4 addresses or CIDRs to match
|
||||
Destination string // IPv4 address or CIDR the traffic is addressed to; empty: the router itself
|
||||
ToAddress string // dnat: the host; snat: the source address to use
|
||||
ToPort string // dnat: the host's port when it differs
|
||||
Hairpin bool // dnat from wan: LANs reach it through the public address too
|
||||
Allow bool // form only: also add the filter rule that lets a forward through
|
||||
Remove bool
|
||||
}
|
||||
|
||||
var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
|
||||
|
||||
func boolOf(r map[string]any, k string) bool {
|
||||
b, _ := r[k].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (s Settings) natRules() []NATRule {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
fw, _ := router["firewall"].(map[string]any)
|
||||
rows, _ := fw["nat"].([]any)
|
||||
var out []NATRule
|
||||
for _, v := range rows {
|
||||
r, _ := v.(map[string]any)
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
proto := str(r, "protocol")
|
||||
if proto == "" {
|
||||
proto = "any"
|
||||
}
|
||||
out = append(out, NATRule{Type: str(r, "type"), From: str(r, "from"), To: str(r, "to"), Protocol: proto,
|
||||
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]), Sources: strings.Join(stringList(r, "sources"), ", "),
|
||||
Destination: str(r, "destination"), ToAddress: str(r, "toAddress"), ToPort: str(r, "toPort"), Hairpin: boolOf(r, "hairpin")})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zoneV4Nets are the IPv4 networks of a zone's LANs; ok is false when a tunnel
|
||||
// is in the zone too (its networks are the peers' routes, not checked here).
|
||||
func (s Settings) zoneV4Nets(zone string) (nets []netip.Prefix, ok bool) {
|
||||
lans := map[string]netip.Prefix{}
|
||||
for _, l := range s.form().LANs {
|
||||
a, aok := ipv4(l.Address)
|
||||
if !aok {
|
||||
continue
|
||||
}
|
||||
bits := 24
|
||||
if n, nok, _ := optionalInt(l.Prefix, 8, 30); nok {
|
||||
bits = n
|
||||
}
|
||||
lans[l.Name] = netip.PrefixFrom(a, bits).Masked()
|
||||
}
|
||||
for _, m := range s.zoneMembers() {
|
||||
if m.Zone != zone {
|
||||
continue
|
||||
}
|
||||
if m.Kind != "lan" {
|
||||
return nil, false
|
||||
}
|
||||
if p, found := lans[m.Name]; found {
|
||||
nets = append(nets, p)
|
||||
}
|
||||
}
|
||||
return nets, len(nets) > 0
|
||||
}
|
||||
|
||||
func validV4Net(v string) bool {
|
||||
if p, err := netip.ParsePrefix(v); err == nil {
|
||||
return p.Addr().Is4()
|
||||
}
|
||||
a, err := netip.ParseAddr(v)
|
||||
return err == nil && a.Is4()
|
||||
}
|
||||
|
||||
// natSettings checks the rows and returns them as settings, together with the
|
||||
// filter rules the rows ask for ("also allow").
|
||||
func (s Settings) natSettings(rows []NATRule, zones []string, errs FormErrors) (nat []any, allows []any) {
|
||||
seen := map[string]bool{}
|
||||
for i, r := range rows {
|
||||
field := func(n string) string { return fmt.Sprintf("nat[%d].%s", i, n) }
|
||||
r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To)
|
||||
r.Ports, r.SourcePorts = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.SourcePorts), ", ")
|
||||
r.Sources = strings.Join(splitList(r.Sources), ", ")
|
||||
r.Destination, r.ToAddress, r.ToPort = strings.TrimSpace(r.Destination), strings.TrimSpace(r.ToAddress), strings.TrimSpace(r.ToPort)
|
||||
if r.Protocol == "" {
|
||||
r.Protocol = "any"
|
||||
}
|
||||
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" &&
|
||||
r.Destination == "" && r.ToAddress == "" && r.ToPort == "") {
|
||||
continue
|
||||
}
|
||||
dnat, snat := r.Type == "dnat", r.Type == "snat"
|
||||
if !dnat && !snat && r.Type != "masquerade" {
|
||||
errs[field("type")] = "port forward, source NAT or masquerade"
|
||||
}
|
||||
inZones := func(z string) bool { return slices.Contains(zones, z) || z == "wan" }
|
||||
switch {
|
||||
case dnat && r.From == "":
|
||||
errs[field("from")] = "where the traffic comes from"
|
||||
case r.From != "" && !inZones(r.From):
|
||||
errs[field("from")] = "choose a zone"
|
||||
}
|
||||
switch {
|
||||
case dnat && !slices.Contains(zones, r.To):
|
||||
errs[field("to")] = "the zone of the host"
|
||||
case !dnat && !inZones(r.To):
|
||||
errs[field("to")] = "choose a zone"
|
||||
case !dnat && r.From == r.To:
|
||||
errs[field("to")] = "source and destination are the same zone"
|
||||
}
|
||||
if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) {
|
||||
errs[field("protocol")] = "any, tcp, udp or icmp"
|
||||
}
|
||||
ports, msg := portList(r.Ports)
|
||||
if msg != "" {
|
||||
errs[field("ports")] = msg
|
||||
}
|
||||
sports, msg := portList(r.SourcePorts)
|
||||
if msg != "" {
|
||||
errs[field("sourceports")] = msg
|
||||
}
|
||||
if (len(ports) > 0 || len(sports) > 0) && r.Protocol != "tcp" && r.Protocol != "udp" {
|
||||
errs[field("ports")] = "ports need protocol tcp or udp"
|
||||
}
|
||||
var sources []any
|
||||
for _, a := range splitList(r.Sources) {
|
||||
if !validV4Net(a) {
|
||||
errs[field("sources")] = a + " is not an IPv4 address or CIDR"
|
||||
break
|
||||
}
|
||||
sources = append(sources, a)
|
||||
}
|
||||
if r.Destination != "" && !validV4Net(r.Destination) {
|
||||
errs[field("destination")] = "an IPv4 address or CIDR"
|
||||
}
|
||||
switch {
|
||||
case dnat || snat:
|
||||
if a, ok := ipv4(r.ToAddress); !ok {
|
||||
errs[field("toaddress")] = "an IPv4 address"
|
||||
} else if dnat {
|
||||
if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool { return n.Contains(a) }) {
|
||||
errs[field("toaddress")] = "not inside a network of zone " + r.To
|
||||
}
|
||||
}
|
||||
case r.ToAddress != "":
|
||||
errs[field("toaddress")] = "masquerade uses the outgoing interface's address"
|
||||
}
|
||||
toPort := 0
|
||||
if r.ToPort != "" {
|
||||
n, err := strconv.Atoi(r.ToPort)
|
||||
switch {
|
||||
case !dnat:
|
||||
errs[field("toport")] = "only for port forwards"
|
||||
case err != nil || n < 1 || n > 65535:
|
||||
errs[field("toport")] = "a port number"
|
||||
case len(ports) != 1 || strings.Contains(r.Ports, "-"):
|
||||
errs[field("toport")] = "needs exactly one forwarded port"
|
||||
}
|
||||
toPort = n
|
||||
}
|
||||
if dnat {
|
||||
if r.Protocol != "tcp" && r.Protocol != "udp" {
|
||||
errs[field("protocol")] = "a port forward needs tcp or udp"
|
||||
} else if len(ports) == 0 {
|
||||
errs[field("ports")] = "name the ports to forward"
|
||||
}
|
||||
}
|
||||
if r.Hairpin && (!dnat || r.From != "wan") {
|
||||
errs[field("hairpin")] = "only for port forwards from wan"
|
||||
}
|
||||
key := fmt.Sprint(r.Type, r.From, r.To, r.Protocol, r.Ports, r.SourcePorts, r.Sources, r.Destination, r.ToAddress, r.ToPort)
|
||||
if seen[key] {
|
||||
errs[field("type")] = "the same rule twice"
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
row := map[string]any{"type": r.Type, "to": r.To}
|
||||
if r.From != "" {
|
||||
row["from"] = r.From
|
||||
}
|
||||
if r.Protocol != "any" {
|
||||
row["protocol"] = r.Protocol
|
||||
}
|
||||
if len(ports) > 0 {
|
||||
row["ports"] = ports
|
||||
}
|
||||
if len(sports) > 0 {
|
||||
row["sourcePorts"] = sports
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
row["sources"] = sources
|
||||
}
|
||||
if r.Destination != "" {
|
||||
row["destination"] = r.Destination
|
||||
}
|
||||
if r.ToAddress != "" && (dnat || snat) {
|
||||
row["toAddress"] = r.ToAddress
|
||||
}
|
||||
if toPort > 0 {
|
||||
row["toPort"] = toPort
|
||||
}
|
||||
if r.Hairpin {
|
||||
row["hairpin"] = true
|
||||
}
|
||||
nat = append(nat, row)
|
||||
if r.Allow && dnat {
|
||||
// What lets the forward through: translated connections to the host.
|
||||
allow := map[string]any{"from": r.From, "to": r.To, "destination": r.ToAddress, "protocol": r.Protocol, "natOnly": true}
|
||||
switch {
|
||||
case toPort > 0:
|
||||
allow["ports"] = []any{toPort}
|
||||
case len(ports) > 0:
|
||||
allow["ports"] = ports
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
allow["sources"] = sources
|
||||
}
|
||||
allows = append(allows, allow)
|
||||
}
|
||||
}
|
||||
return nat, allows
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
lib,
|
||||
buildGo127Module,
|
||||
fetchurl,
|
||||
}:
|
||||
let
|
||||
# The API reference page is Redoc (MIT), served from the web UI's own address.
|
||||
redoc = fetchurl {
|
||||
url = "https://cdn.jsdelivr.net/npm/redoc@2.5.2/bundles/redoc.standalone.js";
|
||||
hash = "sha256-a+ta6iO3HQkSLuNd6PMm44rsBQ53Tzv7ofNOXDZgiYY=";
|
||||
};
|
||||
in
|
||||
buildGo127Module {
|
||||
pname = "router-webui";
|
||||
version = "0.1.0";
|
||||
src = lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
./go.mod
|
||||
(lib.fileset.fileFilter (f: f.hasExt "go") ./.)
|
||||
./providers.json
|
||||
./templates
|
||||
./static
|
||||
];
|
||||
};
|
||||
# static/redoc.standalone.js is not in the source tree (git ignores it); it
|
||||
# is embedded in the binary with the other static files.
|
||||
preBuild = ''
|
||||
cp ${redoc} static/redoc.standalone.js
|
||||
'';
|
||||
# Standard library only.
|
||||
vendorHash = null;
|
||||
meta = {
|
||||
description = "Web UI for a nixos-router configured by settings.json";
|
||||
mainProgram = "router-webui";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"ddns": [
|
||||
"1984",
|
||||
"changeip",
|
||||
"cloudflare",
|
||||
"cloudns",
|
||||
"ddns.fm",
|
||||
"digitalocean",
|
||||
"dinahosting",
|
||||
"directnic",
|
||||
"dnsexit2",
|
||||
"dnsmadeeasy",
|
||||
"domeneshop",
|
||||
"dondominio",
|
||||
"dslreports1",
|
||||
"duckdns",
|
||||
"dyndns1",
|
||||
"dyndns2",
|
||||
"easydns",
|
||||
"enom",
|
||||
"freedns",
|
||||
"freemyip",
|
||||
"gandi",
|
||||
"godaddy",
|
||||
"he.net",
|
||||
"hetzner",
|
||||
"infomaniak",
|
||||
"inwx",
|
||||
"keysystems",
|
||||
"mythicdyn",
|
||||
"namecheap",
|
||||
"nfsn",
|
||||
"njalla",
|
||||
"noip",
|
||||
"ovh",
|
||||
"porkbun",
|
||||
"regfishde",
|
||||
"sitelutions",
|
||||
"yandex",
|
||||
"zoneedit1"
|
||||
],
|
||||
"acme": [
|
||||
"abion",
|
||||
"acmedns",
|
||||
"active24",
|
||||
"alidns",
|
||||
"aliesa",
|
||||
"allinkl",
|
||||
"alwaysdata",
|
||||
"anexia",
|
||||
"artfiles",
|
||||
"arvancloud",
|
||||
"auroradns",
|
||||
"autodns",
|
||||
"axelname",
|
||||
"azion",
|
||||
"azuredns",
|
||||
"baiducloud",
|
||||
"beget",
|
||||
"binarylane",
|
||||
"bindman",
|
||||
"bluecat",
|
||||
"bluecatv2",
|
||||
"bookmyname",
|
||||
"bunny",
|
||||
"checkdomain",
|
||||
"civo",
|
||||
"clouddns",
|
||||
"cloudflare",
|
||||
"cloudns",
|
||||
"cloudru",
|
||||
"com35",
|
||||
"connbyte",
|
||||
"conoha",
|
||||
"conohav3",
|
||||
"constellix",
|
||||
"corenetworks",
|
||||
"cpanel",
|
||||
"curanet",
|
||||
"czechia",
|
||||
"dandomain",
|
||||
"ddnss",
|
||||
"derak",
|
||||
"desec",
|
||||
"designate",
|
||||
"digitalocean",
|
||||
"dinahosting",
|
||||
"directadmin",
|
||||
"dns51",
|
||||
"dnscale",
|
||||
"dnsexit",
|
||||
"dnshomede",
|
||||
"dnsimple",
|
||||
"dnsla",
|
||||
"dnsmadeeasy",
|
||||
"dnsmint",
|
||||
"dnsservices",
|
||||
"dnsupdate",
|
||||
"dode",
|
||||
"domeneshop",
|
||||
"dreamhost",
|
||||
"duckdns",
|
||||
"dyn",
|
||||
"dynadot",
|
||||
"dyndnsfree",
|
||||
"dynu",
|
||||
"easydns",
|
||||
"edgecenter",
|
||||
"edgedns",
|
||||
"edgeone",
|
||||
"efficientip",
|
||||
"epik",
|
||||
"eurodns",
|
||||
"euserv",
|
||||
"excedo",
|
||||
"exoscale",
|
||||
"f5xc",
|
||||
"feno",
|
||||
"fornex",
|
||||
"freemyip",
|
||||
"gandi",
|
||||
"gandiv5",
|
||||
"gcloud",
|
||||
"gcore",
|
||||
"gehirn",
|
||||
"gigahostno",
|
||||
"glesys",
|
||||
"gname",
|
||||
"godaddy",
|
||||
"gravity",
|
||||
"hetzner",
|
||||
"hostingde",
|
||||
"hostinger",
|
||||
"hostingnl",
|
||||
"hosttech",
|
||||
"hostup",
|
||||
"httpnet",
|
||||
"httpreq",
|
||||
"huaweicloud",
|
||||
"hurricane",
|
||||
"hyperone",
|
||||
"ibmcloud",
|
||||
"iijdpf",
|
||||
"infoblox",
|
||||
"infomaniak",
|
||||
"internetbs",
|
||||
"inwx",
|
||||
"ionos",
|
||||
"ionoscloud",
|
||||
"ipv64",
|
||||
"ispconfig",
|
||||
"ispconfigddns",
|
||||
"jdcloud",
|
||||
"joker",
|
||||
"katapult",
|
||||
"keyhelp",
|
||||
"leaseweb",
|
||||
"liara",
|
||||
"lightsail",
|
||||
"limacity",
|
||||
"linode",
|
||||
"liquidweb",
|
||||
"loopia",
|
||||
"luadns",
|
||||
"mailinabox",
|
||||
"manageengine",
|
||||
"metaname",
|
||||
"metaregistrar",
|
||||
"mijnhost",
|
||||
"mittwald",
|
||||
"myaddr",
|
||||
"mydnsjp",
|
||||
"myra",
|
||||
"mythicbeasts",
|
||||
"namecheap",
|
||||
"namedotcom",
|
||||
"namesilo",
|
||||
"namesurfer",
|
||||
"nearlyfreespeech",
|
||||
"nederhost",
|
||||
"neodigit",
|
||||
"netcup",
|
||||
"netlify",
|
||||
"netnod",
|
||||
"nexdns",
|
||||
"ngenix",
|
||||
"nicmanager",
|
||||
"nicru",
|
||||
"nifcloud",
|
||||
"njalla",
|
||||
"nodion",
|
||||
"ns1",
|
||||
"octenium",
|
||||
"omglol",
|
||||
"onecloudru",
|
||||
"onlinenet",
|
||||
"openprovider",
|
||||
"opusdns",
|
||||
"oraclecloud",
|
||||
"otc",
|
||||
"ovh",
|
||||
"pdns",
|
||||
"plesk",
|
||||
"pointdns",
|
||||
"porkbun",
|
||||
"poweradmin",
|
||||
"rackspace",
|
||||
"rage4",
|
||||
"rainyun",
|
||||
"rcodezero",
|
||||
"regfish",
|
||||
"regru",
|
||||
"rimuhosting",
|
||||
"route53",
|
||||
"safedns",
|
||||
"sakuracloud",
|
||||
"scaleway",
|
||||
"scannet",
|
||||
"selectel",
|
||||
"selectelv2",
|
||||
"selfhostde",
|
||||
"servercow",
|
||||
"shellrent",
|
||||
"simply",
|
||||
"sonic",
|
||||
"spaceship",
|
||||
"stackpath",
|
||||
"syse",
|
||||
"technitium",
|
||||
"tele3",
|
||||
"tencentcloud",
|
||||
"timewebcloud",
|
||||
"todaynic",
|
||||
"transip",
|
||||
"ucloud",
|
||||
"ultradns",
|
||||
"uniteddomains",
|
||||
"variomedia",
|
||||
"veesp",
|
||||
"vegadns",
|
||||
"vercel",
|
||||
"versio",
|
||||
"vinyldns",
|
||||
"virtualname",
|
||||
"vkcloud",
|
||||
"volcengine",
|
||||
"vscale",
|
||||
"vultr",
|
||||
"wannafind",
|
||||
"webglobe",
|
||||
"webnamesca",
|
||||
"webnamesru",
|
||||
"websupport",
|
||||
"wedos",
|
||||
"westcn",
|
||||
"xinnet",
|
||||
"yandex",
|
||||
"yandex360",
|
||||
"yandexcloud",
|
||||
"zilore",
|
||||
"zoneedit",
|
||||
"zoneee",
|
||||
"zonomi"
|
||||
],
|
||||
"acmeToken": {
|
||||
"cloudflare": "CF_DNS_API_TOKEN_FILE",
|
||||
"duckdns": "DUCKDNS_TOKEN_FILE",
|
||||
"hetzner": "HETZNER_API_KEY_FILE",
|
||||
"digitalocean": "DO_AUTH_TOKEN_FILE",
|
||||
"desec": "DESEC_TOKEN_FILE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JSON schemas of the settings, made from the router options' documentation
|
||||
// (the file `-options`, from nixosOptionsDoc): the types there are described in
|
||||
// words ("null or integer between 1 and 4094 (both inclusive)"), which are read
|
||||
// back into JSON Schema. What cannot be read becomes "anything" ({}), so a type
|
||||
// this does not know never refuses a valid setting. They describe the area
|
||||
// paths of the REST API and check what is written to them.
|
||||
|
||||
type optionEntry struct {
|
||||
Loc []string `json:"loc"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Default json.RawMessage `json:"default"`
|
||||
Example json.RawMessage `json:"example"`
|
||||
}
|
||||
|
||||
// literal returns the Nix text of a default or example (a literalExpression).
|
||||
func literal(raw json.RawMessage) string {
|
||||
var l struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(raw, &l) == nil {
|
||||
return l.Text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// optNode is the tree of the options: containers (router.firewall), options
|
||||
// and the markers <name> (a map's keys) and * (a list's elements) below them.
|
||||
type optNode struct {
|
||||
entry *optionEntry
|
||||
kids map[string]*optNode
|
||||
}
|
||||
|
||||
func newOptNode() *optNode { return &optNode{kids: map[string]*optNode{}} }
|
||||
|
||||
func buildOptTree(entries map[string]optionEntry) *optNode {
|
||||
root := newOptNode()
|
||||
for _, e := range entries {
|
||||
e := e
|
||||
if len(e.Loc) == 0 || e.Loc[0] != "router" {
|
||||
continue
|
||||
}
|
||||
n := root
|
||||
for _, seg := range e.Loc {
|
||||
k := n.kids[seg]
|
||||
if k == nil {
|
||||
k = newOptNode()
|
||||
n.kids[seg] = k
|
||||
}
|
||||
n = k
|
||||
}
|
||||
n.entry = &e
|
||||
}
|
||||
return root.kids["router"]
|
||||
}
|
||||
|
||||
func loadOptions(path string) (map[string]optionEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries map[string]optionEntry
|
||||
return entries, json.Unmarshal(data, &entries)
|
||||
}
|
||||
|
||||
func (n *optNode) objectSchema() map[string]any {
|
||||
props := map[string]any{}
|
||||
for k, c := range n.kids {
|
||||
if k != "<name>" && k != "*" {
|
||||
props[k] = c.schema()
|
||||
}
|
||||
}
|
||||
return map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
}
|
||||
|
||||
// element is the node whose children are the fields of a map's or list's
|
||||
// submodule (or the node itself for a plain submodule option).
|
||||
func (n *optNode) element() *optNode {
|
||||
if c := n.kids["<name>"]; c != nil {
|
||||
return c
|
||||
}
|
||||
if c := n.kids["*"]; c != nil {
|
||||
return c
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *optNode) schema() map[string]any {
|
||||
if n.entry == nil {
|
||||
return n.objectSchema()
|
||||
}
|
||||
s := parseOptType(n.entry.Type, func() map[string]any { return n.element().objectSchema() })
|
||||
if d := strings.TrimSpace(n.entry.Description); d != "" {
|
||||
s["description"] = d
|
||||
}
|
||||
if d := literal(n.entry.Default); d != "" {
|
||||
s["x-nix-default"] = d
|
||||
}
|
||||
if _, ok := s["x-nix-type"]; !ok {
|
||||
s["x-nix-type"] = n.entry.Type
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
intRangeRe = regexp.MustCompile(`^(?:\d+ bit unsigned integer; )?(?:integer )?between (-?\d+) and (-?\d+) \(both inclusive\)$`)
|
||||
patternRe = regexp.MustCompile(`^string matching the pattern (.+)$`)
|
||||
)
|
||||
|
||||
// parseOptType reads a type description; sub makes the schema of "(submodule)".
|
||||
func parseOptType(t string, sub func() map[string]any) map[string]any {
|
||||
t = strings.TrimSpace(t)
|
||||
switch {
|
||||
case strings.HasPrefix(t, "null or "):
|
||||
return map[string]any{"anyOf": []any{parseOptType(t[len("null or "):], sub), map[string]any{"type": "null"}}}
|
||||
case strings.HasPrefix(t, "list of "):
|
||||
return map[string]any{"type": "array", "items": parseOptType(t[len("list of "):], sub)}
|
||||
case strings.HasPrefix(t, "attribute set of "):
|
||||
return map[string]any{"type": "object", "additionalProperties": parseOptType(t[len("attribute set of "):], sub)}
|
||||
case strings.HasPrefix(t, "one of "):
|
||||
var vals []any
|
||||
for _, v := range strings.Split(t[len("one of "):], ", ") {
|
||||
v = strings.TrimSpace(v)
|
||||
if u, err := strconv.Unquote(v); err == nil {
|
||||
vals = append(vals, u)
|
||||
} else {
|
||||
return map[string]any{}
|
||||
}
|
||||
}
|
||||
return map[string]any{"enum": vals}
|
||||
case t == "(submodule)" || t == "submodule":
|
||||
return sub()
|
||||
case strings.HasPrefix(t, "(") && closingParen(t, 0) == len(t)-1:
|
||||
inner := t[1 : len(t)-1]
|
||||
parts := splitTopLevel(inner, " or ")
|
||||
if len(parts) == 1 {
|
||||
return parseOptType(inner, sub)
|
||||
}
|
||||
var any_ []any
|
||||
for _, p := range parts {
|
||||
any_ = append(any_, parseOptType(p, sub))
|
||||
}
|
||||
return map[string]any{"anyOf": any_}
|
||||
case t == "boolean":
|
||||
return map[string]any{"type": "boolean"}
|
||||
case t == "string":
|
||||
return map[string]any{"type": "string"}
|
||||
case t == "absolute path not in the Nix store":
|
||||
return map[string]any{"type": "string", "pattern": "^/"}
|
||||
case t == "signed integer" || t == "integer":
|
||||
return map[string]any{"type": "integer"}
|
||||
case strings.HasPrefix(t, "unsigned integer"):
|
||||
return map[string]any{"type": "integer", "minimum": 0}
|
||||
}
|
||||
if m := intRangeRe.FindStringSubmatch(t); m != nil {
|
||||
lo, _ := strconv.Atoi(m[1])
|
||||
hi, _ := strconv.Atoi(m[2])
|
||||
return map[string]any{"type": "integer", "minimum": lo, "maximum": hi}
|
||||
}
|
||||
if m := patternRe.FindStringSubmatch(t); m != nil {
|
||||
if _, err := regexp.Compile("^(?:" + m[1] + ")$"); err == nil {
|
||||
return map[string]any{"type": "string", "pattern": "^(?:" + m[1] + ")$"}
|
||||
}
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// closingParen is the index of the parenthesis that closes the one at i.
|
||||
func closingParen(s string, i int) int {
|
||||
depth := 0
|
||||
for j := i; j < len(s); j++ {
|
||||
switch s[j] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return j
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// splitTopLevel splits s at sep, not inside parentheses.
|
||||
func splitTopLevel(s, sep string) []string {
|
||||
var parts []string
|
||||
depth, start := 0, 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch {
|
||||
case s[i] == '(':
|
||||
depth++
|
||||
case s[i] == ')':
|
||||
depth--
|
||||
case depth == 0 && strings.HasPrefix(s[i:], sep):
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + len(sep)
|
||||
i += len(sep) - 1
|
||||
}
|
||||
}
|
||||
return append(parts, s[start:])
|
||||
}
|
||||
|
||||
// systemSchema describes `system`, which the router options do not cover.
|
||||
func systemSchema() map[string]any {
|
||||
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{
|
||||
"hostName": str("The router's host name."),
|
||||
"timeZone": str("A time zone name like Europe/Berlin (default UTC)."),
|
||||
"sshKeys": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "SSH keys of root. Read only through the API."},
|
||||
"stateVersion": str("system.stateVersion. Read only through the API."),
|
||||
}}
|
||||
}
|
||||
|
||||
// settingsSchema is the schema of the settings document below `router` and
|
||||
// `system` (nil when there is no option documentation).
|
||||
func settingsSchema(optionsFile string) map[string]any {
|
||||
if optionsFile == "" {
|
||||
return nil
|
||||
}
|
||||
entries, err := loadOptions(optionsFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tree := buildOptTree(entries)
|
||||
if tree == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{
|
||||
"version": map[string]any{"type": "integer"}, "router": tree.schema(), "system": systemSchema(),
|
||||
}}
|
||||
}
|
||||
|
||||
func unwrapNullable(s map[string]any) map[string]any {
|
||||
if branches, ok := s["anyOf"].([]any); ok && len(branches) == 2 {
|
||||
for i, b := range branches {
|
||||
if m, ok := b.(map[string]any); ok && m["type"] == "null" {
|
||||
if other, ok := branches[1-i].(map[string]any); ok {
|
||||
return other
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveSchema walks the schema along path segments (area paths).
|
||||
func resolveSchema(root map[string]any, segs []string) (map[string]any, error) {
|
||||
s := root
|
||||
for i, seg := range segs {
|
||||
s = unwrapNullable(s)
|
||||
props, _ := s["properties"].(map[string]any)
|
||||
if p, ok := props[seg].(map[string]any); ok {
|
||||
s = p
|
||||
continue
|
||||
}
|
||||
if add, ok := s["additionalProperties"].(map[string]any); ok {
|
||||
s = add
|
||||
continue
|
||||
}
|
||||
if s["type"] == "array" {
|
||||
return nil, fmt.Errorf("%s is a list: its elements cannot be addressed, use the whole list", strings.Join(segs[:i], "/"))
|
||||
}
|
||||
return nil, fmt.Errorf("unknown path %s (no option %q there)", strings.Join(segs, "/"), seg)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// validate checks a value against a schema made here; it returns what is wrong.
|
||||
func validate(v any, s map[string]any, path string) []string {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
if branches, ok := s["anyOf"].([]any); ok {
|
||||
for _, b := range branches {
|
||||
if m, ok := b.(map[string]any); ok && len(validate(v, m, path)) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Say what is wrong with the likeliest branch: the one that is not null.
|
||||
for _, b := range branches {
|
||||
if m, ok := b.(map[string]any); ok && m["type"] != "null" {
|
||||
if errs := validate(v, m, path); len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
}
|
||||
}
|
||||
return []string{path + ": not a valid value"}
|
||||
}
|
||||
if enum, ok := s["enum"].([]any); ok {
|
||||
if !slices.Contains(enum, v) {
|
||||
return []string{fmt.Sprintf("%s: must be one of %s", path, strings.TrimPrefix(fmt.Sprint(s["x-nix-type"]), "one of "))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fail := func(want string) []string {
|
||||
return []string{fmt.Sprintf("%s: expected %s", path, want)}
|
||||
}
|
||||
switch s["type"] {
|
||||
case "object":
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return fail("an object")
|
||||
}
|
||||
props, _ := s["properties"].(map[string]any)
|
||||
var errs []string
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
child := path + "/" + k
|
||||
if p, ok := props[k].(map[string]any); ok {
|
||||
errs = append(errs, validate(m[k], p, child)...)
|
||||
} else if add, ok := s["additionalProperties"].(map[string]any); ok {
|
||||
errs = append(errs, validate(m[k], add, child)...)
|
||||
} else if s["additionalProperties"] == false {
|
||||
errs = append(errs, child+": unknown option")
|
||||
}
|
||||
}
|
||||
return errs
|
||||
case "array":
|
||||
a, ok := v.([]any)
|
||||
if !ok {
|
||||
return fail("a list")
|
||||
}
|
||||
items, _ := s["items"].(map[string]any)
|
||||
var errs []string
|
||||
for i, e := range a {
|
||||
errs = append(errs, validate(e, items, fmt.Sprintf("%s[%d]", path, i))...)
|
||||
}
|
||||
return errs
|
||||
case "string":
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fail("a string")
|
||||
}
|
||||
if p, ok := s["pattern"].(string); ok {
|
||||
if re, err := regexp.Compile(p); err == nil && !re.MatchString(str) {
|
||||
return []string{fmt.Sprintf("%s: %q does not match the required form (%v)", path, str, s["x-nix-type"])}
|
||||
}
|
||||
}
|
||||
case "integer":
|
||||
f, ok := v.(float64)
|
||||
if !ok || f != float64(int64(f)) {
|
||||
return fail("a whole number")
|
||||
}
|
||||
if lo, ok := s["minimum"].(int); ok && f < float64(lo) {
|
||||
return []string{fmt.Sprintf("%s: %v is below the minimum %d", path, f, lo)}
|
||||
}
|
||||
if hi, ok := s["maximum"].(int); ok && f > float64(hi) {
|
||||
return []string{fmt.Sprintf("%s: %v is above the maximum %d", path, f, hi)}
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := v.(bool); !ok {
|
||||
return fail("true or false")
|
||||
}
|
||||
case "null":
|
||||
if v != nil {
|
||||
return fail("null")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSensors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(path, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(root, path)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, []byte(content+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("thermal/thermal_zone0/type", "acpitz")
|
||||
write("thermal/thermal_zone0/temp", "47000")
|
||||
write("thermal/thermal_zone1/type", "x86_pkg_temp")
|
||||
write("thermal/thermal_zone1/temp", "61000")
|
||||
write("thermal/thermal_zone2/type", "broken")
|
||||
write("thermal/thermal_zone2/temp", "0")
|
||||
write("hwmon/hwmon3/name", "coretemp")
|
||||
write("hwmon/hwmon3/temp1_input", "62000")
|
||||
write("hwmon/hwmon3/temp1_label", "Package id 0")
|
||||
write("hwmon/hwmon3/temp2_input", "58500")
|
||||
write("hwmon/hwmon3/temp2_label", "Core 0")
|
||||
write("hwmon/hwmon4/name", "nvme")
|
||||
write("hwmon/hwmon4/temp1_input", "39000")
|
||||
write("hwmon/hwmon5/name", "nvme")
|
||||
write("hwmon/hwmon5/temp1_input", "41000")
|
||||
|
||||
got := readSensors(filepath.Join(root, "thermal"), filepath.Join(root, "hwmon"))
|
||||
want := map[string]float64{
|
||||
"acpitz": 47, "x86_pkg_temp": 61, "coretemp Package id 0": 62, "coretemp Core 0": 58.5,
|
||||
"nvme temp1": 39, "nvme temp1 2": 41,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("sensors = %+v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
if v, ok := want[s.Label]; !ok || v != s.Temp || s.Key != s.Label {
|
||||
t.Errorf("unexpected %+v", s)
|
||||
}
|
||||
}
|
||||
if h := hottest(got); h != 62 {
|
||||
t.Errorf("hottest = %v", h)
|
||||
}
|
||||
if got := readSensors(filepath.Join(root, "none"), filepath.Join(root, "none")); len(got) != 0 || hottest(got) == hottest(got) {
|
||||
t.Errorf("no sensors: %+v (hottest should be NaN)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingKernel(t *testing.T) {
|
||||
img := "/nix/store/abc123-linux-6.12.5/bzImage"
|
||||
for running, want := range map[string]string{"6.12.5": "", "6.12.5-zen1": "", "6.12.5.0": "", "6.12.4": "6.12.5", "": ""} {
|
||||
if got := pendingKernel(running, img); got != want {
|
||||
t.Errorf("running %q: %q, want %q", running, got, want)
|
||||
}
|
||||
}
|
||||
if got := pendingKernel("7.3.0", "/nix/store/abc-linux-7.3/bzImage"); got != "" {
|
||||
t.Errorf("7.3.0 is 7.3: %q", got)
|
||||
}
|
||||
if got := pendingKernel("6.12.4", "/boot/vmlinuz"); got != "" {
|
||||
t.Errorf("an unknown path gave %q", got)
|
||||
}
|
||||
if k := systemStatus().Kernel; k == "" {
|
||||
t.Skip("no /proc/sys/kernel/osrelease")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Settings is settings.json kept generic: the forms change the keys they know
|
||||
// and everything else (WireGuard, port forwards, ...) survives untouched.
|
||||
type Settings map[string]any
|
||||
|
||||
func loadSettings(path string) (Settings, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseSettings(data)
|
||||
}
|
||||
|
||||
func parseSettings(data []byte) (Settings, error) {
|
||||
var s Settings
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("settings are not valid JSON: %w", err)
|
||||
}
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("settings must be a JSON object")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s Settings) marshal() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces path so that readers never see a partial file.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
func (s Settings) save(path string) error {
|
||||
data, err := s.marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
// obj returns the object at key, creating it if needed.
|
||||
func obj(m map[string]any, key string) map[string]any {
|
||||
if v, ok := m[key].(map[string]any); ok {
|
||||
return v
|
||||
}
|
||||
v := map[string]any{}
|
||||
m[key] = v
|
||||
return v
|
||||
}
|
||||
|
||||
func str(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys[:len(keys)-1] {
|
||||
next, ok := m[k].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
m = next
|
||||
}
|
||||
switch v := m[keys[len(keys)-1]].(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64: // as decoded from JSON
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case int: // as set by apply
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func boolean(m map[string]any, def bool, keys ...string) bool {
|
||||
for _, k := range keys[:len(keys)-1] {
|
||||
next, ok := m[k].(map[string]any)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
m = next
|
||||
}
|
||||
if v, ok := m[keys[len(keys)-1]].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Form is what the network page edits.
|
||||
type Form struct {
|
||||
HostName string
|
||||
Domain string // the local domain (router.dns.domain)
|
||||
WAN WANForm
|
||||
LANs []LANForm
|
||||
}
|
||||
|
||||
type WANForm struct {
|
||||
Interface string // a device: "enp2s0", or "enp2s0.1001" for a VLAN
|
||||
Type string // "dhcp" or "pppoe"
|
||||
IPv6 bool
|
||||
PPPoEUser string
|
||||
PPPoEPassword string // write-only: empty keeps the stored one
|
||||
PasswordSet bool
|
||||
}
|
||||
|
||||
type LANForm struct {
|
||||
Name string
|
||||
Interface string // a device: "eth2", or "eth2.20" for a VLAN
|
||||
Address string
|
||||
Prefix string
|
||||
Internet bool
|
||||
IPv6PD bool // delegate the ISP's prefix to this LAN
|
||||
DHCP bool
|
||||
PoolStart string
|
||||
PoolEnd string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
func (s Settings) form() Form {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
if router == nil {
|
||||
router = map[string]any{}
|
||||
}
|
||||
f := Form{
|
||||
HostName: str(s, "system", "hostName"),
|
||||
Domain: str(router, "dns", "domain"),
|
||||
WAN: WANForm{
|
||||
Interface: str(router, "wan", "interface"),
|
||||
Type: str(router, "wan", "ipv4", "type"),
|
||||
IPv6: boolean(router, false, "wan", "ipv6", "enable"),
|
||||
PPPoEUser: str(router, "wan", "ipv4", "pppoe", "username"),
|
||||
},
|
||||
}
|
||||
if vlan := str(router, "wan", "ipv4", "pppoe", "vlan"); vlan != "" && f.WAN.Type == "pppoe" {
|
||||
f.WAN.Interface += "." + vlan
|
||||
}
|
||||
if f.WAN.Type == "" {
|
||||
f.WAN.Type = "dhcp"
|
||||
}
|
||||
f.WAN.PasswordSet = str(router, "wan", "ipv4", "pppoe", "passwordFile") != ""
|
||||
lans, _ := router["lans"].(map[string]any)
|
||||
names := make([]string, 0, len(lans))
|
||||
for name := range lans {
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
for _, name := range names {
|
||||
l, _ := lans[name].(map[string]any)
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
dev := str(l, "interface")
|
||||
if vlan := str(l, "vlan"); vlan != "" {
|
||||
dev += "." + vlan
|
||||
}
|
||||
f.LANs = append(f.LANs, LANForm{
|
||||
Name: name,
|
||||
Interface: dev,
|
||||
Address: str(l, "ipv4", "address"),
|
||||
Prefix: str(l, "ipv4", "prefixLength"),
|
||||
Internet: boolean(l, false, "internet"),
|
||||
IPv6PD: boolean(l, false, "ipv6", "pd"),
|
||||
DHCP: boolean(l, true, "dhcp", "enable"),
|
||||
PoolStart: str(l, "dhcp", "pool", "start"),
|
||||
PoolEnd: str(l, "dhcp", "pool", "end"),
|
||||
})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
var (
|
||||
nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
|
||||
// A DNS name of up to 253 characters.
|
||||
domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`)
|
||||
)
|
||||
|
||||
// FormErrors maps a field to what is wrong with it.
|
||||
type FormErrors map[string]string
|
||||
|
||||
func (e FormErrors) Error() string {
|
||||
keys := make([]string, 0, len(e))
|
||||
for k := range e {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, "%s: %s; ", k, e[k])
|
||||
}
|
||||
return strings.TrimSuffix(b.String(), "; ")
|
||||
}
|
||||
|
||||
func optionalInt(v string, lo, hi int) (int, bool, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < lo || n > hi {
|
||||
return 0, false, fmt.Errorf("must be a number from %d to %d", lo, hi)
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
|
||||
// splitVLAN splits "enp2s0.1001" into the interface and its VLAN ID; a name
|
||||
// without one has VLAN 0.
|
||||
func splitVLAN(dev string) (string, int) {
|
||||
dev = strings.TrimSpace(dev)
|
||||
i := strings.LastIndex(dev, ".")
|
||||
if i <= 0 {
|
||||
return dev, 0
|
||||
}
|
||||
id, err := strconv.Atoi(dev[i+1:])
|
||||
if err != nil || id < 1 || id > 4094 {
|
||||
return dev, 0
|
||||
}
|
||||
return dev[:i], id
|
||||
}
|
||||
|
||||
// badVLAN reports a device name that looks like a VLAN (interface.digits) but
|
||||
// whose ID is not from 1 to 4094.
|
||||
func badVLAN(dev string) bool {
|
||||
i := strings.LastIndex(dev, ".")
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
if _, err := strconv.Atoi(dev[i+1:]); err != nil {
|
||||
return false
|
||||
}
|
||||
_, id := splitVLAN(dev)
|
||||
return id == 0
|
||||
}
|
||||
|
||||
// Device is a row of the interface list: a physical NIC or a VLAN on one.
|
||||
type Device struct {
|
||||
Name string
|
||||
Kind string // "physical" or "VLAN"
|
||||
Role string // "WAN", "LAN lan", or ""
|
||||
Removable bool // a VLAN defined in the UI that nothing uses
|
||||
// Set by the app from the machine and the settings.
|
||||
Kernel string // physical: the name the kernel gives the card now
|
||||
MAC string // physical: of the card, when it is on this machine
|
||||
Custom string // VLAN: the name chosen for its device
|
||||
}
|
||||
|
||||
// devices lists the physical interfaces plus the VLAN devices the settings
|
||||
// use or the UI defined (defined), each with the role it plays.
|
||||
func (f Form) devices(physical, defined []string) []Device {
|
||||
roles := map[string][]string{}
|
||||
add := func(dev, role string) { roles[dev] = append(roles[dev], role) }
|
||||
add(f.WAN.Interface, "WAN")
|
||||
for _, l := range f.LANs {
|
||||
if l.Name == "" || l.Remove {
|
||||
continue
|
||||
}
|
||||
add(l.Interface, "LAN "+l.Name)
|
||||
}
|
||||
// Interfaces the settings or the defined VLANs refer to are listed even
|
||||
// when this machine lacks them (other hardware).
|
||||
names := slices.Clone(physical)
|
||||
var vlans, extra []string
|
||||
for _, dev := range append(slices.Collect(maps.Keys(roles)), defined...) {
|
||||
base, id := splitVLAN(dev)
|
||||
if id != 0 && !slices.Contains(vlans, dev) {
|
||||
vlans = append(vlans, dev)
|
||||
}
|
||||
if !slices.Contains(names, base) && !slices.Contains(extra, base) {
|
||||
extra = append(extra, base)
|
||||
}
|
||||
}
|
||||
slices.Sort(vlans)
|
||||
slices.Sort(extra)
|
||||
names = append(names, extra...)
|
||||
var out []Device
|
||||
for _, n := range names {
|
||||
out = append(out, Device{Name: n, Kind: "physical", Role: strings.Join(roles[n], ", ")})
|
||||
for _, v := range vlans {
|
||||
if parent, _ := splitVLAN(v); parent == n {
|
||||
out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", "),
|
||||
Removable: len(roles[v]) == 0 && slices.Contains(defined, v)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ipv4(v string) (netip.Addr, bool) {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(v))
|
||||
return a, err == nil && a.Is4()
|
||||
}
|
||||
|
||||
// apply writes the form into the settings. Only basic checks happen here; the
|
||||
// build on the device validates the rest (and the router's assertions).
|
||||
// It returns the PPPoE password to store, if one was entered.
|
||||
func (s Settings) apply(f Form, passwordFile string) (string, error) {
|
||||
errs := FormErrors{}
|
||||
system := obj(s, "system")
|
||||
router := obj(s, "router")
|
||||
wan := obj(router, "wan")
|
||||
|
||||
if f.HostName = strings.TrimSpace(f.HostName); f.HostName != "" {
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9-]{1,63}$`).MatchString(f.HostName) {
|
||||
errs["hostName"] = "letters, digits and dashes only"
|
||||
}
|
||||
system["hostName"] = f.HostName
|
||||
}
|
||||
|
||||
if f.Domain = strings.ToLower(strings.TrimSpace(f.Domain)); f.Domain == "" {
|
||||
if dns, ok := router["dns"].(map[string]any); ok {
|
||||
delete(dns, "domain")
|
||||
}
|
||||
} else if !domainRe.MatchString(f.Domain) {
|
||||
errs["domain"] = "letters, digits and dashes, in labels separated by dots (home.example.org)"
|
||||
} else {
|
||||
obj(router, "dns")["domain"] = f.Domain
|
||||
}
|
||||
|
||||
// A VLAN device (enp2s0.1001) is the physical interface plus the
|
||||
// PPPoE session's VLAN.
|
||||
wanDev, wanVLAN := splitVLAN(f.WAN.Interface)
|
||||
if !ifaceRe.MatchString(f.WAN.Interface) {
|
||||
errs["wan.interface"] = "an interface name is required"
|
||||
} else if badVLAN(f.WAN.Interface) {
|
||||
errs["wan.interface"] = "the VLAN ID must be from 1 to 4094"
|
||||
}
|
||||
wan["interface"] = wanDev
|
||||
ipv4cfg := obj(wan, "ipv4")
|
||||
if wanVLAN != 0 && f.WAN.Type != "pppoe" {
|
||||
errs["wan.interface"] = "an uplink on a VLAN needs PPPoE"
|
||||
}
|
||||
switch f.WAN.Type {
|
||||
case "dhcp":
|
||||
ipv4cfg["type"] = "dhcp"
|
||||
case "pppoe":
|
||||
ipv4cfg["type"] = "pppoe"
|
||||
pppoe := obj(ipv4cfg, "pppoe")
|
||||
pppoe["username"] = strings.TrimSpace(f.WAN.PPPoEUser)
|
||||
if pppoe["username"] == "" {
|
||||
errs["wan.pppoe.username"] = "required for PPPoE"
|
||||
}
|
||||
if wanVLAN != 0 {
|
||||
pppoe["vlan"] = wanVLAN
|
||||
} else {
|
||||
delete(pppoe, "vlan")
|
||||
delete(pppoe, "vlanName")
|
||||
}
|
||||
if f.WAN.PPPoEPassword != "" {
|
||||
pppoe["passwordFile"] = passwordFile
|
||||
} else if str(pppoe, "passwordFile") == "" {
|
||||
errs["wan.pppoe.password"] = "required for PPPoE"
|
||||
}
|
||||
default:
|
||||
errs["wan.type"] = "must be dhcp or pppoe"
|
||||
}
|
||||
if f.WAN.IPv6 {
|
||||
v6 := obj(wan, "ipv6")
|
||||
v6["enable"] = true
|
||||
// PPPoE carries IPv6 inside the session; otherwise it is native.
|
||||
if f.WAN.Type == "pppoe" {
|
||||
v6["transport"] = "ppp"
|
||||
} else {
|
||||
v6["transport"] = "native"
|
||||
}
|
||||
} else if v6, ok := wan["ipv6"].(map[string]any); ok {
|
||||
v6["enable"] = false
|
||||
}
|
||||
|
||||
lans := obj(router, "lans")
|
||||
keep := map[string]bool{}
|
||||
for i, lf := range f.LANs {
|
||||
field := func(n string) string { return fmt.Sprintf("lans[%d].%s", i, n) }
|
||||
lf.Name = strings.TrimSpace(lf.Name)
|
||||
if lf.Name == "" && lf.Interface == "" && lf.Address == "" {
|
||||
continue // the empty "add a LAN" row
|
||||
}
|
||||
if !nameRe.MatchString(lf.Name) {
|
||||
errs[field("name")] = "lowercase letters, digits and dashes, starting with a letter"
|
||||
continue
|
||||
}
|
||||
if keep[lf.Name] {
|
||||
errs[field("name")] = "used twice"
|
||||
continue
|
||||
}
|
||||
if lf.Remove {
|
||||
continue
|
||||
}
|
||||
keep[lf.Name] = true
|
||||
l := obj(lans, lf.Name)
|
||||
if !ifaceRe.MatchString(lf.Interface) {
|
||||
errs[field("interface")] = "an interface name is required"
|
||||
} else if badVLAN(lf.Interface) {
|
||||
errs[field("interface")] = "the VLAN ID must be from 1 to 4094"
|
||||
}
|
||||
lanDev, lanVLAN := splitVLAN(lf.Interface)
|
||||
l["interface"] = lanDev
|
||||
if lanVLAN != 0 {
|
||||
l["vlan"] = lanVLAN
|
||||
} else {
|
||||
delete(l, "vlan")
|
||||
delete(l, "deviceName")
|
||||
}
|
||||
l["internet"] = lf.Internet
|
||||
if lf.IPv6PD {
|
||||
obj(l, "ipv6")["pd"] = true
|
||||
} else if v6, ok := l["ipv6"].(map[string]any); ok {
|
||||
delete(v6, "pd")
|
||||
if len(v6) == 0 {
|
||||
delete(l, "ipv6")
|
||||
}
|
||||
}
|
||||
v4 := obj(l, "ipv4")
|
||||
if _, ok := ipv4(lf.Address); !ok {
|
||||
errs[field("address")] = "an IPv4 address is required"
|
||||
}
|
||||
v4["address"] = strings.TrimSpace(lf.Address)
|
||||
if p, ok, err := optionalInt(lf.Prefix, 8, 30); err != nil {
|
||||
errs[field("prefix")] = err.Error()
|
||||
} else if ok {
|
||||
v4["prefixLength"] = p
|
||||
} else {
|
||||
delete(v4, "prefixLength")
|
||||
}
|
||||
dhcp := obj(l, "dhcp")
|
||||
dhcp["enable"] = lf.DHCP
|
||||
if lf.DHCP {
|
||||
start, ok1 := ipv4(lf.PoolStart)
|
||||
end, ok2 := ipv4(lf.PoolEnd)
|
||||
if !ok1 {
|
||||
errs[field("poolStart")] = "an IPv4 address is required"
|
||||
}
|
||||
if !ok2 {
|
||||
errs[field("poolEnd")] = "an IPv4 address is required"
|
||||
}
|
||||
if ok1 && ok2 && end.Less(start) {
|
||||
errs[field("poolEnd")] = "must not be before the start"
|
||||
}
|
||||
pool := obj(dhcp, "pool")
|
||||
pool["start"] = strings.TrimSpace(lf.PoolStart)
|
||||
pool["end"] = strings.TrimSpace(lf.PoolEnd)
|
||||
}
|
||||
}
|
||||
for name := range lans {
|
||||
if !keep[name] {
|
||||
delete(lans, name)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
errs["lans"] = "at least one LAN is required"
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return "", errs
|
||||
}
|
||||
return f.WAN.PPPoEPassword, nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sample = `{
|
||||
"system": { "hostName": "router", "stateVersion": "26.11" },
|
||||
"router": {
|
||||
"wan": { "interface": "eth1" },
|
||||
"lans": {
|
||||
"main": {
|
||||
"interface": "eth2", "internet": true,
|
||||
"ipv4": { "address": "192.168.10.1" },
|
||||
"dhcp": { "pool": { "start": "192.168.10.100", "end": "192.168.10.199" },
|
||||
"reservations": { "nas": { "mac": "aa:bb:cc:dd:ee:ff", "address": "192.168.10.5" } } },
|
||||
"ipv6": { "address": "fd00::1/64" }
|
||||
}
|
||||
},
|
||||
"wireguard": { "vpn": { "listenPort": 51820 } }
|
||||
}
|
||||
}`
|
||||
|
||||
func mustParse(t *testing.T) Settings {
|
||||
t.Helper()
|
||||
s, err := parseSettings([]byte(sample))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestFormReadsSettings(t *testing.T) {
|
||||
f := mustParse(t).form()
|
||||
if f.HostName != "router" || f.WAN.Interface != "eth1" || f.WAN.Type != "dhcp" {
|
||||
t.Fatalf("unexpected form: %+v", f)
|
||||
}
|
||||
if len(f.LANs) != 1 || f.LANs[0].Name != "main" || !f.LANs[0].DHCP || !f.LANs[0].Internet ||
|
||||
f.LANs[0].PoolEnd != "192.168.10.199" {
|
||||
t.Fatalf("unexpected LANs: %+v", f.LANs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKeepsUnknownKeys(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.LANs[0].PoolEnd = "192.168.10.150"
|
||||
if _, err := s.apply(f, "/var/lib/router/pppoe-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := s.marshal()
|
||||
for _, want := range []string{`"192.168.10.150"`, `"reservations"`, `"fd00::1/64"`, `"wireguard"`, `"stateVersion"`} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Errorf("%s missing after apply:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPPPoE(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.WAN.Type = "pppoe"
|
||||
f.WAN.PPPoEUser = "user@isp"
|
||||
f.WAN.Interface += ".7"
|
||||
if _, err := s.apply(f, "/pw"); err == nil {
|
||||
t.Fatal("PPPoE without any password was accepted")
|
||||
}
|
||||
s = mustParse(t)
|
||||
f.WAN.PPPoEPassword = "secret"
|
||||
pw, err := s.apply(f, "/pw")
|
||||
if err != nil || pw != "secret" {
|
||||
t.Fatalf("apply: %q %v", pw, err)
|
||||
}
|
||||
if got := str(s, "router", "wan", "ipv4", "pppoe", "passwordFile"); got != "/pw" {
|
||||
t.Errorf("passwordFile = %q", got)
|
||||
}
|
||||
out, _ := s.marshal()
|
||||
if strings.Contains(string(out), "secret") {
|
||||
t.Error("the password ended up in the settings")
|
||||
}
|
||||
if !strings.Contains(string(out), `"vlan": 7`) {
|
||||
t.Errorf("vlan is not a number:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyValidates(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.LANs[0].Address = "not-an-ip"
|
||||
f.LANs[0].Interface = "eth2.5000"
|
||||
f.LANs = append(f.LANs, LANForm{Name: "Bad Name", Interface: "eth3", Address: "10.0.0.1"})
|
||||
_, err := s.apply(f, "/pw")
|
||||
var fe FormErrors
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("expected form errors, got %v", err)
|
||||
}
|
||||
for _, k := range []string{"lans[0].address", "lans[0].interface", "lans[1].name"} {
|
||||
if fe[k] == "" {
|
||||
t.Errorf("no error for %s: %v", k, fe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAddsAndRemovesLANs(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.LANs = append(f.LANs, LANForm{Name: "guest", Interface: "eth2.20", Address: "192.168.30.1",
|
||||
DHCP: true, PoolStart: "192.168.30.100", PoolEnd: "192.168.30.199"})
|
||||
if _, err := s.apply(f, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.form().LANs; len(got) != 2 || got[0].Name != "guest" || got[0].Interface != "eth2.20" {
|
||||
t.Fatalf("after adding: %+v", got)
|
||||
}
|
||||
f = s.form()
|
||||
f.LANs[0].Remove = true
|
||||
if _, err := s.apply(f, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.form().LANs; len(got) != 1 || got[0].Name != "main" {
|
||||
t.Fatalf("after removing: %+v", got)
|
||||
}
|
||||
f = s.form()
|
||||
f.LANs[0].Remove = true
|
||||
if _, err := s.apply(f, "/pw"); err == nil {
|
||||
t.Fatal("removing the last LAN was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVLANUplinkAndIPv6(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.WAN.Type = "pppoe"
|
||||
f.WAN.PPPoEUser = "user@isp"
|
||||
f.WAN.PPPoEPassword = "secret"
|
||||
f.WAN.Interface = "enp2s0.1001"
|
||||
f.WAN.IPv6 = true
|
||||
f.LANs[0].IPv6PD = true
|
||||
if _, err := s.apply(f, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := str(s, "router", "wan", "interface"); got != "enp2s0" {
|
||||
t.Errorf("interface = %q", got)
|
||||
}
|
||||
if got := str(s, "router", "wan", "ipv4", "pppoe", "vlan"); got != "1001" {
|
||||
t.Errorf("vlan = %q", got)
|
||||
}
|
||||
if got := str(s, "router", "wan", "ipv6", "transport"); got != "ppp" {
|
||||
t.Errorf("transport = %q", got)
|
||||
}
|
||||
if !s.form().LANs[0].IPv6PD || s.form().WAN.Interface != "enp2s0.1001" {
|
||||
t.Errorf("form does not round-trip: %+v", s.form())
|
||||
}
|
||||
devs := s.form().devices([]string{"enp2s0", "wlp3s0"}, []string{"enp2s0.77"})
|
||||
if len(devs) != 5 || !devs[2].Removable || devs[1].Name != "enp2s0.1001" || devs[1].Role != "WAN" {
|
||||
t.Errorf("devices = %+v", devs)
|
||||
}
|
||||
|
||||
// Without PPPoE a VLAN uplink is not possible; turning IPv6 off keeps the rest.
|
||||
g := s.form()
|
||||
g.WAN.Type = "dhcp"
|
||||
if _, err := s.apply(g, "/pw"); err == nil {
|
||||
t.Error("a DHCP uplink on a VLAN was accepted")
|
||||
}
|
||||
g = s.form()
|
||||
g.WAN.IPv6 = false
|
||||
g.LANs[0].IPv6PD = false
|
||||
if _, err := s.apply(g, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if boolean(s, true, "router", "wan", "ipv6", "enable") || s.form().LANs[0].IPv6PD {
|
||||
t.Error("IPv6 still on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDomain(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.Domain = " Home.Example.org "
|
||||
if _, err := s.apply(f, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := str(s, "router", "dns", "domain"); got != "home.example.org" {
|
||||
t.Errorf("domain = %q", got)
|
||||
}
|
||||
if s.form().Domain != "home.example.org" {
|
||||
t.Error("the form does not show it")
|
||||
}
|
||||
f.Domain = "bad domain"
|
||||
if _, err := s.apply(f, "/pw"); err == nil {
|
||||
t.Error("a domain with a space was accepted")
|
||||
}
|
||||
f.Domain = ""
|
||||
if _, err := s.apply(f, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := str(s, "router", "dns", "domain"); got != "" {
|
||||
t.Errorf("domain not removed: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
body { margin: 0; }
|
||||
@@ -0,0 +1,11 @@
|
||||
// Shows the OpenAPI document with Redoc (redoc.standalone.js).
|
||||
(function () {
|
||||
var el = document.getElementById("redoc");
|
||||
Redoc.init(el.dataset.spec, {
|
||||
nonce: el.dataset.nonce,
|
||||
hideDownloadButton: false,
|
||||
expandResponses: "200",
|
||||
pathInMiddlePanel: true,
|
||||
sortPropsAlphabetically: false,
|
||||
}, el);
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
// Live updates for the overview: refetches the dashboard part of the page
|
||||
// (rendered by the server, like the page itself) and switches time ranges
|
||||
// without a reload. Without JavaScript the page and its links still work.
|
||||
(function () {
|
||||
"use strict";
|
||||
const root = document.getElementById("dashboard");
|
||||
if (!root) return;
|
||||
let range = root.dataset.range;
|
||||
let interval = Number(root.dataset.interval) || 5;
|
||||
let timer = null;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch("/dashboard?range=" + encodeURIComponent(range), { credentials: "same-origin" });
|
||||
if (r.redirected || r.status === 401 || r.status === 403) {
|
||||
location.href = "/login"; // the session ended
|
||||
return;
|
||||
}
|
||||
if (r.ok) root.innerHTML = await r.text();
|
||||
} catch (e) {
|
||||
// The router may be restarting the UI (an apply); try again later.
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(refresh, interval * 1000);
|
||||
}
|
||||
|
||||
root.addEventListener("click", function (ev) {
|
||||
const a = ev.target.closest("a[data-range]");
|
||||
if (!a) return;
|
||||
ev.preventDefault();
|
||||
range = a.dataset.range;
|
||||
interval = range === "15m" || range === "1h" ? 5 : 60;
|
||||
history.replaceState(null, "", "/?range=" + encodeURIComponent(range));
|
||||
refresh();
|
||||
});
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) clearTimeout(timer);
|
||||
else refresh();
|
||||
});
|
||||
schedule();
|
||||
})();
|
||||
@@ -0,0 +1,5 @@
|
||||
// Ask before a form marked data-confirm is sent.
|
||||
document.addEventListener("submit", function (e) {
|
||||
var q = e.target.getAttribute("data-confirm");
|
||||
if (q && !confirm(q)) e.preventDefault();
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/* Left sidebar, panels with a header bar. Light and dark: follows the system
|
||||
unless the page says data-theme="light" or "dark" (Advanced page). */
|
||||
:root {
|
||||
--fg: #1f2430; --muted: #5d6675; --bg: #eef0f3; --surface: #fff; --field: #fff; --line: #d5d9e0; --head: #e4e8ee; --zebra: #f6f8fa;
|
||||
--accent: #1d6fd8; --accent-hover: #1859b0; --on-accent: #fff;
|
||||
--side: #232a35; --side-fg: #c6cdd9; --side-muted: #7d889a; --side-hover: #323b49; --side-active: #1d6fd8;
|
||||
--warn: #fff3cd; --warnline: #e0a100; --bad: #c62828; --ok: #dff3e4; --good: #1b7a3a;
|
||||
--code: #eceff3; --switch: #b8c0cc; --tick: #8a94a5; --scroll: #b9c0cc;
|
||||
--s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; --s5: #0b7285; --s6: #862e9c; --s7: #5c940d; --s8: #a61e4d; --s9: #5f3dc4;
|
||||
--radius: 4px; --shadow: 0 1px 2px rgba(16, 24, 40, .06);
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--fg: #e4e8ee; --muted: #9aa4b5; --bg: #181c23; --surface: #212732; --field: #181c23; --line: #333b48; --head: #2a313d; --zebra: rgba(255, 255, 255, .03);
|
||||
--accent: #6ea8fe; --accent-hover: #8dbaff; --on-accent: #0d1b33;
|
||||
--side: #12151b; --side-fg: #c3cad6; --side-muted: #6c7688; --side-hover: #202631; --side-active: #6ea8fe;
|
||||
--warn: #3b341a; --warnline: #f0b429; --bad: #ff8a80; --ok: #1c3527; --good: #5fd38d;
|
||||
--code: #2c3441; --switch: #434d5e; --tick: #5b667a; --scroll: #3d4757;
|
||||
--s0: #8ba4ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc;
|
||||
--shadow: none; color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--fg: #e4e8ee; --muted: #9aa4b5; --bg: #181c23; --surface: #212732; --field: #181c23; --line: #333b48; --head: #2a313d; --zebra: rgba(255, 255, 255, .03);
|
||||
--accent: #6ea8fe; --accent-hover: #8dbaff; --on-accent: #0d1b33;
|
||||
--side: #12151b; --side-fg: #c3cad6; --side-muted: #6c7688; --side-hover: #202631; --side-active: #6ea8fe;
|
||||
--warn: #3b341a; --warnline: #f0b429; --bad: #ff8a80; --ok: #1c3527; --good: #5fd38d;
|
||||
--code: #2c3441; --switch: #434d5e; --tick: #5b667a; --scroll: #3d4757;
|
||||
--s0: #8ba4ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc;
|
||||
--shadow: none; color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; scrollbar-color: var(--scroll) transparent; scrollbar-width: thin; }
|
||||
body { margin: 0; padding-left: 15rem; font: 14.5px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); -webkit-font-smoothing: antialiased; }
|
||||
a { color: var(--accent); text-underline-offset: 2px; }
|
||||
|
||||
/* Sidebar */
|
||||
header { position: fixed; z-index: 20; left: 0; top: 0; bottom: 0; width: 15rem; display: flex; flex-direction: column; gap: 1rem; padding: 1rem .6rem; background: var(--side); overflow-y: auto; }
|
||||
header > strong { padding: .2rem .7rem; font-size: 1.1rem; font-weight: 700; color: #fff; }
|
||||
nav { display: flex; flex-direction: column; gap: 1px; }
|
||||
nav > a, nav .group a, nav button.link { display: block; width: 100%; padding: .38rem .7rem; border-radius: var(--radius); color: var(--side-fg); text-decoration: none; text-align: left; }
|
||||
nav .group a { padding-left: 1.4rem; }
|
||||
nav a:hover, nav button.link:hover { background: var(--side-hover); color: #fff; }
|
||||
nav a[aria-current] { background: var(--side-active); color: var(--on-accent); }
|
||||
nav .group { margin: .8rem 0 0; }
|
||||
nav .group > span { display: block; padding: 0 .7rem .25rem; font-size: .7rem; text-transform: uppercase; letter-spacing: .08em; color: var(--side-muted); }
|
||||
nav form { margin-top: 1rem; }
|
||||
nav button.link { background: none; border: 0; padding: .38rem .7rem; font-weight: 400; cursor: pointer; }
|
||||
|
||||
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; }
|
||||
h2 { font-size: .95rem; font-weight: 600; margin: 0 0 .6rem; }
|
||||
section { margin: 0 0 1.5rem; }
|
||||
section.busy, section.pending { padding: .75rem 1rem; border-radius: var(--radius); background: var(--warn); }
|
||||
.flash, .error { padding: .6rem .9rem; border-radius: var(--radius); margin: 0 0 1rem; }
|
||||
.flash { background: var(--ok); }
|
||||
.error { background: color-mix(in srgb, var(--bad) 12%, transparent); }
|
||||
.error, .field-error { color: var(--bad); }
|
||||
.field-error { font-size: .9em; }
|
||||
.hint { color: var(--muted); font-size: .9em; margin: .25rem 0 .6rem; }
|
||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: .3rem 1.25rem; }
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* Panels: a header bar (the legend) over a padded body */
|
||||
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 ~ * { 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 > .scroll { margin-left: 0; margin-right: 0; padding: 0 1rem; overflow-x: auto; }
|
||||
|
||||
/* Fields: a caption above the input */
|
||||
label { display: inline-flex; flex-direction: column; gap: .2rem; margin: 0 1rem .6rem 0; font-size: .85em; font-weight: 500; color: var(--muted); }
|
||||
input, select, textarea, button { font: inherit; color: var(--fg); background: var(--field); border: 1px solid var(--line); border-radius: var(--radius); padding: .4rem .6rem; }
|
||||
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::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 65%, transparent); }
|
||||
textarea[rows="30"] { height: min(44rem, 65vh); }
|
||||
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 label { margin: 0; }
|
||||
|
||||
/* Ticks: a switch in front of its text; a plain box inside tables */
|
||||
label:has(> input[type=checkbox]) { flex-direction: row; align-items: center; gap: .6rem; font-size: 1em; font-weight: 400; color: var(--fg); margin: 0 1.25rem .5rem 0; }
|
||||
fieldset > label:has(> input[type=checkbox]), form > label:has(> input[type=checkbox]) { display: flex; }
|
||||
input[type=checkbox] { appearance: none; flex: none; position: relative; width: 2.1rem; height: 1.2rem; padding: 0; margin: 0; border: 0; border-radius: 1rem; background: var(--switch); cursor: pointer; transition: background .15s; vertical-align: middle; }
|
||||
input[type=checkbox]::after { content: ""; position: absolute; top: .15rem; left: .15rem; width: .9rem; height: .9rem; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .3); transition: transform .15s; }
|
||||
input[type=checkbox]:checked { background: var(--accent); }
|
||||
input[type=checkbox]:checked::after { transform: translateX(.9rem); }
|
||||
td > input[type=checkbox], th > input[type=checkbox], p > label > input[type=checkbox] { width: 1.05rem; height: 1.05rem; border-radius: 3px; border: 1.5px solid var(--tick); background: var(--field); }
|
||||
td > input[type=checkbox]::after, th > input[type=checkbox]::after, p > label > input[type=checkbox]::after { display: none; }
|
||||
td > input[type=checkbox]:checked, th > input[type=checkbox]:checked, p > label > input[type=checkbox]:checked { border-color: var(--accent); background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-6.5' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 90% no-repeat; }
|
||||
td:has(> input[type=checkbox]) { text-align: center; }
|
||||
|
||||
/* Buttons */
|
||||
button { background: var(--accent); color: var(--on-accent); border-color: var(--accent); cursor: pointer; font-weight: 600; padding: .42rem 1.05rem; }
|
||||
button:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
button.secondary { background: var(--surface); color: var(--fg); border-color: var(--line); }
|
||||
button.secondary:hover { background: var(--zebra); border-color: var(--accent); }
|
||||
button.link { background: none; border: 0; padding: 0; color: var(--accent); font-weight: 400; text-decoration: underline; }
|
||||
button.link:hover { background: none; color: var(--accent-hover); }
|
||||
.default { position: absolute; left: -9999px; }
|
||||
.actions { display: flex; gap: .75rem; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* Tables */
|
||||
table { border-collapse: collapse; }
|
||||
th { text-align: left; font-size: .82rem; font-weight: 600; color: var(--muted); padding: 0 .45rem .35rem; white-space: nowrap; border-bottom: 1px solid var(--line); }
|
||||
td { padding: .3rem .45rem; }
|
||||
table tr:nth-child(even) td { background: var(--zebra); }
|
||||
pre.log { background: var(--code); padding: .85rem 1rem; border-radius: var(--radius); overflow-x: auto; font-size: 12.5px; max-height: 30rem; }
|
||||
code { font-size: .88em; overflow-wrap: anywhere; background: var(--code); padding: .1em .35em; border-radius: 3px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
.opts td:first-child code { white-space: nowrap; overflow-wrap: normal; }
|
||||
.opts td { vertical-align: top; }
|
||||
.muted { color: var(--muted); }
|
||||
.bad { color: var(--bad); }
|
||||
.ok { color: var(--good); }
|
||||
|
||||
body.bare { padding-left: 0; }
|
||||
.bare header { position: static; width: auto; flex-direction: row; background: none; padding: 1rem 1.25rem; }
|
||||
.bare header > strong { color: var(--fg); }
|
||||
.bare main { margin: 0 auto; max-width: 30rem; }
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
body { padding-left: 0; }
|
||||
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; }
|
||||
.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; }
|
||||
header nav { display: none; width: 100%; max-height: calc(100vh - 4rem); overflow-y: auto; }
|
||||
.menu-toggle:checked ~ nav { display: flex; }
|
||||
.menu-toggle:focus-visible ~ .menu-button { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||
main { padding: .75rem .6rem 2rem; }
|
||||
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 a { padding: .25rem .8rem; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; color: var(--fg); background: var(--surface); }
|
||||
.ranges a:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.ranges a.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1rem; margin: 0 0 1.25rem; }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-top: 3px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: .85rem 1.1rem; margin: 0; }
|
||||
.card.up { border-top-color: var(--good); }
|
||||
.card.degraded { border-top-color: var(--warnline); }
|
||||
.card.down { border-top-color: var(--bad); }
|
||||
.card .state { font-weight: 600; margin: 0 0 .5rem; }
|
||||
.card dl { margin: 0; font-size: .92em; }
|
||||
.events { list-style: none; margin: 0; padding: 0; font-size: .9em; }
|
||||
.events li span:first-child { display: inline-block; width: 2.5rem; font-weight: 600; }
|
||||
.scroll { overflow-x: auto; min-width: 0; }
|
||||
.leases td, .leases th { padding: .2rem .75rem .2rem 0; white-space: nowrap; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); gap: 1rem; margin: 0 0 1.5rem; }
|
||||
.chart { margin: 0; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: .65rem .9rem; min-width: 0; }
|
||||
.chart figcaption { display: flex; flex-wrap: wrap; gap: .25rem 1rem; justify-content: space-between; font-size: .9em; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: .1rem .9rem; }
|
||||
.key::before { content: ""; display: inline-block; width: .7rem; height: .2rem; margin: 0 .3rem .2rem 0; background: currentColor; vertical-align: middle; }
|
||||
.key b { font-weight: 500; color: var(--fg); }
|
||||
.chart svg { width: 100%; height: auto; display: block; overflow: visible; }
|
||||
.chart .grid { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .lab { fill: var(--muted); font-size: 11px; }
|
||||
.chart .line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.chart .hover { fill: transparent; }
|
||||
.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
|
||||
.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
|
||||
.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
|
||||
.s5 { color: var(--s5); stroke: var(--s5); } .s6 { color: var(--s6); stroke: var(--s6); } .s7 { color: var(--s7); stroke: var(--s7); } .s8 { color: var(--s8); stroke: var(--s8); } .s9 { color: var(--s9); stroke: var(--s9); }
|
||||
|
||||
/* WireGuard */
|
||||
.qr svg { width: 14rem; height: 14rem; background: #fff; padding: .5rem; }
|
||||
pre { overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
|
||||
form.inline { margin-top: 0.5rem; }
|
||||
details { margin: 0.5rem 0; }
|
||||
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// System is how the UI acts on the box. Everything that changes the running
|
||||
// system goes through systemd units (router-apply, router-confirm), so a
|
||||
// switch that restarts the UI cannot cut an apply short.
|
||||
type System struct {
|
||||
ConfigDir string // the configuration flake, settings.json in it
|
||||
StateDir string // /var/lib/router: secrets, UI state
|
||||
// ReleaseFile describes the installed nixos-router; /etc/router-release if empty.
|
||||
ReleaseFile string
|
||||
// VersionFile holds the running NixOS version; /run/current-system/nixos-version if empty.
|
||||
VersionFile string
|
||||
// LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty.
|
||||
LeasesDNSDir string
|
||||
ACMEDir string // where the ACME client keeps the web UI's certificate (fullchain.pem, key.pem), if any
|
||||
}
|
||||
|
||||
func (s System) leasesDNSDir() string {
|
||||
if s.LeasesDNSDir != "" {
|
||||
return s.LeasesDNSDir
|
||||
}
|
||||
return "/run/router-leases-dns"
|
||||
}
|
||||
|
||||
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
|
||||
func (s System) confirmedPath() string { return filepath.Join(s.StateDir, "settings.confirmed.json") }
|
||||
func (s System) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") }
|
||||
func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-password") }
|
||||
|
||||
func command(name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func unitProperty(unit, prop string) string {
|
||||
out, _ := command("systemctl", "show", "--value", "-p", prop, unit)
|
||||
return out
|
||||
}
|
||||
|
||||
// Status is what the overview shows.
|
||||
type Status struct {
|
||||
Current string
|
||||
Confirmed string
|
||||
Applying bool
|
||||
ApplyResult string // "success", "exit-code", ... of the last apply ("" if none)
|
||||
Log string
|
||||
RollbackIn time.Duration // > 0 while a change awaits confirmation
|
||||
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
|
||||
}
|
||||
|
||||
func (s System) Status() Status {
|
||||
st := Status{}
|
||||
st.Current, _ = filepath.EvalSymlinks("/run/current-system")
|
||||
st.Confirmed, _ = filepath.EvalSymlinks("/nix/var/nix/profiles/system")
|
||||
state := unitProperty("router-apply.service", "ActiveState")
|
||||
st.Applying = state == "activating"
|
||||
if inv := unitProperty("router-apply.service", "InvocationID"); inv != "" {
|
||||
st.ApplyResult = unitProperty("router-apply.service", "Result")
|
||||
if st.Applying {
|
||||
st.ApplyResult = ""
|
||||
}
|
||||
st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "200",
|
||||
"_SYSTEMD_INVOCATION_ID="+inv)
|
||||
}
|
||||
st.ConfirmRunning = unitProperty("router-confirm.service", "ActiveState") == "activating"
|
||||
st.RollbackIn = rollbackIn()
|
||||
cur, err1 := os.ReadFile(s.settingsPath())
|
||||
conf, err2 := os.ReadFile(s.confirmedPath())
|
||||
st.Unapplied = err1 == nil && err2 == nil && !sameJSON(cur, conf)
|
||||
if st.Unapplied {
|
||||
st.TokenEdit = s.tokenEditNote(cur)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func sameJSON(a, b []byte) bool {
|
||||
var x, y any
|
||||
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
xa, _ := json.Marshal(x)
|
||||
ya, _ := json.Marshal(y)
|
||||
return bytes.Equal(xa, ya)
|
||||
}
|
||||
|
||||
// rollbackIn is the time left on router-rollback.timer, armed by router-apply.
|
||||
func rollbackIn() time.Duration {
|
||||
out, err := command("systemctl", "list-timers", "--output=json", "router-rollback.timer")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var timers []struct {
|
||||
Next uint64 `json:"next"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
if json.Unmarshal([]byte(out), &timers) != nil {
|
||||
return 0
|
||||
}
|
||||
for _, t := range timers {
|
||||
if t.Unit == "router-rollback.timer" && t.Next > 0 {
|
||||
left := time.Until(time.UnixMicro(int64(t.Next)))
|
||||
if left > 0 {
|
||||
return left
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s System) Apply() error {
|
||||
_, err := command("systemctl", "start", "--no-block", "router-apply.service")
|
||||
return err
|
||||
}
|
||||
|
||||
// Reboot restarts the router a few seconds from now, so the page that asked
|
||||
// for it is still answered.
|
||||
func (s System) Reboot() error {
|
||||
_, err := command("systemd-run", "--quiet", "--on-active=3s", "systemctl", "reboot")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s System) rootPasswordPath() string { return filepath.Join(s.StateDir, "root-password") }
|
||||
|
||||
// setRootPassword is replaced in tests.
|
||||
var setRootPassword = func(s System, password string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "mkpasswd", "-s")
|
||||
cmd.Stdin = strings.NewReader(password + "\n")
|
||||
hash, err := cmd.Output()
|
||||
if err != nil || !strings.HasPrefix(string(hash), "$") {
|
||||
return fmt.Errorf("hashing the password for root: %v", err)
|
||||
}
|
||||
if err := writeFileAtomic(s.rootPasswordPath(), hash, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// A unit does the part that needs more than the web UI may touch: /etc/shadow.
|
||||
if out, err := command("systemctl", "start", "router-root-password.service"); err != nil {
|
||||
return fmt.Errorf("setting root's password: %v %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s System) Confirm() error {
|
||||
_, err := command("systemctl", "start", "--no-block", "router-confirm.service")
|
||||
return err
|
||||
}
|
||||
|
||||
// Discard puts back the settings of the confirmed system.
|
||||
func (s System) Discard() error {
|
||||
data, err := os.ReadFile(s.confirmedPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(s.settingsPath(), data, 0o600)
|
||||
}
|
||||
|
||||
// Interfaces lists the physical-looking network devices, for the forms.
|
||||
func Interfaces() []string {
|
||||
entries, _ := os.ReadDir("/sys/class/net")
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if n == "lo" {
|
||||
continue
|
||||
}
|
||||
// Skip virtual devices (VLANs, PPP, WireGuard): they are not chosen
|
||||
// as a LAN's or the uplink's interface.
|
||||
if _, err := os.Stat(filepath.Join("/sys/class/net", n, "device")); err != nil {
|
||||
continue
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{{define "content"}}
|
||||
<section class="panel">
|
||||
<h2>Settings</h2>
|
||||
<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>
|
||||
<form method="post" action="/advanced">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<textarea name="settings" rows="30" spellcheck="false">{{.Data.Settings}}</textarea>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
|
||||
<section class="panel">
|
||||
<h2>Appearance</h2>
|
||||
<form method="post" action="/prefs" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Theme
|
||||
<select name="theme">
|
||||
<option value="" {{if eq .Data.Theme ""}}selected{{end}}>Automatic (follow the browser or system)</option>
|
||||
<option value="light" {{if eq .Data.Theme "light"}}selected{{end}}>Light</option>
|
||||
<option value="dark" {{if eq .Data.Theme "dark"}}selected{{end}}>Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">Applies to everyone using this web UI, right away.</p>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Dashboard</h2>
|
||||
<form method="post" action="/prefs" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Ping targets <input name="pingTargets" value="{{.Data.PingTargets}}" spellcheck="false"></label>
|
||||
<p class="hint">Up to 5 IP addresses, separated by commas. <code>gateway</code> is the provider's end of the line.
|
||||
Takes effect right away; no apply needed.</p>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Password</h2>
|
||||
<p>One password for this web UI and for root on the console and over SSH.</p>
|
||||
<form method="post" action="/password" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Current password <input type="password" name="current" autocomplete="current-password" required></label>
|
||||
<label>New password (at least 8 characters) <input type="password" name="new" autocomplete="new-password" minlength="8" required></label>
|
||||
<label>New password again <input type="password" name="confirm" autocomplete="new-password" minlength="8" required></label>
|
||||
<button>Change password</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<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>
|
||||
<form method="post" action="/reboot" data-confirm="Reboot the router now?"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Reboot</button></form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Backup</h2>
|
||||
<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
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,47 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}{{$f := .Form}}
|
||||
<section>
|
||||
<h2>Serving now</h2>
|
||||
{{if .Has}}
|
||||
<dl>
|
||||
<dt>Certificate for</dt><dd>{{.Cert.Subject}}</dd>
|
||||
<dt>Issued by</dt><dd>{{.Cert.Issuer}}{{if .Cert.SelfSigned}} <span class="bad">self-signed: browsers warn</span>{{end}}</dd>
|
||||
<dt>Valid until</dt><dd>{{.Cert.NotAfter.Format "2006-01-02"}}</dd>
|
||||
<dt>SHA-256</dt><dd><code>{{.Cert.Fingerprint}}</code></dd>
|
||||
</dl>
|
||||
{{else}}<p>No certificate yet; the web UI creates a self-signed one when it starts.</p>{{end}}
|
||||
</section>
|
||||
<form method="post" action="/certificate">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Let's Encrypt</legend>
|
||||
<label class="inline"><input type="checkbox" name="enabled" {{if $f.Enabled}}checked{{end}}> Get a certificate for the web UI and renew it</label>
|
||||
<div>
|
||||
<label>DNS account <select name="provider"><option value=""></option>
|
||||
{{range .Providers}}<option value="{{.Name}}" {{if eq .Name $f.Provider}}selected{{end}}>{{.Name}} ({{.Type}})</option>{{end}}
|
||||
</select></label>
|
||||
<label>Contact e-mail <input name="email" value="{{$f.Email}}" size="26" spellcheck="false" placeholder="you@example.org"></label>
|
||||
</div>
|
||||
{{range $k := list "provider" "email"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
|
||||
<p class="hint">The account must be able to change the DNS records of the names: Let's Encrypt is shown it by a TXT record, so no port
|
||||
needs to be open to the internet. Accounts are kept on the <a href="/dnsproviders">DNS providers</a> page (cloudflare, desec,
|
||||
digitalocean, duckdns and hetzner work here).{{if not .Providers}} <strong>There is none yet.</strong>{{end}}</p>
|
||||
<label>Names, one per line; the first is the certificate's name <textarea name="domains" rows="4" cols="40" spellcheck="false">{{$f.Domains}}</textarea></label>
|
||||
{{with index $e "domains"}}<p class="field-error">{{.}}</p>{{end}}
|
||||
<p class="hint">A wildcard like <code>*.home.example.org</code> works. The names should lead to the router (see DNS) so the browser reaches it by name.</p>
|
||||
<label class="inline"><input type="checkbox" name="staging" {{if $f.Staging}}checked{{end}}> Test first with the staging service (its certificates are not trusted by browsers); uncheck for the real one</label>
|
||||
<label class="inline"><input type="checkbox" name="agree" {{if $f.Agree}}checked{{end}}> I accept the <a href="https://letsencrypt.org/repository/">Let's Encrypt subscriber agreement</a></label>
|
||||
{{with index $e "agree"}}<p class="field-error">{{.}}</p>{{end}}
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
<details{{if .Failed}} open{{end}}>
|
||||
<summary>Issuing and renewal log{{if .Failed}} (the last run failed){{end}}</summary>
|
||||
{{if .Status}}<p class="muted">{{.Status}}</p>{{if .Log}}<pre class="log">{{.Log}}</pre>{{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}}
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{if .Diff}}
|
||||
<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>
|
||||
{{with .TokenEdit}}<p class="error">{{.}}</p>{{end}}
|
||||
<pre class="log">{{.Diff}}</pre>
|
||||
<div class="actions">
|
||||
<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>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>No changes: the saved settings are the confirmed ones.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,84 @@
|
||||
{{define "dashboard"}}
|
||||
{{$d := .Data.Dashboard}}{{$u := $d.Snap.Uplink}}{{$sys := $d.Snap.System}}
|
||||
<div class="ranges" role="group" aria-label="Time range">
|
||||
{{range $d.Ranges}}<a href="/?range={{.}}" data-range="{{.}}"{{if eq . $d.Range}} class="active" aria-current="true"{{end}}>{{.}}</a>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
{{if $d.Snap.Time.IsZero}}
|
||||
<section class="card"><h2>Internet</h2><p class="state">Collecting…</p></section>
|
||||
{{else}}
|
||||
<section class="card {{if and $u.Connected (or $u.Reachable (not $u.Checked))}}up{{else if $u.Connected}}degraded{{else}}down{{end}}">
|
||||
<h2>Internet</h2>
|
||||
<p class="state">
|
||||
{{if and $u.Connected (or $u.Reachable (not $u.Checked))}}Connected
|
||||
{{else if $u.Connected}}Connected, but no answer from the internet
|
||||
{{else if not $u.Exists}}{{if eq $u.Type "pppoe"}}Not connected: no PPPoE session{{else}}Not connected: {{$u.Device}} is missing{{end}}
|
||||
{{else}}Not connected{{end}}
|
||||
{{if not $u.Since.IsZero}}<span class="muted">for {{ago $u.Since}}</span>{{end}}
|
||||
</p>
|
||||
<dl>
|
||||
<dt>Uplink</dt><dd>{{$u.Device}} ({{if eq $u.Type "pppoe"}}PPPoE{{else}}DHCP{{end}})</dd>
|
||||
{{if $u.IPv4}}<dt>IPv4</dt><dd>{{join $u.IPv4 ", "}}</dd>{{end}}
|
||||
{{if $u.IPv6}}<dt>IPv6</dt><dd>{{join $u.IPv6 ", "}}</dd>{{end}}
|
||||
{{if $u.Gateway}}<dt>Gateway</dt><dd>{{$u.Gateway}}</dd>{{end}}
|
||||
{{range $d.Snap.Pings}}
|
||||
<dt>Ping {{.Target}}</dt><dd>{{if .OK}}{{ms .RTT}}{{else if .Address}}<span class="bad">no answer</span>{{else}}<span class="muted">no address</span>{{end}}{{if and .Address (ne .Address .Target)}} <span class="muted">{{.Address}}</span>{{end}}</dd>
|
||||
{{end}}
|
||||
</dl>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="card">
|
||||
<h2>System</h2>
|
||||
<dl>
|
||||
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
|
||||
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span> <a href="/advanced">Reboot</a>{{end}}</dd>{{end}}
|
||||
{{if $sys.BootPending}}<dt>Reboot</dt><dd><span class="bad">a new configuration starts at the next reboot</span> <a href="/advanced">Reboot</a></dd>{{end}}
|
||||
<dt>Load</dt><dd>{{$sys.Load}}</dd>
|
||||
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
|
||||
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
|
||||
{{if not (isNaN $sys.Temperature)}}<dt>Temperature</dt><dd>{{value $sys.Temperature "°C"}}</dd>{{end}}
|
||||
{{if $sys.DiskTotal}}<dt>Disk</dt><dd>{{bytes $sys.DiskUsed}} of {{bytes $sys.DiskTotal}}</dd>{{end}}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{{if $d.Events}}
|
||||
<section class="card">
|
||||
<h2>Connection log</h2>
|
||||
<ul class="events">
|
||||
{{range $d.Events}}<li><span class="{{if .Connected}}ok{{else}}bad{{end}}">{{if .Connected}}up{{else}}down{{end}}</span> {{.Time.Local.Format "Jan 2 15:04:05"}}{{if .Detail}} <span class="muted">{{.Detail}}</span>{{end}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>DHCP clients</h2>
|
||||
{{if $d.Leases}}
|
||||
<div class="scroll">
|
||||
<table class="leases">
|
||||
<tr><th>Name</th><th>Address</th><th>MAC</th><th>LAN</th><th>Lease ends in</th></tr>
|
||||
{{range $d.Leases}}<tr><td>{{or .Hostname "–"}}</td><td>{{.Address}}</td><td><code>{{.MAC}}</code></td><td>{{.LAN}}</td><td>{{until .Expires}}</td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{else}}<p class="muted">No active leases.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<div class="charts">
|
||||
{{range $d.Charts}}
|
||||
<figure class="chart">
|
||||
<figcaption><strong>{{.Title}}</strong>
|
||||
<span class="legend">{{range .Lines}}<span class="key {{.Class}}">{{.Label}} <b>{{.Now}}</b></span>{{end}}</span>
|
||||
</figcaption>
|
||||
<svg viewBox="-64 -8 690 150" role="img" aria-label="{{.Title}}">
|
||||
{{range .YTicks}}<line class="grid" x1="0" x2="{{chartW}}" y1="{{.Pos}}" y2="{{.Pos}}"/><text class="lab" x="-6" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>{{end}}
|
||||
{{range .XTicks}}<text class="lab" x="{{.Pos}}" y="138" text-anchor="middle">{{.Label}}</text>{{end}}
|
||||
{{range .Lines}}<path class="line {{.Class}}" d="{{.Path}}"/>{{end}}
|
||||
{{if .Empty}}<text class="lab" x="300" y="64" text-anchor="middle">no data yet</text>{{end}}
|
||||
{{range .Cols}}<rect class="hover" x="{{.X}}" y="0" width="{{.W}}" height="{{chartH}}"><title>{{.Tip}}</title></rect>{{end}}
|
||||
</svg>
|
||||
</figure>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}{{$f := .Form}}
|
||||
<p class="hint">Keeps DNS records at your provider pointing at the router's internet addresses: it checks regularly and
|
||||
right away when the address changes (a new PPPoE session, a new lease). Uses
|
||||
<a href="https://ddclient.net/protocols.html">ddclient</a>; one provider account.</p>
|
||||
<form method="post" action="/ddns">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Provider</legend>
|
||||
<label class="inline"><input type="checkbox" name="enabled" {{if $f.Enabled}}checked{{end}}> Keep these names up to date</label>
|
||||
<div>
|
||||
<label>Account <select name="provider"><option value=""></option>
|
||||
{{range .Providers}}<option value="{{.Name}}" {{if eq .Name $f.Provider}}selected{{end}}>{{.Name}} ({{.Type}})</option>{{end}}
|
||||
</select></label>
|
||||
<label>Zone <input name="zone" value="{{$f.Zone}}" size="22" spellcheck="false" placeholder="example.org (Cloudflare)"></label>
|
||||
</div>
|
||||
{{range $k := list "provider" "zone"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
|
||||
<p class="hint">The account (login and token) is kept on the <a href="/dnsproviders">DNS providers</a> page, where certificates use it too.
|
||||
{{if not .Providers}}<strong>There is none yet.</strong>{{end}}</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Names</legend>
|
||||
<label>Host names, one per line <textarea name="domains" rows="6" cols="40" spellcheck="false">{{$f.Domains}}</textarea></label>
|
||||
{{with index $e "domains"}}<p class="field-error">{{.}}</p>{{end}}
|
||||
<div>
|
||||
<label class="inline"><input type="checkbox" name="ipv4" {{if $f.IPv4}}checked{{end}}> IPv4 (A records)</label>
|
||||
<label class="inline"><input type="checkbox" name="ipv6" {{if $f.IPv6}}checked{{end}}> IPv6 (AAAA records)</label>
|
||||
<label class="inline"><input type="checkbox" name="ssl" {{if $f.SSL}}checked{{end}}> HTTPS to the provider</label>
|
||||
<label>Check every <input name="interval" value="{{$f.Interval}}" size="6"></label>
|
||||
</div>
|
||||
{{range $k := list "ipv4" "interval"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
<section>
|
||||
<h2>Last updates</h2>
|
||||
{{if .Status.Log}}<p class="muted">ddclient: {{or .Status.State "unknown"}}{{if .Status.Last}}, last run {{.Status.Last}}{{end}}</p>
|
||||
<pre class="log">{{.Status.Log}}</pre>
|
||||
{{else}}<p class="muted">Nothing yet. Once applied, the log of ddclient shows up here.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<form method="post" action="/dhcp">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Reservations</legend>
|
||||
{{if not .LANs}}<p class="field-error">No LAN serves DHCP; enable it on the Network page first.</p>{{end}}
|
||||
<input type="hidden" name="rows" value="{{len .Rows}}">
|
||||
<table>
|
||||
<tr><th>LAN</th><th>Name</th><th>MAC</th><th>Address</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Rows}}
|
||||
<tr>
|
||||
<td><select name="res.{{$i}}.lan">
|
||||
{{range $.Data.LANs}}<option value="{{.}}" {{if eq . $r.LAN}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="res.{{$i}}.name" value="{{$r.Name}}" size="14" placeholder="new"></td>
|
||||
<td><input name="res.{{$i}}.mac" value="{{$r.MAC}}" size="17" spellcheck="false" placeholder="aa:bb:cc:dd:ee:ff"></td>
|
||||
<td><input name="res.{{$i}}.address" value="{{$r.Address}}" size="13"></td>
|
||||
<td>{{if $r.Name}}<input type="checkbox" name="res.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "lan" "name" "mac" "address"}}{{with index $e (printf "res[%d].%s" $i $f)}}<tr><td colspan="5" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Fill in the last row to add one. The name is also sent to the client and published in DNS.
|
||||
The address should be outside the pool.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
<section>
|
||||
<h2>Clients without a reservation</h2>
|
||||
{{if .Clients}}
|
||||
<div class="scroll">
|
||||
<table class="leases">
|
||||
<tr><th>Name</th><th>Address</th><th>MAC</th><th>LAN</th><th></th></tr>
|
||||
{{range .Clients}}<tr><td>{{or .Hostname "–"}}</td><td>{{.Address}}</td><td><code>{{.MAC}}</code></td><td>{{.LAN}}</td>
|
||||
<td><a href="/dhcp?lan={{.LAN | urlquery}}&name={{.Hostname | urlquery}}&mac={{.MAC | urlquery}}&address={{.Address | urlquery}}">Reserve</a></td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{else}}<p class="muted">No active leases.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,88 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<form method="post" action="/dns">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Records</legend>
|
||||
<input type="hidden" name="recs" value="{{len .Records}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Addresses</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Records}}
|
||||
<tr>
|
||||
<td><input name="rec.{{$i}}.name" value="{{$r.Name}}" size="30" spellcheck="false" placeholder="nas or *.k8s.example.org"></td>
|
||||
<td><input name="rec.{{$i}}.addresses" value="{{$r.Addresses}}" size="40" spellcheck="false" placeholder="192.168.10.20, fd00::20"></td>
|
||||
<td>{{if $r.Name}}<input type="checkbox" name="rec.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "addresses"}}{{with index $e (printf "rec[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">An IPv4 address becomes an A record, an IPv6 address an AAAA record; several addresses give several records.
|
||||
A name without a dot lives in the local domain{{if .Domain}} (<code>{{.Domain}}</code>){{else}} (none is set: do that on the Network page){{end}}.
|
||||
A full name such as <code>photos.example.org</code> overrides public DNS for just that name.</p>
|
||||
<p class="hint"><code>*.k8s.example.org</code> is a wildcard: <code>k8s.example.org</code> itself and every name below it
|
||||
(<code>a.k8s.example.org</code>, <code>x.y.k8s.example.org</code>) answer with these addresses.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Forward zones</legend>
|
||||
<input type="hidden" name="zones" value="{{len .Zones}}">
|
||||
<table>
|
||||
<tr><th>Domain</th><th>Resolvers</th><th>Remove</th></tr>
|
||||
{{range $i, $z := .Zones}}
|
||||
<tr>
|
||||
<td><input name="zone.{{$i}}.name" value="{{$z.Name}}" size="30" spellcheck="false" placeholder="branch.example.org"></td>
|
||||
<td><input name="zone.{{$i}}.addresses" value="{{$z.Addresses}}" size="30" spellcheck="false" placeholder="10.1.0.254"></td>
|
||||
<td>{{if $z.Name}}<input type="checkbox" name="zone.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "addresses"}}{{with index $e (printf "zone[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Names below these domains are asked from these resolvers (for example across a tunnel), not from the internet.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>DHCP clients</legend>
|
||||
<label><input type="checkbox" name="dynamic" {{if .Dynamic}}checked{{end}}> Publish the names of DHCP clients as <code>name.{{if .Domain}}{{.Domain}}{{else}}domain{{end}}</code></label>
|
||||
{{if and .Dynamic (not .Domain)}}<p class="field-error">This needs a local domain (Network page).</p>{{end}}
|
||||
<p class="hint">A client chooses its own name, so only its first label is used, only in your local domain, and a name
|
||||
that is configured here (a record above, a reservation, the router itself, a wildcard) is never taken over.
|
||||
Of two clients with one name the newer lease wins. Takes effect when applied.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
{{if .Dyn}}
|
||||
<section>
|
||||
<h2>DHCP clients in DNS</h2>
|
||||
{{if .Dyn.Error}}<p class="field-error">The last update failed: {{.Dyn.Error}}</p>{{end}}
|
||||
{{if .Dyn.Published}}
|
||||
<table>
|
||||
<tr><th>Name</th><th>Address</th></tr>
|
||||
{{range .Dyn.Published}}<tr><td>{{.Name}}</td><td>{{.Address}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{else}}<p class="muted">No client names are published at the moment.</p>{{end}}
|
||||
{{if .Dyn.Skipped}}
|
||||
<p class="hint">Not published:</p>
|
||||
<table>
|
||||
<tr><th>Client name</th><th>Address</th><th>Why</th></tr>
|
||||
{{range .Dyn.Skipped}}<tr><td>{{.Hostname}}</td><td>{{.Address}}</td><td>{{.Reason}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
<p class="hint">As of {{ago .Dyn.Updated}} ago, from the running configuration.</p>
|
||||
</section>
|
||||
{{else if .Dynamic}}
|
||||
<p class="hint">Client names are not published yet: apply the settings so the service starts.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Auto}}
|
||||
<section>
|
||||
<h2>Published automatically</h2>
|
||||
<ul>{{range .Auto}}<li>{{.}}</li>{{end}}</ul>
|
||||
<p class="hint">The router's own name and the DHCP reservations (edit those on the DHCP page).</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,36 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<p class="hint">Accounts at DNS providers, kept once and used by <a href="/ddns">Dynamic DNS</a> (the providers ddclient knows) and by the
|
||||
<a href="/certificate">certificate</a> (the providers lego knows). Secrets stay on the router in private files, not in the settings,
|
||||
and are never shown again.<br>
|
||||
<strong>Password or API token</strong>: for dynamic DNS, and for certificates through cloudflare, desec, digitalocean, duckdns and hetzner.<br>
|
||||
<strong>Certificate variables</strong>: for certificates through any other provider, the variables lego asks for, one
|
||||
<code>VARIABLE=value</code> per line (see <a href="https://go-acme.github.io/lego/dns/">the list of providers</a>), for example
|
||||
<code>AWS_ACCESS_KEY_ID=…</code> and <code>AWS_SECRET_ACCESS_KEY=…</code> for route53.</p>
|
||||
<form method="post" action="/dnsproviders">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="providers" value="{{len .Rows}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Provider</th><th>Login</th><th>Server</th><th>Password or API token</th><th>Certificate variables</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Rows}}
|
||||
<tr>
|
||||
<td>{{if $r.New}}<input name="prov.{{$i}}.name" size="14" spellcheck="false" placeholder="cloudflare"><input type="hidden" name="prov.{{$i}}.new" value="1">
|
||||
{{else}}{{$r.Name}}<input type="hidden" name="prov.{{$i}}.name" value="{{$r.Name}}">{{end}}</td>
|
||||
<td><select name="prov.{{$i}}.type">{{range $r.Options}}<option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
|
||||
<td><input name="prov.{{$i}}.username" value="{{$r.Username}}" size="12" spellcheck="false" autocomplete="off"></td>
|
||||
<td><input name="prov.{{$i}}.server" value="{{$r.Server}}" size="18" spellcheck="false" placeholder="only dyndns2"></td>
|
||||
<td><input type="password" name="prov.{{$i}}.secret" size="24" autocomplete="new-password" placeholder="{{if $r.SecretSet}}stored; leave empty to keep{{else if $r.New}}required{{else}}missing{{end}}"></td>
|
||||
<td><textarea name="prov.{{$i}}.env" rows="2" cols="26" spellcheck="false" autocomplete="off" placeholder="{{if $r.EnvSet}}stored; leave empty to keep{{else}}VARIABLE=value, one per line{{end}}"></textarea></td>
|
||||
<td>{{if not $r.New}}<input type="checkbox" name="prov.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "type" "username" "server" "secret" "env"}}{{with index $e (printf "prov[%d].%s" $i $f)}}<tr><td colspan="7" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">Login is <code>token</code> for a Cloudflare API token (permission Zone → DNS → Edit). Fill in the last row to add an account.</p>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,127 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<p class="hint">Every LAN and tunnel belongs to a zone. Traffic inside a zone is allowed; between zones it is denied
|
||||
unless a rule allows it. A rule works in one direction (replies are always allowed), so letting two zones talk
|
||||
to each other takes two rules.</p>
|
||||
<form method="post" action="/firewall">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Zones</legend>
|
||||
<input type="hidden" name="members" value="{{len .Members}}">
|
||||
<table>
|
||||
<tr><th>Interface</th><th>Device</th><th>Zone</th></tr>
|
||||
{{range $i, $m := .Members}}
|
||||
<tr>
|
||||
<td>{{if eq $m.Kind "lan"}}LAN{{else}}Tunnel{{end}} <strong>{{$m.Name}}</strong>
|
||||
<input type="hidden" name="member.{{$i}}.kind" value="{{$m.Kind}}">
|
||||
<input type="hidden" name="member.{{$i}}.name" value="{{$m.Name}}">
|
||||
<input type="hidden" name="member.{{$i}}.device" value="{{$m.Device}}"></td>
|
||||
<td><code>{{$m.Device}}</code></td>
|
||||
<td><select name="member.{{$i}}.zone">
|
||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $m.Zone}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
</tr>
|
||||
{{with index $e (printf "member[%d]" $i)}}<tr><td colspan="3" class="field-error">{{.}}</td></tr>{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Several interfaces can share a zone. The uplink is the zone <code>wan</code>; rules from it publish services.</p>
|
||||
{{range .Empty}}
|
||||
<p>Zone <strong>{{.}}</strong> has no interface. <label class="inline"><input type="checkbox" name="rmzone" value="{{.}}"> remove</label>
|
||||
{{with index $e (printf "zone.%s" .)}}<span class="field-error">{{.}}</span>{{end}}</p>
|
||||
{{end}}
|
||||
<label>New zones <input name="newzones" value="{{.New}}" size="24" placeholder="guests, iot"></label>
|
||||
{{with index $e "newzone"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<p class="hint">A new zone can be picked above (and in the rules) after saving.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Rules</legend>
|
||||
<input type="hidden" name="rows" value="{{len .Rows}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th title="only connections that a port forward translated">Forwarded only</th><th>Log</th><th>Rate limit</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Rows}}
|
||||
<tr>
|
||||
<td><select name="rule.{{$i}}.from"><option value=""></option>
|
||||
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td>
|
||||
<td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td>
|
||||
<td><select name="rule.{{$i}}.to"><option value=""></option>
|
||||
{{range $.Data.ToZones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="rule.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="whole zone"></td>
|
||||
<td><input name="rule.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="all"></td>
|
||||
<td><select name="rule.{{$i}}.protocol">
|
||||
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input type="checkbox" name="rule.{{$i}}.natonly" {{if $r.NATOnly}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="rule.{{$i}}.log" {{if $r.Log}}checked{{end}}></td>
|
||||
<td><input name="rule.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td>
|
||||
<td>{{if or $r.From $r.To}}<input type="checkbox" name="rule.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol" "ratelimit"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR;
|
||||
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.
|
||||
From <code>wan</code> (the internet) a rule must name the address inside, the protocol and the ports, and only lets
|
||||
traffic through; a port forward is set up below. <strong>Forwarded only</strong> limits a rule to connections that a
|
||||
port forward translated. To zone <code>local</code> is the router itself (SSH, the web UI): no address needed, but protocol and ports are. A port forward on the same port wins. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Address translation (NAT)</legend>
|
||||
<input type="hidden" name="nats" value="{{len .NATs}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Type</th><th>From zone</th><th>From address</th><th>Sent to</th><th>Protocol</th><th>Port</th><th>To zone</th><th>Translate to</th><th>To port</th><th>Hairpin</th><th>Also allow</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .NATs}}
|
||||
<tr>
|
||||
<td><select name="nat.{{$i}}.type">
|
||||
{{range $p := list "dnat" "snat" "masquerade"}}<option value="{{$p}}" {{if eq $p $r.Type}}selected{{end}}>{{if eq $p "dnat"}}port forward{{else if eq $p "snat"}}source NAT{{else}}masquerade{{end}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><select name="nat.{{$i}}.from"><option value="">any</option>
|
||||
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="nat.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="any"></td>
|
||||
<td><input name="nat.{{$i}}.destination" value="{{$r.Destination}}" size="14" spellcheck="false" placeholder="the router"></td>
|
||||
<td><select name="nat.{{$i}}.protocol">
|
||||
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="nat.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
|
||||
<td><select name="nat.{{$i}}.to"><option value=""></option>
|
||||
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="nat.{{$i}}.toaddress" value="{{$r.ToAddress}}" size="14" spellcheck="false" placeholder="192.168.10.5"></td>
|
||||
<td><input name="nat.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td>
|
||||
<td><input type="checkbox" name="nat.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td>
|
||||
<td>{{if or $r.ToAddress $r.Ports}}{{if $r.Allow}}<input type="checkbox" name="nat.{{$i}}.allow" checked>{{else}}<input type="checkbox" name="nat.{{$i}}.allow">{{end}}{{end}}</td>
|
||||
<td>{{if or $r.ToAddress $r.Ports $r.To}}<input type="checkbox" name="nat.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "type" "from" "sources" "destination" "protocol" "ports" "to" "toaddress" "toport" "hairpin"}}{{with index $e (printf "nat[%d].%s" $i $f)}}<tr><td colspan="12" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint"><strong>Port forward:</strong> traffic from the zone (<code>wan</code> is the internet) addressed to the router
|
||||
(or to the address in <em>Sent to</em>) on these ports goes to the host in <em>Translate to</em>, in zone <em>To zone</em>;
|
||||
the sender's address is kept. <em>Also allow</em> adds the filter rule that lets it through (without it the firewall still
|
||||
blocks it). <em>Hairpin</em> lets your LANs use a forward from the internet through the router's public address.<br>
|
||||
<strong>Masquerade</strong> and <strong>source NAT</strong> rewrite where traffic from the zone (or any zone) leaves through
|
||||
<em>To zone</em>: masquerade with the outgoing interface's address, source NAT with the address in <em>Translate to</em>.
|
||||
Internet-enabled LANs are already masqueraded out the uplink. Fill in the last row to add one.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
{{if .Other}}
|
||||
<section>
|
||||
<h2>Other rules</h2>
|
||||
<p class="muted">Rules with options this page does not edit are kept as they are; change them in <code>settings.json</code>.</p>
|
||||
<ul>{{range .Other}}<li>{{.}}</li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en"{{with .Theme}} data-theme="{{.}}"{{end}}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{{if .Refresh}}<meta http-equiv="refresh" content="{{.Refresh}}">{{end}}
|
||||
<title>{{.Title}} · router</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body{{if not .LoggedIn}} class="bare"{{end}}>
|
||||
<header>
|
||||
<strong>router</strong>
|
||||
{{if .LoggedIn}}
|
||||
<input type="checkbox" id="menu" class="menu-toggle" aria-label="Menu">
|
||||
<label for="menu" class="menu-button">Menu</label>
|
||||
<nav>
|
||||
<a href="/"{{if eq .Title "Overview"}} aria-current="page"{{end}}>Overview</a>
|
||||
<div class="group"><span>Network</span>
|
||||
<a href="/network"{{if eq .Title "Network"}} aria-current="page"{{end}}>Interfaces</a>
|
||||
<a href="/dhcp"{{if eq .Title "DHCP"}} aria-current="page"{{end}}>DHCP</a>
|
||||
<a href="/dns"{{if eq .Title "DNS"}} aria-current="page"{{end}}>DNS</a>
|
||||
<a href="/wireguard"{{if eq .Title "WireGuard"}} aria-current="page"{{end}}>WireGuard</a>
|
||||
<a href="/firewall"{{if eq .Title "Firewall"}} aria-current="page"{{end}}>Firewall</a>
|
||||
</div>
|
||||
<div class="group"><span>Services</span>
|
||||
<a href="/ddns"{{if eq .Title "Dynamic DNS"}} aria-current="page"{{end}}>Dynamic DNS</a>
|
||||
<a href="/dnsproviders"{{if eq .Title "DNS providers"}} aria-current="page"{{end}}>DNS providers</a>
|
||||
<a href="/certificate"{{if eq .Title "Certificate"}} aria-current="page"{{end}}>Certificate</a>
|
||||
</div>
|
||||
<div class="group"><span>System</span>
|
||||
<a href="/update"{{if eq .Title "Update"}} aria-current="page"{{end}}>Update</a>
|
||||
<a href="/upgrade"{{if eq .Title "Packages"}} aria-current="page"{{end}}>Packages</a>
|
||||
<a href="/tokens"{{if eq .Title "API access"}} aria-current="page"{{end}}>API access</a>
|
||||
<a href="/advanced"{{if eq .Title "Advanced"}} aria-current="page"{{end}}>Advanced</a>
|
||||
</div>
|
||||
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
||||
</nav>
|
||||
{{end}}
|
||||
</header>
|
||||
<main>
|
||||
<h1>{{.Title}}</h1>
|
||||
{{if .Flash}}<p class="flash">{{.Flash}}</p>{{end}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
<script src="/static/menu.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
{{define "content"}}
|
||||
<form method="post" action="/login" class="narrow">
|
||||
<label>Password <input type="password" name="password" autocomplete="current-password" autofocus required></label>
|
||||
<button>Log in</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,118 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<fieldset>
|
||||
<legend>Interfaces</legend>
|
||||
<form method="post" action="/interfaces">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="rows" value="{{len .Devices}}">
|
||||
<button class="default" tabindex="-1" aria-hidden="true">Save names</button>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Interface</th><th>MAC address</th><th title="The name Linux gives it: the web UI, the firewall and the logs use it">Name</th><th>Kind</th><th>Used as</th><th></th></tr>
|
||||
{{range $i, $d := .Devices}}<tr>
|
||||
<td>{{if $d.Kernel}}{{$d.Kernel}}{{else}}{{$d.Name}}{{end}}</td>
|
||||
<td>{{if $d.MAC}}<code>{{$d.MAC}}</code>{{end}}</td>
|
||||
<td>
|
||||
<input type="hidden" name="dev.{{$i}}" value="{{$d.Name}}">
|
||||
<input type="hidden" name="mac.{{$i}}" value="{{$d.MAC}}">
|
||||
{{if eq $d.Kind "VLAN"}}
|
||||
<input type="hidden" name="from.{{$i}}" value="{{$d.Custom}}">
|
||||
<input name="name.{{$i}}" value="{{$d.Custom}}" size="12" placeholder="{{$d.Name}}" spellcheck="false" {{if not $d.Role}}disabled{{end}}>
|
||||
{{else}}
|
||||
<input type="hidden" name="from.{{$i}}" value="{{$d.Name}}">
|
||||
<input name="name.{{$i}}" value="{{$d.Name}}" size="12" spellcheck="false" {{if not $d.MAC}}disabled{{end}}>
|
||||
{{if and $d.Kernel (ne $d.Kernel $d.Name)}}<span class="hint">after the next reboot</span>{{end}}
|
||||
{{end}}
|
||||
{{with index $e (printf "name[%d]" $i)}}<span class="field-error">{{.}}</span>{{end}}
|
||||
</td>
|
||||
<td>{{$d.Kind}}</td><td>{{if $d.Role}}{{$d.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
|
||||
<td>{{if $d.Removable}}<button formaction="/vlan" name="remove" value="{{$d.Name}}" class="link">remove</button>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">A name is what Linux calls the interface: <code>wan</code> or <code>lan</code> instead of <code>enp2s0</code>,
|
||||
<code>dmz</code> instead of <code>lan.1002</code>. Cards are found by their MAC address. Linux cannot rename an
|
||||
interface that is in use: after you save and apply, the router starts using the new names at its next reboot.
|
||||
Clear a name to go back to the default.</p>
|
||||
<button>Save names</button>
|
||||
</form>
|
||||
<form method="post" action="/vlan" class="sub">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<p class="hint">Add a VLAN (a tagged network on one of the interfaces); it is then offered below.</p>
|
||||
<label>On <select name="parent">{{range .Devices}}{{if eq .Kind "physical"}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}</select></label>
|
||||
<label>VLAN ID <input name="id" size="5" inputmode="numeric"></label>
|
||||
<button name="add" value="1">Add VLAN</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<form method="post" action="/network">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
|
||||
<fieldset>
|
||||
<legend>System</legend>
|
||||
<label>Host name <input name="hostName" value="{{.Form.HostName}}"></label>
|
||||
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Local domain <input name="domain" value="{{.Form.Domain}}" placeholder="home.example.org" spellcheck="false"></label>
|
||||
{{with index $e "domain"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<p class="hint">Optional. The router answers for it, publishes DHCP reservations in it, and clients get it as
|
||||
their search domain, so <code>nas</code> finds <code>nas.home.example.org</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Uplink (WAN)</legend>
|
||||
<label>Interface
|
||||
<select name="wan.interface">{{$wan := .Form.WAN.Interface}}
|
||||
{{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{devLabel $.Data.Devices .}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Type
|
||||
<select name="wan.type">
|
||||
<option value="dhcp" {{if eq .Form.WAN.Type "dhcp"}}selected{{end}}>DHCP</option>
|
||||
<option value="pppoe" {{if eq .Form.WAN.Type "pppoe"}}selected{{end}}>PPPoE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" name="wan.ipv6" {{if .Form.WAN.IPv6}}checked{{end}}> IPv6 (prefix delegation from the provider; also tick it on the LANs below)</label>
|
||||
<div class="sub">
|
||||
<p class="hint">PPPoE only:</p>
|
||||
<label>Username <input name="wan.pppoe.username" value="{{.Form.WAN.PPPoEUser}}" autocomplete="off"></label>
|
||||
{{with index $e "wan.pppoe.username"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Password <input type="password" name="wan.pppoe.password" autocomplete="new-password"
|
||||
placeholder="{{if .Form.WAN.PasswordSet}}stored — leave empty to keep{{end}}"></label>
|
||||
{{with index $e "wan.pppoe.password"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>LANs</legend>
|
||||
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
|
||||
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Interface</th><th>Address</th><th>Prefix</th><th>Internet access</th><th>IPv6</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
|
||||
{{range $i, $l := .Form.LANs}}
|
||||
<tr>
|
||||
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
|
||||
<td><select name="lan.{{$i}}.interface">{{$cur := $l.Interface}}
|
||||
{{if not $cur}}<option value="" selected></option>{{end}}
|
||||
{{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{devLabel $.Data.Devices .}}</option>{{end}}
|
||||
</select></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 type="checkbox" name="lan.{{$i}}.internet" {{if $l.Internet}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="lan.{{$i}}.ipv6pd" {{if $l.IPv6PD}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="lan.{{$i}}.dhcp" {{if $l.DHCP}}checked{{end}}></td>
|
||||
<td><input name="lan.{{$i}}.poolStart" value="{{$l.PoolStart}}" size="13"></td>
|
||||
<td><input name="lan.{{$i}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
|
||||
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "interface" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="10" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
|
||||
</fieldset>
|
||||
|
||||
<button>Save</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,50 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{$s := .Data.Status}}
|
||||
{{if $s.Applying}}
|
||||
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
|
||||
{{else if $s.ConfirmRunning}}
|
||||
<section class="busy"><h2>Confirming…</h2></section>
|
||||
{{else if gt (seconds $s.RollbackIn) 0}}
|
||||
<section class="pending">
|
||||
<h2>Waiting for confirmation</h2>
|
||||
<p>The new configuration is active. Unless you confirm it within <strong>{{duration $s.RollbackIn}}</strong>,
|
||||
the router goes back to the confirmed system on its own.</p>
|
||||
<form method="post" action="/confirm"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Confirm</button></form>
|
||||
</section>
|
||||
{{else if $s.Unapplied}}
|
||||
<section class="pending">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div id="dashboard" data-range="{{.Data.Dashboard.Range}}" data-interval="{{.Data.Dashboard.Interval}}">
|
||||
{{template "dashboard" .}}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Configuration</h2>
|
||||
{{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}<p>The settings match the confirmed system.</p>{{end}}
|
||||
<dl>
|
||||
<dt>Running system</dt><dd><code>{{short $s.Current}}</code></dd>
|
||||
<dt>Confirmed system</dt><dd><code>{{short $s.Confirmed}}</code></dd>
|
||||
</dl>
|
||||
{{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}
|
||||
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Rebuild and apply</button></form>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if $s.Log}}
|
||||
<section>
|
||||
<h2>Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}</h2>
|
||||
<pre class="log">{{$s.Log}}</pre>
|
||||
</section>
|
||||
{{end}}
|
||||
<script src="/static/dashboard.js" defer></script>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,73 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<section>
|
||||
<p>An assistant or a script can read this router's state and logs and edit its settings through an
|
||||
<a href="https://modelcontextprotocol.io">MCP</a> endpoint at <code>{{.URL}}</code> and, with the same features, a
|
||||
REST API described by <a href="/api/openapi.json">OpenAPI</a> (<code>/api/v1/…</code>, see the <a href="/apireference">API reference</a>). It needs a token, sent as
|
||||
<code>Authorization: Bearer …</code>. A <strong>read</strong> token reads state and settings; a <strong>change</strong>
|
||||
token can also edit the settings. Edits are saved but never applied: you review them under
|
||||
<a href="/changes">Changes</a> and apply them yourself. Secrets are never sent, and every call is logged below.</p>
|
||||
</section>
|
||||
|
||||
{{if .Secret}}
|
||||
<section class="pending">
|
||||
<p>Token for <strong>{{.Name}}</strong>:</p>
|
||||
<p><code>{{.Secret}}</code></p>
|
||||
<p class="hint">For Claude Code: <code>claude mcp add --transport http router {{.URL}} --header "Authorization: Bearer {{.Secret}}"</code>.
|
||||
The web UI's certificate is self-signed unless you set up a <a href="/certificate">certificate</a>: the client must trust it.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<fieldset>
|
||||
<legend>Tokens</legend>
|
||||
{{if .Tokens}}
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<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>
|
||||
<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><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>
|
||||
</div>
|
||||
{{else}}<p class="hint">No tokens yet.</p>{{end}}
|
||||
</fieldset>
|
||||
|
||||
<form method="post" action="/tokens">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>New token</legend>
|
||||
<label>Name <input name="name" size="20" placeholder="claude on my laptop" required></label>
|
||||
<label>Access
|
||||
<select name="scope">
|
||||
<option value="read">read: state and settings</option>
|
||||
<option value="change">change: also edit the settings</option>
|
||||
</select>
|
||||
</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>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>Recent calls</legend>
|
||||
{{if .Audit}}
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Time</th><th>Token</th><th>Tool</th><th>Arguments</th><th>Result</th></tr>
|
||||
{{range .Audit}}<tr><td>{{.Time.Format "2006-01-02 15:04:05"}}</td><td>{{.Token}}</td><td><code>{{.Tool}}</code></td>
|
||||
<td><code>{{.Args}}</code></td><td>{{if .Error}}<span class="bad">{{.Error}}</span>{{else}}ok{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{else}}<p class="hint">Nothing yet.</p>{{end}}
|
||||
</fieldset>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{$d := .Data}}
|
||||
<section>
|
||||
<h2>Installed</h2>
|
||||
<dl>
|
||||
<dt>Version</dt><dd>{{or $d.Installed.Version "unknown"}}{{if $d.Installed.Rev}} <span class="muted">{{$d.Installed.Rev}}</span>{{end}}</dd>
|
||||
<dt>Settings schema</dt><dd>{{$d.Installed.Schema}}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{{if $d.Status.Running}}
|
||||
<section class="busy"><h2>Updating…</h2><p>Verifying, importing and building the new version. This page refreshes on its own;
|
||||
the web UI restarts when the new version is switched on.</p></section>
|
||||
{{else if $d.Pending}}
|
||||
<section class="pending"><h2>Waiting for confirmation</h2>
|
||||
<p>A new configuration is active and not confirmed yet. Confirm it on the <a href="/">Overview</a>, or the router
|
||||
goes back to the previous version.</p></section>
|
||||
{{end}}
|
||||
|
||||
<section>
|
||||
<h2>Install a bundle</h2>
|
||||
<p>A <code>.bundle</code> file made with <code>nix run .#release</code>. It is installed only if it is signed with
|
||||
this router's update key. The settings are kept (and migrated if the new version needs it); the new version
|
||||
must be confirmed afterwards, or the router goes back.</p>
|
||||
<form method="post" action="/update/upload?csrf={{.CSRF}}" enctype="multipart/form-data">
|
||||
<p><label><input type="checkbox" name="force"> also install a version that is not newer</label></p>
|
||||
<p><input type="file" name="bundle" required> <button>Upload and install</button></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Update server</h2>
|
||||
<form method="post" action="/update/url" class="actions">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Address <input name="url" value="{{$d.URL}}" size="40" placeholder="https://updates.example.com/router"></label>
|
||||
<button class="secondary">Save</button>
|
||||
</form>
|
||||
{{if $d.URL}}
|
||||
<form method="post" action="/update/check" class="actions">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="secondary">Check for updates</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if $d.OfferErr}}<p class="error">Could not check: {{$d.OfferErr}}</p>{{end}}
|
||||
{{with $d.Offer}}
|
||||
<p>{{if .Newer}}Available: <strong>{{.Version}}</strong>{{if .Rev}} <span class="muted">{{.Rev}}</span>{{end}}.{{else}}Nothing newer: the server has {{.Version}}.{{end}}</p>
|
||||
<form method="post" action="/update/install">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
{{if not .Newer}}<p><label><input type="checkbox" name="force"> install it anyway</label></p>{{end}}
|
||||
<button>{{if .Newer}}Install {{.Version}}{{else}}Install{{end}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<p class="hint">The server only has to serve static files: the <code>latest</code> file and <code>cache/</code> that
|
||||
<code>nix run .#release</code> writes. The data is signed, so plain HTTP works too.</p>
|
||||
</section>
|
||||
|
||||
{{if $d.Status.Log}}
|
||||
<section>
|
||||
<h2>Last update{{if $d.Status.Result}} — {{if eq $d.Status.Result "success"}}succeeded{{else}}failed ({{$d.Status.Result}}){{end}}{{end}}</h2>
|
||||
<pre class="log">{{$d.Status.Log}}</pre>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,101 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{$d := .Data}}
|
||||
<section>
|
||||
<h2>Packages</h2>
|
||||
<p>Upgrades the kernel and all programs (NixOS) without a new release of nixos-router. Nothing is downloaded until
|
||||
you ask for it, and an upgrade is activated like a settings change: unless you confirm it in time, the router
|
||||
goes back to the previous system.</p>
|
||||
<dl>
|
||||
<dt>Running</dt><dd>{{or $d.Version "unknown"}}</dd>
|
||||
</dl>
|
||||
<form method="post" action="/upgrade/channel" class="actions">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Follow
|
||||
<select name="channel">
|
||||
{{range $d.Choices}}<option value="{{.Value}}"{{if eq .Value $d.Channel}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<button class="secondary">Save</button>
|
||||
</form>
|
||||
<p class="hint">The channel only chooses what an upgrade offers; saving it changes nothing on the router. Going back to an older release
|
||||
(unstable to 26.05) is refused unless you tick "downgrade anyway" when previewing; state written by the newer
|
||||
release may not work with the older one.</p>
|
||||
</section>
|
||||
|
||||
{{if $d.Updating}}
|
||||
<section class="busy"><h2>Updating…</h2><p>The router is installing a new version of nixos-router.</p></section>
|
||||
{{else if $d.Run.Running}}
|
||||
<section class="busy"><h2>Working…</h2><p>This page refreshes on its own. The web UI restarts when an upgrade is switched on.</p></section>
|
||||
{{else if $d.Pending}}
|
||||
<section class="pending"><h2>Waiting for confirmation</h2>
|
||||
<p>A new system is active and not confirmed yet. Confirm it on the <a href="/">Overview</a>, or the router goes back to the
|
||||
previous one.</p></section>
|
||||
{{end}}
|
||||
|
||||
{{if $d.Channel}}
|
||||
<section>
|
||||
<h2>Check</h2>
|
||||
<form method="post" action="/upgrade/check" class="actions">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="secondary"{{if or $d.Run.Running $d.Updating}} disabled{{end}}>Check for upgrades</button>
|
||||
</form>
|
||||
{{with $d.Check}}
|
||||
<dl>
|
||||
<dt>Channel</dt><dd>{{.Branch}}</dd>
|
||||
<dt>Running</dt><dd>{{.Running.Version}}</dd>
|
||||
<dt>Newest</dt><dd>{{printf "%.7s" .Newest.Rev}} <span class="muted">checked {{.Time}}</span></dd>
|
||||
</dl>
|
||||
{{if .UpToDate}}<p>Up to date.</p>{{else}}<p>A newer revision is available.</p>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What would change</h2>
|
||||
<form method="post" action="/upgrade/preview" class="actions">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
{{if and $d.Check $d.Check.UpToDate}}<label><input type="checkbox" name="force"> look anyway</label>{{end}}
|
||||
<label><input type="checkbox" name="downgrade"> downgrade anyway</label>
|
||||
<button class="secondary"{{if or $d.Run.Running $d.Updating}} disabled{{end}}>Preview</button>
|
||||
</form>
|
||||
<p class="hint">Works it out from the new revision's description: no package is downloaded.</p>
|
||||
{{with $d.Preview}}
|
||||
<p><strong>{{.Version}}</strong> <span class="muted">{{printf "%.7s" .Rev}}, previewed {{.Time}}</span></p>
|
||||
{{if .Changes}}
|
||||
<table>
|
||||
<thead><tr><th>Package</th><th>Now</th><th></th><th>New</th></tr></thead>
|
||||
<tbody>{{range .Changes}}<tr><td>{{.Name}}</td><td>{{.From}}</td><td>→</td><td>{{.To}}</td></tr>{{end}}</tbody>
|
||||
</table>
|
||||
{{else}}<p>No package changes.</p>{{end}}
|
||||
<p class="hint">The list shows the versions of programs the router runs; a program that is only rebuilt does not appear.</p>
|
||||
<dl>
|
||||
<dt>To download</dt><dd>{{or .Download "nothing, or the package cache cannot be reached"}}</dd>
|
||||
<dt>Built here</dt><dd>{{.Builds}} derivation{{if ne .Builds 1}}s{{end}}{{if gt .Builds 50}} <span class="muted">(many: the package cache may not have them yet; this takes long)</span>{{end}}</dd>
|
||||
{{if .RebootNeeded}}<dt>Kernel</dt><dd>A new kernel: it runs after the next reboot.</dd>{{end}}
|
||||
</dl>
|
||||
<div class="actions">
|
||||
<form method="post" action="/upgrade/download">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="secondary"{{if or $d.Run.Running $d.Updating $d.Pending}} disabled{{end}}>Download now</button>
|
||||
</form>
|
||||
<form method="post" action="/upgrade/apply">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button{{if or $d.Run.Running $d.Updating $d.Pending}} disabled{{end}}>Upgrade to {{.Version}}</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="hint">Downloading first lets you start the upgrade later without needing the internet. Upgrading
|
||||
downloads what is missing, builds the system and switches to it; confirm it on the Overview.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{else}}
|
||||
<section><p>Choose a channel to look for upgrades.</p></section>
|
||||
{{end}}
|
||||
|
||||
{{if $d.Run.Log}}
|
||||
<section>
|
||||
<h2>Last run{{if $d.Run.Result}} — {{if eq $d.Run.Result "success"}}succeeded{{else}}failed ({{$d.Run.Result}}){{end}}{{end}}</h2>
|
||||
<pre class="log">{{$d.Run.Log}}</pre>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user