Compare commits

66 Commits
Author SHA1 Message Date
nilsandClaude Sonnet 5.5 4ee28b812b Release 0.12.8
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-09 17:26:15 +01:00
nilsandClaude Sonnet 5.5 b37d8a0961 Allow previewing an older NixOS release with --downgrade
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-09 08:25:36 +01:00
nilsandClaude Sonnet 5.5 1efa068443 Release 0.12.7
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 23:28:47 +01:00
nilsandClaude Sonnet 5.5 3d5ec77f57 Follow the channel server's redirect and offer the stable releases
channels.nixos.org answers <branch>/git-revision with a redirect, which
router-upgrade check did not follow: on a real router it saw an empty
answer. The test channel is now an HTTP server that redirects the same way.

The page labelled the running release "stable", but nixos-unstable already
calls itself the next release (26.11 does not exist as a branch yet). It
offers none, unstable and the stable releases listed in the code (26.05).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 23:28:47 +01:00
nilsandClaude Sonnet 5.5 f4a192d416 Release 0.12.6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 22:27:39 +01:00
nilsandClaude Sonnet 5.5 dd03127a24 Add the Packages page for nixpkgs upgrades
A page under System for router-upgrade: the channel to follow, Check,
Preview (the package versions that change, the download size, what would be
built here, a reboot note for a new kernel), Download now and Upgrade. The
steps run in router-upgrade.service, which the page asks through a request
file; the last run's output is kept in upgrade/last.log because a finished
one-shot unit has no journal to look up.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 22:27:39 +01:00
nilsandClaude Sonnet 5.5 4ac8c6ea6f Release 0.12.5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 21:06:10 +01:00
nilsandClaude Sonnet 5.5 56d6e153ae Add router-upgrade for nixpkgs upgrades on the router
router.update.channel names the nixpkgs branch to follow (unstable or a
release like 26.05). router-upgrade check compares its newest tested
revision with the running one; preview locks a copy of the configuration
flake to it and lists the packages that change version and the size of the
download without fetching anything; download fetches it; apply takes the
lock and activates with commit-confirm, the lock going back on a rollback
or a failed build. router-update keeps an upgraded nixpkgs pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 21:06:10 +01:00
nilsandClaude Sonnet 5.5 af614ea501 Release 0.12.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 18:17:16 +01:00
nilsandClaude Sonnet 5.5 bda07ae163 Fold the certificate's order log away
The log of the last issuing run is only of interest when something went
wrong. It is a collapsed section now, open when the last run failed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 18:17:01 +01:00
nilsandClaude Sonnet 5.5 5572172904 Re-resolve WireGuard peers' endpoint names
systemd-networkd resolves an endpoint name once, when it creates the
device. A peer behind a dynamic DNS name that changes its address was
never found again until the next restart.

A timer (router.wireguardEndpointRefresh, default 1min, null for off)
runs wg set for the peers with a name whose last handshake is older than
135 seconds, as wireguard-tools' reresolve-dns does. Working tunnels are
left alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 18:17:01 +01:00
nilsandClaude Sonnet 5.5 879b618981 Release 0.12.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-05 08:10:59 +01:00
nilsandClaude Sonnet 5.5 98b0ebc130 Type the REST API's responses
Every tool now returns a Go struct (apitypes.go) instead of a map, and
typeschema.go derives the OpenAPI response schemas from those types by
reflection (json, doc and enum tags), so the document cannot drift from
what is sent. Text results are documented as text/plain, settings in
answers use the typed settings schema, and edits answer with EditResponse.

TestResponsesMatchTheirSchemas checks real responses against the
generated schemas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-05 08:10:59 +01:00
nilsandClaude Sonnet 5.5 e3209569bd Release 0.12.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 22:01:38 +01:00
nilsandClaude Sonnet 5.5 876080d3f5 Show the API reference with Redoc
Replace the hand-built reference page with Redoc rendering the generated
OpenAPI document. The bundle is fetched by package.nix (pinned hash) and
served from the UI's own address; only that page gets a style nonce and
worker-src blob: in its CSP.

The spec now explains authentication, saved-not-applied, ETags (If-Match,
409) and errors, documents the ETag header, gives each operation a curl
sample, and no longer names MCP tools. Area GETs no longer emit
"parameters": null, which Redoc could not render.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 22:01:30 +01:00
nilsandClaude Sonnet 5.5 64e08fb485 Add typed settings areas to the REST API
/api/v1/settings/router/firewall, .../router/lans/<name>, .../system, down to
a single option: GET reads, PUT replaces, PATCH merges, DELETE removes, with
the settings' ETag/If-Match and the same limits as the whole-document edit
(editSettings is now shared). Values are checked against a JSON Schema made
from the option documentation (schema.go reads the types, which nixosOptionsDoc
describes in words); a wrong type, a value out of range or an unknown key is
refused with its place. The OpenAPI document has the area paths with their
schemas; the API reference page lists every area's options.

Tests: unit tests for the type reader, the validator and the paths; the real
option file and a router's own settings validate (ROUTER_OPTIONS_JSON,
LIVE_SETTINGS_JSON); a subtest in apply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 20:09:20 +01:00
nilsandClaude Sonnet 5.5 42ceb78d2b Add an API reference page to the web UI
/apireference lists the REST operations (from the same route list and tool
schemas as the OpenAPI document) with access, parameters and a curl example
for this router. Linked from the API access page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:40:45 +01:00
nilsandClaude Sonnet 5.5 6d8e9d4166 Release 0.12.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:17:27 +01:00
nilsandClaude Sonnet 5.5 9646e67337 Fix text areas being 65% of the screen high on every page
The rule meant for the settings box on the Advanced page matched every
textarea with a rows attribute (DNS providers, DDNS, certificate, WireGuard
import). It now only matches that box.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:15:06 +01:00
nilsandClaude Sonnet 5.5 4e935d4f04 SPECS: the Cloudflare AAAA records are gone, truenas.home stays
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:10:34 +01:00
nilsandClaude Sonnet 5.5 c7b5368ba3 Release 0.12.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:06:28 +01:00
nilsandClaude Sonnet 5.5 b1fa7b1396 Add token expiry, edit notice, log allowlist, local-zone test; merge PLAN.md into SPECS.md
get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd,
nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30
(default) or 90 days, or without limit. The overview and the Changes page
say which token made the unapplied changes.

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

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

basic: a subtest for wan -> local rules.

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:06:27 +01:00
nilsandClaude Sonnet 5.5 586274af4e Add the appliance web UI look, interface names, MCP and REST API, local zone
Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes),
theme preference on the Advanced page, Changes page with the pending diff.

Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and
pppoe.vlanName; a rename is staged for the next reboot by router-apply.

Remote access: an MCP server (POST /mcp) and an OpenAPI REST API
(/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit
log; tools to read status, settings, leases, logs (get_logs), and to edit
and check the saved settings (never applied). Tokens on the API access page.

Firewall: a reserved `local` zone (the router itself) as the destination of
rules, so the web UI or SSH can be allowed from the WAN.

PLAN.md lists what is still left.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 16:56:59 +01:00
nilsandClaude Sonnet 5.5 95ab7223b9 Restyle the web UI as a dark monospace console
Phosphor green on black, square corners, outlined buttons, uppercase
headings. Checkboxes in front of their label become switches (small
ticks inside tables) instead of stacking above the text. Fieldsets no
longer grow wider than the page; wide tables scroll inside their panel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 21:12:18 +01:00
nilsandClaude Sonnet 5.5 0900a46f1d Release 0.11.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 19:27:49 +01:00
nilsandClaude Sonnet 5.5 caf2c6b26d Add DNS accounts, Let's Encrypt, password change, reboot, HTTP redirect
- router.dnsProviders: named accounts at DNS providers, used by dynamic
  DNS and certificates (ddclient and lego provider lists in
  pkgs/router-webui/providers.json). Schema 2 migrates router.ddns.
- router.acme: a Let's Encrypt certificate for the web UI via
  security.acme (DNS-01, resolver 1.1.1.1), served and reloaded by the
  web UI; Certificate page.
- Web UI: password change (web UI and root), reboot button, HTTP to HTTPS
  redirect (router.webui.httpPort), grouped menu.
- Latest kernel (linuxPackages_latest); 7.3 and 7.3.0 are the same kernel
  for the reboot hint.
- SPECS.md: wanted later (REST API, GraphQL, MCP).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 19:27:49 +01:00
nilsandClaude Sonnet 5.5 a32bf42777 Release 0.10.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 15:54:30 +01:00
nilsandClaude Sonnet 5.5 a58600823f Add a generic NAT table: port forward, source NAT, masquerade
router.firewall.nat replaces the dnat/toPort/hairpin fields of the filter
rules. Filter rules gain natOnly, and may use wan with log and rate limit
in the web UI. The Firewall page edits the NAT table and can add the
matching allow rule. Settings schema 1 (migrations/1.jq) converts old
forwards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 15:54:29 +01:00
nilsandClaude Sonnet 5.5 3e9c2e4a7a Remove examples/home and the antigua WireGuard import script
The hand-written port of antigua's router role predates the installer and
the web UI; the router is configured through settings.json now. Its flake
outputs (the home configuration, installer-home, the home check) go too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 14:31:10 +01:00
nilsandClaude Sonnet 5.5 cfa07634e8 Release 0.9.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 13:35:57 +01:00
nilsandClaude Sonnet 5.5 a36cebc827 Add dynamic DNS and show the running kernel on the overview
router.ddns keeps provider records on the uplink's addresses with ddclient,
run by a timer and by a watcher that starts it when an address changes. The
Dynamic DNS page edits it; the secret is a private file on the router. The
overview shows the running kernel and says when the current system boots a
different one. PLAN.md records why the A/B image approach was not pursued.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 13:35:57 +01:00
nilsandClaude Sonnet 5.5 c198ffbe37 Update nixpkgs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 13:35:57 +01:00
nilsandClaude Sonnet 5.5 d3e97caff1 Release 0.8.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 10:20:50 +01:00
nilsandClaude Sonnet 5.5 ec2a10658f Edit port forwards and openings from the internet on the firewall page
Rules from wan are edited in their own table: protocol, ports, zone, host,
host port, allowed sources, port forward (NAT), hairpin, log and rate
limit, with the module's checks done up front.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 10:20:50 +01:00
nilsandClaude Sonnet 5.5 9f9628eecc Release 0.7.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 09:26:25 +01:00
nilsandClaude Sonnet 5.5 eda7080d94 Zone-based firewall page, per-sensor temperatures, 15 minute default
The firewall page now manages zones (assign each LAN or tunnel, add and
remove zones) and rules with zone, address and port on both sides plus the
protocol. router.firewall.zones declares zones without members and rules
take sourcePorts. The temperature chart draws every thermal zone and hwmon
sensor; the overview defaults to 15 minutes, 1h stays an option.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 09:26:25 +01:00
nilsandClaude Sonnet 5.5 e2986f1418 Release 0.6.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 08:31:30 +01:00
nilsandClaude Sonnet 5.5 605753b20e Add a firewall page to the web UI
Allow rules between zones (LANs and tunnels) are edited on their own page:
from, to, protocol, ports, destination. Port forwards and rules with more
options are listed and kept as they are.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 08:31:30 +01:00
nilsandClaude Sonnet 5.5 e597349fa2 Release 0.5.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 08:07:47 +01:00
nilsandClaude Sonnet 5.5 a01214707a Test dynamic DHCP names in the DNS VM test
A client with a hostname is published with its reverse record, a client
claiming a configured name does not take it over, and a name outside the
domain is cut to its first label.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 00:30:11 +01:00
nilsandClaude Sonnet 5.5 2eab735f59 Publish DHCP clients' names in DNS
router-webui leases-dns reads Kea's leases and publishes <name>.<domain>
with reverse records in the running Unbound (router.dns.dynamicLeases).
Names the configuration publishes are never taken over; a client's name
is cut to its first label and kept inside the local domain; of two
clients the newer lease wins. The DNS page switches it on and shows what
is published and what was skipped; router-setup enables it with a domain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 00:18:01 +01:00
nilsandClaude Sonnet 5.5 84f6002d05 Add WireGuard, DNS and more to the web UI and router-setup
Web UI: a WireGuard page (tunnels, peers, keys made on the router, client
configs with a QR code, import of wg-quick configs, live status), a DNS page
(records, wildcards, forward zones), interface dropdowns with VLAN devices,
IPv6 options and a local domain. router-setup asks about VLANs, IPv6 and
the domain. The router publishes its own name in DNS and hands the local
domain to DHCP clients; wg and jq are on the router. Includes a script
that takes over antigua's tunnels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 23:53:34 +01:00
nilsandClaude Sonnet 5.5 54040a1402 Release 0.4.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 21:42:07 +01:00
nilsandClaude Sonnet 5.5 f839e7fffb Add DHCP reservations to the web UI, split out a fast webui test
The DHCP page edits the reservations per LAN (checked like the module's
assertions) and offers active clients for reservation. The web UI checks
that need no installed system moved from apply.nix into webui.nix (one
node, under a minute); apply.nix shares one apply for the pool change and
a reservation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 21:41:43 +01:00
nilsandClaude Sonnet 5.5 ca05f3ab36 Release 0.3.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 21:11:59 +01:00
nilsandClaude Sonnet 5.5 574748806f Add IPv6 and VLAN devices to the web UI and router-setup
The web UI lists physical and VLAN interfaces and takes a VLAN uplink as
a device name (enp2s0.1001); it has IPv6 checkboxes for the WAN and each
LAN. router-setup asks whether to create VLANs before WAN and LAN are
picked, and whether to enable IPv6. --config-only writes the
configuration without installing, which the setup test uses for a PPPoE
uplink on a VLAN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 21:11:11 +01:00
nilsandClaude Sonnet 5.5 5983eadae2 Add fast iteration tooling
nix run .#fast, .#test and .#webui-dev; see DEVELOPING.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 13:34:21 +01:00
nilsandClaude Sonnet 5.5 b9c63273e4 Add signed updates from a bundle or a URL
router-release signs a release as a binary cache; router-update verifies
it against the router's update keys, migrates settings.json, and applies
it with commit-confirm. Includes the web UI page and a VM test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 13:34:21 +01:00
nilsandClaude Sonnet 5.5 2a111d012b Add a dashboard and backup download to the web UI
Metric history (5s to 1h tiers, persisted), uplink status, ping targets,
DHCP leases, and a tar.gz backup of the state directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 13:34:21 +01:00
nilsandClaude Sonnet 5.5 e684ddb06c Reuse an existing install or backup in router-setup
router-setup finds earlier installs and router-backup-*.tar.gz files and
reinstalls them with everything in /var/lib/router carried over.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 13:34:21 +01:00
nilsandClaude Opus 5.5 8f65587f99 Keep routers and the installer running with the lid closed
logind suspends laptops on lid close by default; a router (and an install
in progress) must keep running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 09:42:27 +01:00
nilsandClaude Opus 5.5 b12bb5aa51 Ignore only the top-level image and installer build links
Unanchored, the patterns also matched modules/image, modules/installer and examples/image, hiding new files there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 09:21:12 +01:00
nilsandClaude Opus 5.5 f01d4c14fb Drop the debug root password from the image example
Installed routers get root's password from router-setup now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 09:20:54 +01:00
nilsandClaude Opus 5.5 f5909fbdcb Add a generic installer that asks for the basics and builds offline
The installer ISO no longer needs a prebuilt system: `router-setup` asks
for the disk, the WAN and LAN interfaces, DHCP or PPPoE (optionally on a
VLAN), host name, LAN address and a password, writes settings.json and
the secrets, builds that router offline (the ISO carries a reference
router system whose closure has everything the build needs) and installs
it with `router-install --system`.

The password is used for the web UI and, hashed, as root's console
password (routerSettings sets root's hashedPasswordFile; it applies when
the account is created at installation).

checks.setup answers router-setup on the console and checks the installed
box: its settings, the web UI and console login with that password, and
a rebuild that reproduces the installed system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 09:19:14 +01:00
nilsandClaude Opus 5.5 5ddff44a35 Serve DHCP on LANs plugged in after boot; accept browser logins
Kea skips interfaces without carrier when it opens its sockets and only
looks again on a reload, so a LAN cable plugged in after Kea started got
no DHCP until a restart. networkd-dispatcher now restarts Kea when a DHCP
LAN gets carrier (a restart: a SIGHUP while Kea is starting ends it). The
rule script is the same for every configuration and reads the interfaces
from /etc/router/dhcp-interfaces, so the device never has to rebuild it
(writeShellApplication's shellcheck could not be built offline).

The web UI refused every login from a browser with "forbidden": under
Referrer-Policy no-referrer browsers send "Origin: null" with form posts.
Use http.CrossOriginProtection (Sec-Fetch-Site, then Origin) and a
same-origin referrer policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 09:19:14 +01:00
nilsandClaude Opus 5.5 753459f5d7 Add a web UI for settings-configured routers
router-webui (Go, standard library only) edits settings.json: host
name, WAN (DHCP or PPPoE), LANs with DHCP, and the raw JSON for the
rest; keys the forms do not know are kept. It applies and confirms by
starting router-apply.service and router-confirm.service, which a
switch never restarts, and shows the build log and the rollback
countdown. HTTPS with a self-signed certificate, a PBKDF2 password
hash, CSRF tokens and an Origin check; reachable from management LANs
only (new router.managementPorts).

router-apply records the settings it built and router-confirm keeps
them as settings.confirmed.json (new rollback.onConfirm hook), so the
UI can tell unapplied changes and discard them.

checks.apply now also logs in from a LAN client, discards the change
left by the rollback, edits the DHCP pool, applies and confirms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:43:58 +01:00
nilsandClaude Opus 5.5 2c8d5562f9 Configure a router from settings.json and rebuild it on the device
nixosModules.settings builds a router from a JSON file: the router
options as they are, plus host name, time zone, SSH keys and
stateVersion; the module system validates it. lib.mkRouterSystem builds
such a system, lib.mkConfigDir the configuration directory installed to
/var/lib/router/config (a flake pinned to this router source, the
settings and an optional local.nix). router-apply builds it and switches
with commit-confirm.

The device must be able to rebuild without internet, so the packages the
rebuilt derivations need are part of the system; they were found by
diffing the derivation graphs of typical changes against the installed
system.

checks.apply installs such a router, boots it offline, checks that the
unchanged settings rebuild to the installed system, applies and
confirms a change, and rolls back an unconfirmed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:22:26 +01:00
nilsandClaude Opus 5.5 a4f4ec12a3 Add A/B updates with boot counting to the appliance image
The image gets a second, empty store slot. systemd-sysupdate (wrapped as
router-update) installs a new version into the slot not in use: both
store partitions, then the UKI with boot counting, verified against a
signed SHA256SUMS. system.build.update holds a version's files.

Counted boots must pass router-healthy (the router's units plus
router.image.healthChecks, each checked on its own) to reach
boot-complete.target; otherwise the box reboots, and once the tries are
used up systemd-boot falls back to the previous version.

checks.image now installs v2 into the empty slot, boots and blesses it,
refuses a tampered update, and rolls a broken v3 back to v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:49:50 +01:00
nilsandClaude Opus 5.5 daa70fe7ac Add a read-only appliance image with a dm-verity protected store
nixosModules.image builds a GPT disk image with systemd-repart: ESP with a
UKI carrying the store's verity root hash, an erofs /nix/store on a
dm-verity partition, a tmpfs root, an immutable /etc overlay and an ext4
state partition on /var. No Nix on the device; it is replaced as a whole.

The store is made with 4 KiB erofs blocks: systemd-repart passes the 512
byte sector size to mkfs.erofs, and libblkid cannot verify the checksum of
such a superblock, so udev never reported the file system and the initrd
waited forever for /dev/mapper/usr.

examples/image (nix build .#image) is a hardware test build with a debug
console password; checks.image boots the image under UEFI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:54:24 +01:00
nilsandClaude Sonnet 5.5 406196adce Add the home gateway config and tunnel source addresses
- wireguard.<n>.sourceAddresses: source for router-originated traffic
  into a tunnel without an address of its own (PreferredSource on the
  routes); checked at build time.
- examples/home: antigua's routing role ported to the router module
  (PPPoE with IPv6 inside the session, LAN with ULA and reservation,
  local DNS and forward zones, the freifunk/family/mobile tunnels with
  explicit zone rules, ddclient following the PPP device).
  nixosConfigurations.home, packages.installer-home and a flake check
  that builds it.
- PLAN.md: the remaining antigua gaps and the open question about
  services that stay behind the router.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:53:14 +01:00
nilsandClaude Sonnet 5.5 c395a43a78 Add a static ULA per LAN, announced via SLAAC and RDNSS
lans.<n>.ipv6.address (a ULA /64) is assigned to the LAN, announced
with an explicit prefix (networkd only announces the delegated prefix
when prefix delegation is on) and advertised as RDNSS; Unbound listens
and answers on it. Checked at build time: valid IPv6 CIDR, /64, fc00::/7.

Covered by the onestick VM test (client SLAAC address, ping and DNS over
the ULA, RA captured on the wire), assertion cases and a unit test for
the /64 prefix helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:48:47 +01:00
nilsandClaude Sonnet 5.5 70dddad206 Add IPv6 inside the PPP session and use enp2s0 in the example
- wan.ipv6.transport = "ppp": IPv6CP in pppd, router advertisements and
  DHCPv6 prefix delegation on the PPP device, DHCPv6 accepted on the PPP
  device, IPv6 MSS clamped to MTU-60. Allowed on a shared single-NIC
  link. A build-time check requires a PPPoE uplink.
- IPv4 MSS clamping now only matches IPv4 packets.
- The example uses the new box's NIC name (enp2s0).

Covered by the onestick VM test (radvd and a minimal DHCPv6-PD
responder as the ISP) and assertion cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:03:57 +01:00
nilsandClaude Sonnet 5.5 b5429ad876 Add local DNS records, DHCP reservations and an installer ISO
DNS and DHCP (needed to replace a dnsmasq-based router):
- router.dns.domain/hosts/overrides/forwardZones: authoritative local
  domain, split-horizon host records with reverse entries, wildcard
  overrides and per-zone forwarders (private zones skip DNSSEC).
- lans.<n>.dhcp.reservations: Kea host reservations, published in DNS.
- Tunnels with dns = true admit what their peers route, so a
  site-to-site peer without a transfer address can use the resolver.

Installer:
- router-install wipes a disk, creates an ESP and an ext4 root and
  installs a prebuilt system with its secrets; nothing is built on the
  box. installed-layout is the shared disk layout contract.
- lib.mkInstaller builds an ISO for a given nixosSystem;
  packages.installer-example is the example's ISO.

Covered by the dns and installer VM tests (the latter installs onto an
empty disk and boots it under UEFI), wireguard DNS and assertion cases.
PLAN.md records the remaining gaps for replacing the hand-written router.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:31:44 +01:00
nilsandClaude Sonnet 5.5 0653b5cf2f Add router-on-a-stick, commit-confirm rollback and an example config
- Allow the WAN link to also carry LANs when the uplink is PPPoE on a
  VLAN with IPv6 off; only the PPP device counts as an uplink then, so
  LAN traffic can never match WAN rules.
- Add router.rollback: router-arm / router-confirm / router-rollback
  plus `nix run .#deploy`, a commit-confirm deployment flow.
- Wait for the LAN devices instead of the uplink in networkd
  wait-online, so an unplugged uplink cannot block boot or activation.
- Add examples/desktop (single NIC, PPPoE on VLAN 1001) as
  nixosConfigurations.example, built as a flake check.

Covered by the onestick and rollback VM tests and new assertion cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:09:09 +01:00
nilsandClaude Sonnet 5.5 3f8f2f4199 Add port forwards and WAN-originated firewall rules
Extend router.firewall.rules with a reserved `wan` source zone: DNAT
(dnat, toPort), port ranges, source allow-lists, per-source rate
limits, logging and optional hairpin NAT. IPv6 is opened only by
explicit pinholes. Build-time assertions require WAN rules to name a
destination, protocol and ports so a typo cannot expose a whole zone.

Covered by the basic/pppoe VM tests and the assertion tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:48:40 +01:00
nilsandClaude Sonnet 5.5 30ab1a0ed6 Add WireGuard tunnels as firewall zone members
router.wireguard.<name> configures tunnels through networkd: road warrior
(listening, dual-stack), site-to-site (no transfer network) and provider
dial-out with masquerade. A tunnel has a zone like a LAN, so inter-zone rules
apply; management, DNS and internet are opt-in. Peers' allowedIPs become
routes (destination-based routing; default-route tunnels are rejected).

Tunnels have a kill switch (default on): forwarded packets for their
allowedIPs are dropped if they would leave via an uplink, before the
established-accept so flows that used the tunnel are cut too.

Private keys stay outside the Nix store and are passed to the unprivileged
systemd-networkd with LoadCredential, so key files can be root-only.

Assertions cover CIDR/key/endpoint formats, duplicate allowedIPs, overlaps
with LAN subnets, port and device clashes, and rule destinations inside
tunnel networks. A five-VM `wireguard` check covers road warrior, site-to-site,
the provider tunnel with an IPv6 transfer net, zone rules and the kill switch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:56:45 +01:00
139 changed files with 25078 additions and 296 deletions
+5
View File
@@ -1,3 +1,8 @@
result result
result-* result-*
.nixos-test-history .nixos-test-history
/image
/installer
/dist
pkgs/router-webui/router-webui
pkgs/router-webui/static/redoc.standalone.js
+61
View File
@@ -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.
-116
View File
@@ -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).
+279
View File
@@ -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).
+1
View File
@@ -0,0 +1 @@
0.12.8
+61
View File
@@ -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";
};
}
+53
View File
@@ -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
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1792773339, "lastModified": 1793110635,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=", "narHash": "sha256-dhoW6VgFWlaodSY5jrBn+CF2FLmaUvDVgM7+C7T2+NM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504", "rev": "31ecbaca1bdefa45dffc409f0250bd8039ea0010",
"type": "github" "type": "github"
}, },
"original": { "original": {
+348 -7
View File
@@ -3,22 +3,358 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: outputs =
{ self, nixpkgs }:
let let
systems = [ "x86_64-linux" "aarch64-linux" ]; systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); 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 in
{ {
nixosModules = { nixosModules = {
router = ./modules/router; router = ./modules/router;
default = self.nixosModules.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. # A router built from settings.json; this is what the device's own
checks.x86_64-linux = import ./tests { # configuration flake (see mkConfigDir) evaluates.
inherit self; lib.mkRouterSystem =
pkgs = nixpkgs.legacyPackages.x86_64-linux; {
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 # `nix run .#lab`: boot the basic test topology in tmux, one console pane
# per VM plus the interactive test-driver REPL. # per VM plus the interactive test-driver REPL.
@@ -28,7 +364,12 @@
test = self.checks.x86_64-linux.basic; test = self.checks.x86_64-linux.basic;
lab = pkgs.writeShellApplication { lab = pkgs.writeShellApplication {
name = "router-lab"; name = "router-lab";
runtimeInputs = with pkgs; [ tmux util-linux gnugrep coreutils ]; runtimeInputs = with pkgs; [
tmux
util-linux
gnugrep
coreutils
];
text = import ./tests/lab.nix { text = import ./tests/lab.nix {
inherit (pkgs) lib; inherit (pkgs) lib;
driver = test.driverInteractive; driver = test.driverInteractive;
+1
View File
@@ -0,0 +1 @@
nixos-router-update-1:pdzUo8vb5GtdQFd1SGVin45qAW25AqERczbcsKGW0M4=
+21
View File
@@ -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
+19
View File
@@ -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
+16
View File
@@ -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))
+360
View File
@@ -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} "$@"
'')
];
};
}
+127
View File
@@ -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;
};
}
+47
View File
@@ -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.
'';
}
+34
View File
@@ -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;
}
+461
View File
@@ -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"
];
};
}
+73
View File
@@ -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
View File
@@ -6,7 +6,26 @@ let
lansByName = cfg.lans; lansByName = cfg.lans;
lans = lib.attrValues lansByName; 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; 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 # Assertions about a single LAN; skipped (reported once) when its addresses
# do not even parse, so we never feed null into the integer helpers. # do not even parse, so we never feed null into the integer helpers.
@@ -31,12 +50,37 @@ let
assertion = addrOk; assertion = addrOk;
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address."; 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. # IFNAMSIZ is 16 including the terminating NUL.
assertion = lib.stringLength lan.device <= 15; assertion = lib.stringLength lan.device <= 15;
message = "router: ${where}: device name `${lan.device}` is longer than 15 characters."; 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 ( ++ lib.optionals lan.dhcp.enable (
[ [
{ {
@@ -58,35 +102,139 @@ let
message = "router: ${where}.dhcp.pool contains the router's own address ${lan.ipv4.address}."; 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 = ruleAssertions =
r: r:
let let
where = "router.firewall.rules (${r.from} -> ${r.to})"; where = "router.firewall.rules (${r.from} -> ${r.to})";
dest = if r.destination == null then null else ipv4.parseNet r.destination; fromWan = r.from == "wan";
destLans = lib.filter (l: l.zone == r.to && validIp l.ipv4.address) lans; v6Dest = r.destination != null && isV6 r.destination;
dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination;
inZone = inZone =
dest != null dest != null
&& lib.any ( && lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
l: zoneV4Nets r.to
dest.prefixLength >= l.ipv4.prefixLength );
&& ipv4.inSubnet {
address = ipv4.parse l.ipv4.address;
inherit (l.ipv4) prefixLength;
} dest.address
) destLans;
in in
[ [
{ {
assertion = lib.elem r.from zoneNames; assertion = fromWan || lib.elem r.from zoneNames;
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames})."; message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
} }
{ {
assertion = lib.elem r.to zoneNames; assertion = r.to == "local" || lib.elem r.to zoneNames;
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " 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; assertion = r.from != r.to;
@@ -96,20 +244,274 @@ let
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp"; assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: ports are only valid with protocol tcp or 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) [ ++ 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."; message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
} }
{ {
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone; assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone;
message = "router: ${where}: destination `${r.destination}` is not inside any LAN of zone `${r.to}`."; 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."; 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 ( overlapPairs = lib.concatLists (
@@ -128,13 +530,45 @@ in
assertion = lans != [ ]; assertion = lans != [ ];
message = "router: at least one LAN must be defined."; 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; assertion = lib.any (l: l.allowManagement) lans;
message = "router: no LAN has allowManagement = true; you would be locked out of the router."; 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)); assertion =
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN."; !(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 != ""; assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
@@ -162,7 +596,39 @@ in
} }
] ]
++ lib.concatLists (lib.mapAttrsToList perLan lansByName) ++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
++ dnsAssertions
++ lib.concatMap ruleAssertions cfg.firewall.rules ++ 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 ( ++ map (
{ a, b }: { a, b }:
let let
+99
View File
@@ -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;
};
};
};
}
+5
View File
@@ -4,8 +4,13 @@
./assertions.nix ./assertions.nix
./networking.nix ./networking.nix
./pppoe.nix ./pppoe.nix
./wireguard.nix
./firewall.nix ./firewall.nix
./dhcp.nix ./dhcp.nix
./dns.nix ./dns.nix
./ddns.nix
./acme.nix
./rollback.nix
./power.nix
]; ];
} }
+47 -1
View File
@@ -1,4 +1,9 @@
{ config, lib, ... }: {
config,
lib,
pkgs,
...
}:
let let
cfg = config.router; cfg = config.router;
dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans); dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans);
@@ -6,6 +11,31 @@ let
in in
{ {
config = lib.mkIf (cfg.enable && dhcpLans != [ ]) { 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 = { services.kea.dhcp4 = {
enable = true; enable = true;
settings = { settings = {
@@ -27,6 +57,11 @@ in
inherit (l.ipv4) subnet; inherit (l.ipv4) subnet;
interface = l.device; interface = l.device;
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ]; 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 = [ option-data = [
{ {
name = "routers"; name = "routers";
@@ -36,6 +71,17 @@ in
name = "domain-name-servers"; name = "domain-name-servers";
data = l.ipv4.address; 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; }) dhcpLans;
}; };
+106 -7
View File
@@ -2,29 +2,128 @@
let let
cfg = config.router; cfg = config.router;
lans = lib.attrValues cfg.lans; 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 in
{ {
config = lib.mkIf cfg.enable { 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 = { services.unbound = {
enable = true; enable = true;
# What router-webui leases-dns publishes the clients' names through.
localControlSocketPath = lib.mkIf dns.dynamicLeases "/run/unbound/unbound.ctl";
settings = { settings = {
server = { 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. # LAN addresses are assigned by networkd, possibly after Unbound starts.
ip-freebind = true; ip-freebind = true;
access-control = [ access-control = [
"127.0.0.0/8 allow" "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 != [ ]) { // lib.optionalAttrs (cfg.dns.forwarders != [ ] || dns.forwardZones != { }) {
forward-zone = [ forward-zone =
{ lib.mapAttrsToList (name: addrs: {
inherit name;
forward-addr = addrs;
}) dns.forwardZones
++ lib.optional (cfg.dns.forwarders != [ ]) {
name = "."; name = ".";
forward-addr = cfg.dns.forwarders; forward-addr = cfg.dns.forwarders;
} };
];
}; };
}; };
}; };
+237 -31
View File
@@ -4,46 +4,225 @@ let
lans = lib.attrValues cfg.lans; lans = lib.attrValues cfg.lans;
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }"; set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
lanIfaces = map (l: l.device) lans; wgs = lib.attrValues cfg.wireguard;
mgmtIfaces = map (l: l.device) (lib.filter (l: l.allowManagement) lans); isV6 = lib.hasInfix ":";
internetIfaces = map (l: l.device) (lib.filter (l: l.internet) lans);
# zone name -> devices of its member LANs # Everything that is a firewall zone member: LANs and tunnels.
zones = lib.mapAttrs (_: ls: map (l: l.device) ls) (lib.groupBy (l: l.zone) lans); 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 [ ]; 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 ( intraZone = lib.concatLists (
lib.mapAttrsToList ( lib.mapAttrsToList (
zone: devs: 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 ) 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: r:
let let
dest = lib.optionalString (r.destination != null) "ip daddr ${r.destination} "; v4 = lib.filter (x: !isV6 x) r.sources;
proto = v6 = lib.filter isV6 r.sources;
if r.protocol == "any" then destFamily =
"" if r.destination == null then
else if r.protocol == "icmp" then null
"meta l4proto { icmp, ipv6-icmp } " else if isV6 r.destination then
"ip6"
else else
"${r.protocol} dport ${ "ip";
if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }"
} ";
in 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; uplink4 = cfg.wan.ipv4.interface;
pppoe = cfg.wan.ipv4.type == "pppoe"; pppoe = cfg.wan.ipv4.type == "pppoe";
# IPv4 leaves via the PPP device, IPv6 via the physical uplink. # IPv4 leaves via the PPP device, IPv6 via the physical uplink.
uplinks = lib.unique [ # When the physical link also carries LANs, only the PPP device is an uplink.
cfg.wan.interface sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
uplink4 uplinks = lib.unique ([ uplink4 ] ++ lib.optional (!sharedLink) cfg.wan.interface);
];
mss = cfg.wan.ipv4.pppoe.mtu - 40; 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); dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
in in
{ {
@@ -69,21 +248,32 @@ in
destination-unreachable, packet-too-big, time-exceeded, parameter-problem, destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert
} accept } accept
iifname ${set lanIfaces} icmp type echo-request accept iifname ${set memberIfaces} icmp type echo-request accept
iifname ${set lanIfaces} icmpv6 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 (!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 dnsIfaces} udp dport 53 accept
iifname ${set lanIfaces} tcp 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 (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 { chain forward {
type filter hook forward priority filter; policy drop; type filter hook forward priority filter; policy drop;
${lib.concatStringsSep "\n " killRules}
ct state established,related accept ct state established,related accept
ct state invalid drop ct state invalid drop
@@ -91,15 +281,21 @@ in
${lib.optionalString pppoe '' ${lib.optionalString pppoe ''
# Clamp MSS to the PPP MTU in both directions (never raised). # 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} iifname "${uplink4}" meta nfproto ipv4 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} 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 ( ${lib.optionalString (
internetIfaces != [ ] internetIfaces != [ ]
) "iifname ${set internetIfaces} oifname ${set uplinks} accept"} ) "iifname ${set internetIfaces} oifname ${set uplinks} accept"}
# Zones: free inside a zone, denied between zones unless a rule says so. # 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 = { networking.nftables.tables.router-nat = {
family = "ip"; family = "ip";
content = '' content = ''
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) natRules)}
}
chain postrouting { chain postrouting {
type nat hook postrouting priority srcnat; policy accept; type nat hook postrouting priority srcnat; policy accept;
${lib.concatStringsSep "\n " (lib.concatMap (p: p.postrouting) natRules)}
oifname "${uplink4}" masquerade 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"}
} }
''; '';
}; };
+16
View File
@@ -62,4 +62,20 @@ rec {
in in
networkOf a.address p == networkOf b.address p; 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
View File
@@ -2,26 +2,71 @@
let let
cfg = config.router; cfg = config.router;
v6 = cfg.wan.ipv6.enable; 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"; pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan; 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; lans = lib.attrValues cfg.lans;
vlanLans = lib.filter (l: l.vlan != null) lans; vlanLans = lib.filter (l: l.vlan != null) lans;
# VLAN devices hang off their parent; the parent's own unit must list them. # 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); 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. # Parents that carry only tagged LANs still need a unit to be brought up.
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) ( trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
lib.unique (map (l: l.interface) vlanLans) 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 in
{ {
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
networking.useNetworkd = true; networking.useNetworkd = true;
networking.useDHCP = false; 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 = { boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1; "net.ipv4.conf.all.forwarding" = 1;
@@ -49,87 +94,137 @@ in
} }
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans); ) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
systemd.network.networks = { systemd.network.networks =
"10-wan" = { lib.optionalAttrs (!sharedLink) {
matchConfig.Name = cfg.wan.interface; "10-wan" = {
networkConfig = { matchConfig.Name = cfg.wan.interface;
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}" ];
networkConfig = { networkConfig = {
ConfigureWithoutCarrier = true; DHCP = dhcp;
# Never accept RAs from the inside. }
// 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; IPv6AcceptRA = false;
} }
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) { // lib.optionalAttrs (pppoe && vlan != null) {
VLAN = vlansOn lan.interface; VLAN = [ vlanDevice ];
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
IPv6SendRA = true;
}; };
} }
// lib.optionalAttrs lan.ipv6.pd { // lib.optionalAttrs (!pppoe) {
dhcpPrefixDelegationConfig = { # The router runs its own resolver; never adopt the ISP's DNS for the host.
UplinkInterface = cfg.wan.interface; dhcpV4Config.UseDNS = false;
SubnetId = 0;
Announce = true;
Assign = true;
};
# No RDNSS yet: the global address is unknown at evaluation time.
ipv6SendRAConfig.EmitDNS = false;
} }
) // lib.optionalAttrs v6native {
) cfg.lans # The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
// lib.listToAttrs ( dhcpV6Config = {
map ( WithoutRA = "solicit";
parent: UseDelegatedPrefix = true;
lib.nameValuePair "19-trunk-${parent}" { UseDNS = false;
matchConfig.Name = parent; };
};
}
// 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 = { networkConfig = {
LinkLocalAddressing = "no"; LinkLocalAddressing = "no";
IPv6AcceptRA = false; 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
View File
@@ -7,6 +7,29 @@ in
options.router = { options.router = {
enable = mkEnableOption "the router role"; 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 = { wan = {
interface = mkOption { interface = mkOption {
type = types.str; type = types.str;
@@ -14,6 +37,10 @@ in
description = '' description = ''
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged; Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
with PPPoE it also carries the (optionally tagged) PPPoE session. 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; default = null;
description = "VLAN ID the ISP requires for PPPoE, or null for untagged."; 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 { username = mkOption {
type = types.str; type = types.str;
default = ""; default = "";
@@ -67,10 +100,27 @@ in
}; };
}; };
ipv6.enable = mkEnableOption '' ipv6 = {
IPv6 on the uplink: DHCPv6 (soliciting even without RA flags) plus enable = mkEnableOption ''
prefix delegation. The ISP is expected to delegate a single /64 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 { lans = mkOption {
@@ -96,6 +146,16 @@ in
description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`."; 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 { device = mkOption {
type = types.str; type = types.str;
readOnly = true; readOnly = true;
@@ -152,6 +212,29 @@ in
example = "192.168.10.199"; 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 { 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 { allowManagement = mkOption {
type = types.bool; type = types.bool;
default = true; default = true;
@@ -173,7 +269,12 @@ in
}; };
config.device = 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. # "invalid" is only ever seen when assertions.nix is already failing.
config.ipv4.subnet = 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 = [ ]; default = [ ];
description = '' description = ''
Allow rules between zones, on top of the default (deny between zones). Address translation, separate from the allow rules. `dnat` sends
Rules are one-directional; return traffic is allowed by connection traffic that arrives in zone `from` (`wan` for the uplink) and matches
tracking. Both ends must be zones of LANs defined in `router.lans`. 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 ( type = types.listOf (
types.submodule { types.submodule {
options = { options = {
type = mkOption {
type = types.enum [
"dnat"
"snat"
"masquerade"
];
description = "Kind of translation.";
};
from = mkOption { from = mkOption {
type = types.str; type = types.nullOr types.str;
description = "Source zone."; default = null;
description = "Zone the traffic comes from, or `wan`. For `snat` and `masquerade` null means any.";
}; };
to = mkOption { to = mkOption {
type = types.str; 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 { protocol = mkOption {
type = types.enum [ type = types.enum [
@@ -214,9 +508,107 @@ in
default = "any"; default = "any";
}; };
ports = mkOption { ports = mkOption {
type = types.listOf types.port; type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ]; 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 { destination = mkOption {
type = types.nullOr types.str; type = types.nullOr types.str;
@@ -224,7 +616,91 @@ in
example = "192.168.10.5"; example = "192.168.10.5";
description = '' description = ''
Restrict the rule to an IPv4 address or CIDR inside the 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 { dns.forwarders = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = [ ]; default = [ ];
+19
View File
@@ -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";
};
};
}
+13 -2
View File
@@ -12,7 +12,13 @@ let
pppoe = wan.ipv4.pppoe; pppoe = wan.ipv4.pppoe;
# Device carrying the PPPoE session: the VLAN on the uplink, or the uplink itself. # 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 # 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). # by networkd after boot has begun, so pppd must not start before it).
@@ -28,7 +34,12 @@ let
defaultroute defaultroute
replacedefaultroute replacedefaultroute
noipdefault noipdefault
nodefaultroute6 ${
if wan.ipv6.enable && wan.ipv6.transport == "ppp" then
"+ipv6\n ipv6cp-use-ipaddr"
else
"nodefaultroute6"
}
noccp noccp
mtu ${toString pppoe.mtu} mtu ${toString pppoe.mtu}
mru ${toString pppoe.mtu} mru ${toString pppoe.mtu}
+101
View File
@@ -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
];
};
}
+174
View File
@@ -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";
};
};
};
}
+352
View File
@@ -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
];
};
}
+236
View File
@@ -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";
};
};
}
+283
View File
@@ -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";
};
};
};
}
+32
View File
@@ -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
'';
}
+292
View File
@@ -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,
},
}
}
+426
View File
@@ -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")})
}
+131
View File
@@ -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)
}
+108
View File
@@ -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"`
}
+287
View File
@@ -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
}
+292
View File
@@ -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)
}
+113
View File
@@ -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)
}
+19
View File
@@ -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")
}
}
+265
View File
@@ -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.")
}
+77
View File
@@ -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: &current, 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
}
+632
View File
@@ -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"), "&#x2c", ","),
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 ""
}
+494
View File
@@ -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())
}
+235
View File
@@ -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&#x2cname,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")
}
}
+172
View File
@@ -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.")
}
+211
View File
@@ -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)
}
}
+39
View File
@@ -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")
}
}
+224
View File
@@ -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.")
}
+181
View File
@@ -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")
}
}
+100
View File
@@ -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))
}
+267
View File
@@ -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.")
}
+198
View File
@@ -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")
}
}
+331
View File
@@ -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.")
}
+496
View File
@@ -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.")
}
+356
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module router-webui
go 1.27
+229
View File
@@ -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
}
+140
View File
@@ -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)
}
}
}
+342
View File
@@ -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)
}
+172
View File
@@ -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)
}
}
+144
View File
@@ -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
},
}
+837
View File
@@ -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.")
}
+254
View File
@@ -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")
}
}
+538
View File
@@ -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
}
+252
View File
@@ -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)
}
}
+265
View File
@@ -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
}
+241
View File
@@ -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
}
+37
View File
@@ -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";
};
}
+272
View File
@@ -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"
}
}
+378
View 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
}
+72
View File
@@ -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")
}
}
+493
View File
@@ -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
}
+202
View File
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
body { margin: 0; }
+11
View File
@@ -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);
})();
+45
View File
@@ -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();
})();
+5
View File
@@ -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();
});
+182
View File
@@ -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; }
+193
View File
@@ -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
}
+68
View File
@@ -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}}
+16
View File
@@ -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}}
+46
View File
@@ -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}}
+44
View File
@@ -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}}&amp;name={{.Hostname | urlquery}}&amp;mac={{.MAC | urlquery}}&amp;address={{.Address | urlquery}}">Reserve</a></td></tr>{{end}}
</table>
</div>
{{else}}<p class="muted">No active leases.</p>{{end}}
</section>
{{end}}
{{end}}
+88
View File
@@ -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}}
+127
View File
@@ -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}}
+48
View File
@@ -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>
+6
View File
@@ -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}}
+118
View File
@@ -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}}
+50
View File
@@ -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}}
+73
View File
@@ -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}}
+65
View File
@@ -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}}
+101
View File
@@ -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