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
This commit is contained in:
@@ -4,3 +4,4 @@ result-*
|
||||
/image
|
||||
/installer
|
||||
/dist
|
||||
pkgs/router-webui/router-webui
|
||||
|
||||
@@ -105,6 +105,31 @@ Learned along the way:
|
||||
- Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`.
|
||||
- Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases).
|
||||
|
||||
## MCP server (confirmed)
|
||||
|
||||
- **Endpoint**: `POST /mcp` on the web UI (MCP Streamable HTTP, JSON-RPC 2.0, single request/response; no streaming). It is served on the same LANs and tunnels as the UI, with the UI's certificate (self-signed unless ACME is set up: the client must trust it).
|
||||
- **Tokens** (System → API access): `rtr_…`, shown once, stored as SHA-256 in `/var/lib/router/webui/tokens.json`. Scopes `read` and `change`; an `apply` scope is reserved. A session cookie is not a token. A wrong token costs a second; a foreign `Origin` is refused (DNS rebinding).
|
||||
- **Tools**: read — `get_status`, `get_settings` (with an etag), `get_pending_changes`, `list_interfaces`, `list_dhcp_leases`, `get_update_status`, `get_apply_log`, `check_settings`; change — `update_settings` (JSON merge patch, refused when the etag is stale), `discard_changes`. Resources: `router://settings`, and `router://options` (every router option with type, default and description, from `nixosOptionsDoc`, so an assistant knows what settings exist).
|
||||
- **Editing is safe by construction**: edits only write `settings.json`, like the Advanced page. They stay *unapplied*; a person reviews them (Changes page, a diff against the confirmed settings) and applies them in the web UI, with commit-confirm. Not editable: `system.sshKeys`, `system.stateVersion`, the schema `version`, any other `system` key besides hostName and timeZone; every `…File` path must be under the state directory (so a patch cannot point the PPPoE password file at `/etc/shadow`).
|
||||
- **`check_settings`** runs `router-check.service` (as root, outside the web UI's sandbox): `nix eval` of the system's `drvPath`, which runs the option types and all assertions without building. The verdict is in `/var/lib/router/check/result.json` with the etag of the settings it evaluated.
|
||||
- **Audit**: every tool call (token name, tool, arguments, error) is appended to `/var/lib/router/webui/audit.log` (rotated at 1 MB) and shown on the API access page.
|
||||
- **`get_logs`** (read): `journalctl` with arguments built in `logs.go` (no shell): `unit` (a unit name or `kernel`), `lines` (≤ 1000), `since`/`until`, `priority`, `grep`, `boot`. The output is capped at 256 KB.
|
||||
- **Not done**: applying or confirming (needs the `apply` scope), metric history.
|
||||
|
||||
## REST API (confirmed)
|
||||
|
||||
- `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP.
|
||||
- **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`.
|
||||
- Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500.
|
||||
|
||||
## Interface names (confirmed)
|
||||
|
||||
- **Cards**: `router.interfaces.<name>.mac` names the card with that (permanent) MAC address `<name>` from the next boot on, via a systemd `.link` file; `wan.interface` and `lans.*.interface` use the new names, so the web UI, nftables, Kea and the logs show `wan`, `lan`, not `enp2s0`.
|
||||
- **VLAN devices**: `lans.<l>.deviceName` (and `wan.ipv4.pppoe.vlanName`) name the VLAN device (`dmz` instead of `lan.1002`); the default stays `<interface>.<vlan>`.
|
||||
- **Only after a reboot**: a device that is up cannot be renamed and the configuration refers to the new name, so `router-apply` compares `/etc/router/interface-names` (cards and VLAN devices with their names) between the running and the new system. If a card gets, loses or changes its name, or an existing VLAN device changes its name, it only builds, sets the system profile and `switch-to-configuration boot` (no commit-confirm, no live switch); the web UI shows "starts at the next reboot". New VLAN devices (also named ones) are created live as before.
|
||||
- **Names**: lower case letters, digits and dashes, start with a letter, at most 15 characters, and not what the kernel or udev hand out (`eth0`, `wlan0`, `enp2s0`, `ppp0`, ...), checked by an assertion and in the web UI (`chosenName`).
|
||||
- **Risk**: a wrong MAC leaves the new system without its LAN after the reboot and there is no automatic rollback (the previous generation is in the boot menu). The web UI only offers cards that are present, with their MAC read from the machine.
|
||||
|
||||
## Port forwards and NAT (confirmed)
|
||||
|
||||
- Filtering and translation are separate. `router.firewall.rules` only filter and may use the reserved source zone `wan` (no LAN/tunnel may use that zone name; `wan` is never a destination). Rule fields: port ranges (`"9000-9002"`), `sources` allow-list (v4/v6 CIDRs), `rateLimit` (`N/second|minute|hour`, per source via nft meter), `log`, `natOnly` (only connections a DNAT rule translated).
|
||||
@@ -185,10 +210,31 @@ the choice was a Go reimplementation of ~2300 lines of Nix module logic, or Nix
|
||||
box, which makes every post-update boot wait for a rebuild and leaves the settings-derived system outside
|
||||
verity. The image module and its test (`checks.image`) stay as they are for anyone who wants a fixed image.
|
||||
|
||||
## Local zone (confirmed)
|
||||
|
||||
- **`local` zone**: `firewall.rules` may have `to = "local"`, the router itself (SSH, the web UI, ...): those rules go on the `input` chain. From `wan` they need a protocol and ports (no destination); `natOnly` is refused; `local` cannot be a zone name. A port forward on the same port takes the traffic first, so it never reaches `local`. Checked by `tests/assertions.nix` (`local*`); the generated nftables rules are not covered by a VM test yet.
|
||||
|
||||
## Open items
|
||||
|
||||
- **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system.
|
||||
|
||||
### Still left (2026-11-01)
|
||||
|
||||
Release and tests
|
||||
- Release 0.12.0 (bump `VERSION`, `nix run .#release`): nothing since 0.11.0 is released (restyle and themes, interface names, MCP, REST API, `get_logs`, `local` zone).
|
||||
- Rerun the VM tests that were not run on this work: `basic`, `onestick`, `setup`, `update`, `dns`, `ddns`, and a VM test for `local` rules (a WAN host reaching SSH or the web UI through `wan -> local`, and not without the rule).
|
||||
- A pebble-based ACME test (certificate issuance is only checked by evaluation).
|
||||
|
||||
Remote access
|
||||
- MCP and REST: an `apply` scope (build, activate, confirm; a token with it changes the running system); more read tools (metrics history, WireGuard status, certificate status); per-token expiry; a notice in the web UI when a token edited the settings; `get_logs` lets a read token see the whole journal (consider a unit allowlist or a separate `logs` scope).
|
||||
- REST API: the rest of what the web UI does (apply and confirm, metrics, updates), a typed response schema in the OpenAPI document (responses are untyped JSON now).
|
||||
|
||||
Firewall and networking
|
||||
- Host-based routing on 443/80 (decide by TLS server name or `Host` between the router's web UI and a host behind it): needs an SNI proxy on the router, loses the client address unless the host takes the PROXY protocol. Until then, expose the web UI and SSH on their own ports with `wan -> local` rules.
|
||||
- WireGuard: live status (handshake, transfer) in the web UI; a tunnel with `dns = true` but no address; the WireGuard page may show a duplicate "New tunnel" heading.
|
||||
- Installer: name cards (`router.interfaces`) during `router-setup`; dropdowns that list VLAN devices should show their custom names (`lan.1002` as `dmz`).
|
||||
- Advanced page: its sections are still unstyled next to the other pages' panels.
|
||||
|
||||
## Phases
|
||||
|
||||
1. **MVP** — flake with `router.*` schema: single WAN (DHCP), single LAN, NAT, basic firewall, DHCP + DNS. NixOS VM tests (`nixosTest`) in CI.
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# Specs
|
||||
|
||||
What nixos-router should become, beyond what exists. `PLAN.md` records decisions and what is done; this file
|
||||
lists wanted features. Nothing here is started.
|
||||
lists wanted features. The MCP server (read and change scopes) exists; the rest is not started.
|
||||
|
||||
## Later
|
||||
|
||||
### Programmatic access
|
||||
|
||||
- **REST API** for everything the web UI does (settings, apply/confirm, status, metrics, updates), versioned and
|
||||
authenticated with tokens rather than the browser session. The web UI would use the same handlers.
|
||||
- **REST API**: done with the MCP's feature set (OpenAPI at `/api/openapi.json`, see `PLAN.md`). Still to do: the rest
|
||||
of what the web UI does (apply/confirm, metrics, updates), versioned; the web UI would use the same handlers.
|
||||
- **GraphQL** on top of it, possibly: one query for the overview and for related settings.
|
||||
- **MCP server**, so an assistant can read the router's state and propose or make changes (the same checks as the
|
||||
web UI: settings are validated, applied with commit-confirm rollback, secrets are never returned).
|
||||
- To settle first: token scopes (read-only vs. change), audit log, and that changes still go through
|
||||
`settings.json` and `router-apply` rather than touching the live system.
|
||||
- **MCP server**: done for reading and editing settings (see `PLAN.md`, "MCP server"). Still to do: an `apply` scope
|
||||
(build, activate, confirm; a token with it can change the running system), more read tools (metrics history; `get_logs` exists),
|
||||
per-token expiry, and a notice in the web UI when a token edited the settings.
|
||||
- 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.
|
||||
|
||||
@@ -6,6 +6,19 @@ let
|
||||
lansByName = cfg.lans;
|
||||
lans = lib.attrValues lansByName;
|
||||
|
||||
# A name chosen for a network card or a VLAN device: short, plain, and not
|
||||
# one the kernel or udev hands out itself (a rename could then collide).
|
||||
# Keep in step with reservedName in the web UI (pkgs/router-webui/settings.go).
|
||||
chosenNameOk =
|
||||
s:
|
||||
builtins.match "[a-z][a-z0-9-]{0,14}" s != null
|
||||
&& builtins.match "(eth|wlan|ppp|wg|veth|bond|tun|tap|dummy|ifb|docker|virbr|br|lo)[0-9]*|(en|wl|ww|sl)[opsx][0-9a-f].*" s == null;
|
||||
chosenNames =
|
||||
lib.attrNames cfg.interfaces
|
||||
++ lib.concatMap (l: lib.optional (l.deviceName != null) l.deviceName) lans
|
||||
++ lib.optional (cfg.wan.ipv4.pppoe.vlanName != null) cfg.wan.ipv4.pppoe.vlanName;
|
||||
macs = lib.mapAttrsToList (_: i: i.mac) cfg.interfaces;
|
||||
|
||||
validIp = s: ipv4.parse s != null;
|
||||
hostnameOk = s: builtins.match "[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" s != null;
|
||||
# A DNS name: labels of letters, digits, dashes and underscores.
|
||||
@@ -37,6 +50,10 @@ let
|
||||
assertion = addrOk;
|
||||
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
|
||||
}
|
||||
{
|
||||
assertion = lan.deviceName == null || lan.vlan != null;
|
||||
message = "router: ${where}.deviceName needs a vlan.";
|
||||
}
|
||||
{
|
||||
# IFNAMSIZ is 16 including the terminating NUL.
|
||||
assertion = lib.stringLength lan.device <= 15;
|
||||
@@ -208,12 +225,16 @@ let
|
||||
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 =
|
||||
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: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
"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;
|
||||
@@ -249,7 +270,7 @@ let
|
||||
# Rules from the outside must not be able to expose a whole zone by accident.
|
||||
++ lib.optionals fromWan [
|
||||
{
|
||||
assertion = r.destination != null;
|
||||
assertion = r.destination != null || r.to == "local";
|
||||
message = "router: ${where}: rules from `wan` must name a destination host or network.";
|
||||
}
|
||||
{
|
||||
@@ -509,6 +530,22 @@ in
|
||||
assertion = lans != [ ];
|
||||
message = "router: at least one LAN must be defined.";
|
||||
}
|
||||
{
|
||||
assertion = lib.length macs == lib.length (lib.unique macs);
|
||||
message = "router.interfaces: two names are given to the same MAC address.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all chosenNameOk chosenNames;
|
||||
message = "router: names for interfaces and VLAN devices are lower case letters, digits and dashes, start with a letter, have at most 15 characters and do not look like a kernel name (eth0, wlan0, enp2s0, ...): ${lib.concatStringsSep ", " (lib.filter (n: !chosenNameOk n) chosenNames)}.";
|
||||
}
|
||||
{
|
||||
assertion = lib.length chosenNames == lib.length (lib.unique chosenNames);
|
||||
message = "router: a name is used twice among `interfaces` and the VLAN devices.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.wan.ipv4.pppoe.vlanName == null || cfg.wan.ipv4.pppoe.vlan != null;
|
||||
message = "router.wan.ipv4.pppoe.vlanName needs pppoe.vlan.";
|
||||
}
|
||||
{
|
||||
assertion = lib.any (l: l.allowManagement) lans;
|
||||
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
|
||||
@@ -567,6 +604,10 @@ in
|
||||
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)
|
||||
++ [
|
||||
|
||||
@@ -120,8 +120,9 @@ 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} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol r.ports r.sourcePorts}${lib.optionalString r.natOnly "ct status dnat "}";
|
||||
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:
|
||||
@@ -148,7 +149,11 @@ let
|
||||
in
|
||||
{
|
||||
# A zone nothing is assigned to yet has no devices; the rule waits for one.
|
||||
forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) (
|
||||
# `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)
|
||||
);
|
||||
};
|
||||
@@ -260,6 +265,8 @@ in
|
||||
${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 {
|
||||
|
||||
@@ -9,7 +9,11 @@ let
|
||||
v6dev = if v6ppp then cfg.wan.ipv4.interface else cfg.wan.interface;
|
||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||
vlan = cfg.wan.ipv4.pppoe.vlan;
|
||||
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
|
||||
vlanDevice =
|
||||
if cfg.wan.ipv4.pppoe.vlanName != null then
|
||||
cfg.wan.ipv4.pppoe.vlanName
|
||||
else
|
||||
"${cfg.wan.interface}.${toString vlan}";
|
||||
wanVlanOn = parent: lib.optional (pppoe && vlan != null && parent == cfg.wan.interface) vlanDevice;
|
||||
|
||||
lans = lib.attrValues cfg.lans;
|
||||
@@ -38,6 +42,32 @@ in
|
||||
lib.attrValues cfg.lans
|
||||
);
|
||||
|
||||
# Rename network cards by MAC address. Only at boot: the interface that is
|
||||
# up cannot be renamed, and router-apply stages such a change for the
|
||||
# next boot instead of activating it (see the names file below).
|
||||
systemd.network.links = lib.mapAttrs' (
|
||||
name: i:
|
||||
lib.nameValuePair "05-${name}" {
|
||||
matchConfig = {
|
||||
PermanentMACAddress = i.mac;
|
||||
Type = "ether";
|
||||
};
|
||||
linkConfig.Name = name;
|
||||
}
|
||||
) cfg.interfaces;
|
||||
|
||||
# The names this system gives to devices; router-apply compares the file
|
||||
# between the running and the new system and stages a change of a name for
|
||||
# the next boot (a device that is up cannot be renamed).
|
||||
environment.etc."router/interface-names".text =
|
||||
lib.concatMapStrings (l: l + "\n") (
|
||||
lib.sort lib.lessThan (
|
||||
lib.mapAttrsToList (name: i: "nic ${i.mac} ${name}") cfg.interfaces
|
||||
++ map (l: "vlan ${l.interface} ${toString l.vlan} ${l.device}") vlanLans
|
||||
++ lib.optional (pppoe && vlan != null) "vlan ${cfg.wan.interface} ${toString vlan} ${vlanDevice}"
|
||||
)
|
||||
);
|
||||
|
||||
boot.kernel.sysctl = {
|
||||
"net.ipv4.conf.all.forwarding" = 1;
|
||||
"net.ipv6.conf.all.forwarding" = 1;
|
||||
|
||||
@@ -7,6 +7,29 @@ in
|
||||
options.router = {
|
||||
enable = mkEnableOption "the router role";
|
||||
|
||||
interfaces = mkOption {
|
||||
default = { };
|
||||
example = {
|
||||
wan.mac = "52:54:00:12:34:56";
|
||||
};
|
||||
description = ''
|
||||
Names for physical interfaces, keyed by the name to give: the network
|
||||
card with the given MAC address is called that from the next boot on
|
||||
(`wan.interface` and `lans.*.interface` use these names). Interfaces
|
||||
that are not listed keep the kernel's name. Names cannot change while
|
||||
the system runs, so a change only takes effect after a reboot.
|
||||
'';
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options.mac = mkOption {
|
||||
type = types.strMatching "([0-9a-f]{2}:){5}[0-9a-f]{2}";
|
||||
example = "52:54:00:12:34:56";
|
||||
description = "Permanent MAC address of the network card, in lower case.";
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
wan = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
@@ -37,6 +60,12 @@ in
|
||||
default = null;
|
||||
description = "VLAN ID the ISP requires for PPPoE, or null for untagged.";
|
||||
};
|
||||
vlanName = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "isp";
|
||||
description = "Name of the VLAN device of the PPPoE session; null: `<interface>.<vlan>`.";
|
||||
};
|
||||
username = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
@@ -117,6 +146,16 @@ in
|
||||
description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`.";
|
||||
};
|
||||
|
||||
deviceName = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "dmz";
|
||||
description = ''
|
||||
Name of the VLAN device (only with `vlan`); null: `<interface>.<vlan>`.
|
||||
Like `interfaces`, a changed name is safe only after a reboot.
|
||||
'';
|
||||
};
|
||||
|
||||
device = mkOption {
|
||||
type = types.str;
|
||||
readOnly = true;
|
||||
@@ -230,7 +269,12 @@ in
|
||||
};
|
||||
|
||||
config.device =
|
||||
if config.vlan == null then config.interface else "${config.interface}.${toString config.vlan}";
|
||||
if config.vlan == null then
|
||||
config.interface
|
||||
else if config.deviceName != null then
|
||||
config.deviceName
|
||||
else
|
||||
"${config.interface}.${toString config.vlan}";
|
||||
|
||||
# "invalid" is only ever seen when assertions.nix is already failing.
|
||||
config.ipv4.subnet =
|
||||
@@ -492,11 +536,13 @@ in
|
||||
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; `from` may also be
|
||||
the reserved zone `wan` (traffic arriving on the uplinks), which
|
||||
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` and protocol (and ports for tcp/udp), so a typo cannot
|
||||
expose a whole zone. Address translation is separate (`firewall.nat`);
|
||||
`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 (
|
||||
@@ -508,7 +554,7 @@ in
|
||||
};
|
||||
to = mkOption {
|
||||
type = types.str;
|
||||
description = "Destination zone.";
|
||||
description = "Destination zone, or `local` (the router itself).";
|
||||
};
|
||||
protocol = mkOption {
|
||||
type = types.enum [
|
||||
|
||||
@@ -12,7 +12,13 @@ let
|
||||
pppoe = wan.ipv4.pppoe;
|
||||
|
||||
# Device carrying the PPPoE session: the VLAN on the uplink, or the uplink itself.
|
||||
device = if pppoe.vlan == null then wan.interface else "${wan.interface}.${toString pppoe.vlan}";
|
||||
device =
|
||||
if pppoe.vlan == null then
|
||||
wan.interface
|
||||
else if pppoe.vlanName != null then
|
||||
pppoe.vlanName
|
||||
else
|
||||
"${wan.interface}.${toString pppoe.vlan}";
|
||||
|
||||
# Unit that appears once udev has seen the device (VLAN devices are created
|
||||
# by networkd after boot has begun, so pppd must not start before it).
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
# (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.
|
||||
# comes back. A change of interface names is only staged for the next boot.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
options,
|
||||
...
|
||||
}:
|
||||
let
|
||||
@@ -38,6 +39,8 @@ let
|
||||
runtimeInputs = [
|
||||
config.nix.package
|
||||
pkgs.coreutils
|
||||
pkgs.gawk
|
||||
pkgs.gnugrep
|
||||
];
|
||||
text = ''
|
||||
timeout=''${1:-${config.router.rollback.timeout}}
|
||||
@@ -56,6 +59,24 @@ let
|
||||
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"
|
||||
@@ -63,6 +84,38 @@ let
|
||||
'';
|
||||
};
|
||||
|
||||
# What the web UI's MCP endpoint offers an assistant as the router's options.
|
||||
optionsJson =
|
||||
(pkgs.nixosOptionsDoc {
|
||||
options = {
|
||||
inherit (options) router;
|
||||
};
|
||||
warningsAreErrors = false;
|
||||
}).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
|
||||
{
|
||||
@@ -155,6 +208,17 @@ in
|
||||
};
|
||||
# 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;
|
||||
@@ -197,7 +261,7 @@ in
|
||||
lib.optionalString (
|
||||
config.router.acme.enable && config.router.acme.domains != [ ]
|
||||
) " -acme-dir /var/lib/acme/${lib.head config.router.acme.domains}"
|
||||
} -config ${cfg.directory} -state ${cfg.stateDirectory}";
|
||||
} -options ${optionsJson} -config ${cfg.directory} -state ${cfg.stateDirectory}";
|
||||
Restart = "on-failure";
|
||||
# It edits settings and secrets and talks to systemd; nothing else.
|
||||
ProtectSystem = "strict";
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"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
|
||||
}
|
||||
|
||||
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"},
|
||||
{"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)"},
|
||||
{"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"},
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
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
|
||||
}
|
||||
if m, ok := res.(map[string]any); ok && rt.tool == "get_settings" {
|
||||
if e, ok := m["etag"].(string); ok {
|
||||
w.Header().Set("ETag", `"`+e+`"`)
|
||||
}
|
||||
}
|
||||
if m, ok := res.(map[string]any); ok && rt.tool == "update_settings" {
|
||||
if e, ok := m["etag"].(string); ok {
|
||||
w.Header().Set("ETag", `"`+e+`"`)
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
func openAPISpec(version 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"}}}}
|
||||
}
|
||||
paths := map[string]any{}
|
||||
for _, rt := range apiRoutes {
|
||||
tool := toolNamed(rt.tool)
|
||||
op := map[string]any{
|
||||
"operationId": rt.tool,
|
||||
"summary": rt.summary,
|
||||
"description": tool.description,
|
||||
"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 of " + rt.tool, "content": map[string]any{
|
||||
"application/json": map[string]any{"schema": map[string]any{}},
|
||||
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
|
||||
}},
|
||||
"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)
|
||||
for name, p := range props {
|
||||
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"]})
|
||||
}
|
||||
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
|
||||
}
|
||||
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.")},
|
||||
}}
|
||||
return map[string]any{
|
||||
"openapi": "3.1.0",
|
||||
"info": map[string]any{
|
||||
"title": "nixos-router",
|
||||
"version": version,
|
||||
"description": "The router's management API, equal to its MCP server: read its state and logs, edit the saved settings. " +
|
||||
"Changes are saved, never applied: a person applies them in the web UI. Tokens are made on the API access page (scope read or change).",
|
||||
},
|
||||
"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": map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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{}, []string{"--lines=100"}, false},
|
||||
{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{Lines: 5000}, nil, true},
|
||||
{logQuery{Priority: "loud"}, nil, true},
|
||||
{logQuery{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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 map[string]any{"status": "running", "next": "Still evaluating; call check_settings again in a few seconds."}, nil
|
||||
}
|
||||
|
||||
func (a *app) checkReport(r CheckResult, current bool) map[string]any {
|
||||
out := map[string]any{"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
|
||||
}
|
||||
@@ -70,6 +70,7 @@ type SystemStatus struct {
|
||||
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
|
||||
@@ -262,6 +263,14 @@ func systemStatus() SystemStatus {
|
||||
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], " ")
|
||||
|
||||
@@ -26,6 +26,8 @@ type Prefs struct {
|
||||
// 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"}}
|
||||
@@ -400,18 +402,35 @@ func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess ses
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
targets, err := parsePingTargets(r.PostFormValue("pingTargets"))
|
||||
_ = 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 {
|
||||
prefs := a.sys.loadPrefs()
|
||||
prefs.PingTargets = targets
|
||||
err = a.sys.savePrefs(prefs)
|
||||
}
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets not saved: "+err.Error()), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" not saved: "+err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets saved."), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" saved."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// backup streams the state directory (settings, secrets, certificate, metric
|
||||
|
||||
@@ -201,3 +201,35 @@ func keys(m map[string]string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTheme(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
token := csrfFrom(t, do(h, "GET", "/advanced", nil, cookie).Body.String())
|
||||
has := func(path, want string, ck *http.Cookie) bool {
|
||||
return strings.Contains(do(h, "GET", path, nil, ck).Body.String(), want)
|
||||
}
|
||||
if has("/advanced", "data-theme", cookie) || !has("/login", "<body class=\"bare\"", nil) {
|
||||
t.Fatal("default must follow the browser; the login page has no sidebar")
|
||||
}
|
||||
save := func(theme string) {
|
||||
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "theme": {theme}}, cookie)
|
||||
}
|
||||
save("dark")
|
||||
if sys.loadPrefs().Theme != "dark" || !has("/", `data-theme="dark"`, cookie) || !has("/login", `data-theme="dark"`, nil) {
|
||||
t.Fatal("dark not applied")
|
||||
}
|
||||
save("neon")
|
||||
if sys.loadPrefs().Theme != "dark" {
|
||||
t.Fatal("an unknown theme was stored")
|
||||
}
|
||||
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "pingTargets": {"1.1.1.1"}}, cookie)
|
||||
if sys.loadPrefs().Theme != "dark" {
|
||||
t.Fatal("saving the ping targets dropped the theme")
|
||||
}
|
||||
save("")
|
||||
if has("/advanced", "data-theme", cookie) {
|
||||
t.Fatal("automatic not restored")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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))
|
||||
}
|
||||
@@ -206,8 +206,8 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
|
||||
case !nameRe.MatchString(m.Zone):
|
||||
errs[fmt.Sprintf("member[%d]", i)] = "letters, digits and dashes, starting with a letter"
|
||||
continue
|
||||
case m.Zone == "wan":
|
||||
errs[fmt.Sprintf("member[%d]", i)] = "wan is the uplink's zone and cannot be assigned"
|
||||
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
|
||||
@@ -226,8 +226,8 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
|
||||
declared := slices.Clone(s.declaredZones())
|
||||
for _, z := range splitList(zf.New) {
|
||||
switch {
|
||||
case !nameRe.MatchString(z) || z == "wan":
|
||||
errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan is taken)"
|
||||
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)
|
||||
}
|
||||
@@ -279,9 +279,12 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule
|
||||
if !slices.Contains(zones, r.From) && r.From != "wan" {
|
||||
errs[field("from")] = "choose a zone"
|
||||
}
|
||||
if !slices.Contains(zones, r.To) {
|
||||
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"
|
||||
}
|
||||
@@ -312,7 +315,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule
|
||||
}
|
||||
if r.From == "wan" {
|
||||
// What the module requires, so that a typo cannot open a whole zone.
|
||||
if r.Destination == "" {
|
||||
if r.Destination == "" && r.To != "local" {
|
||||
errs[field("destination")] = "rules from the internet must name the host or network inside"
|
||||
}
|
||||
if r.Protocol == "any" {
|
||||
@@ -402,6 +405,7 @@ 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
|
||||
@@ -414,6 +418,7 @@ func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []Zo
|
||||
_, 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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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)$`)
|
||||
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 != "":
|
||||
if !unitRe.MatchString(q.Unit) {
|
||||
return nil, argError("unit: a unit name like router-webui or router-pppoe.service, or kernel")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
var logsTool = mcpTool{
|
||||
name: "get_logs", scope: scopeRead,
|
||||
description: "Lines of the system journal, oldest first (the last `lines` that match). Without `unit` all units. Useful units: router-pppoe (the PPPoE connection: connects and disconnects), router-webui, router-apply, systemd-networkd, kea-dhcp4-server, nftables, wireguard-*; `kernel` for the kernel log. Search a text with `grep`, narrow by `since` (\"1 hour ago\", \"2026-10-31 10:00:00\"), `priority` (err = errors and worse) or `boot` (0 this boot, -1 the previous).",
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"unit": map[string]any{"type": "string", "description": "A systemd unit (router-pppoe, router-webui.service, ...) or `kernel`."},
|
||||
"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."},
|
||||
},
|
||||
},
|
||||
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
|
||||
},
|
||||
}
|
||||
+106
-9
@@ -83,6 +83,7 @@ func main() {
|
||||
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 != "" {
|
||||
@@ -106,7 +107,7 @@ func main() {
|
||||
metrics := newCollector(sys, *leases)
|
||||
srv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: newApp(sys, metrics, *dev != "").routes(),
|
||||
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,
|
||||
}
|
||||
@@ -217,10 +218,19 @@ type app struct {
|
||||
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
|
||||
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)}
|
||||
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 {
|
||||
@@ -257,7 +267,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
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"} {
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "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 {
|
||||
@@ -301,6 +311,7 @@ func (a *app) routes() http.Handler {
|
||||
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))
|
||||
@@ -326,6 +337,12 @@ func (a *app) routes() http.Handler {
|
||||
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("/mcp", a.mcp)
|
||||
a.registerAPI(mux)
|
||||
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -349,9 +366,11 @@ type page struct {
|
||||
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)
|
||||
}
|
||||
@@ -555,8 +574,85 @@ type networkData struct {
|
||||
}
|
||||
|
||||
// devices lists what the interface dropdowns offer for the form f.
|
||||
func (a *app) devices(f Form) []Device {
|
||||
return f.devices(Interfaces(), a.sys.loadPrefs().VLANs)
|
||||
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
|
||||
@@ -588,7 +684,7 @@ func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
}
|
||||
case r.PostFormValue("remove") != "":
|
||||
dev := r.PostFormValue("remove")
|
||||
for _, d := range s.form().devices(Interfaces(), prefs.VLANs) {
|
||||
for _, d := range a.devices(s, s.form()) {
|
||||
if d.Name == dev && !d.Removable {
|
||||
msg = dev + " is in use."
|
||||
}
|
||||
@@ -627,7 +723,7 @@ func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session)
|
||||
}
|
||||
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(f)}
|
||||
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(s, f)}
|
||||
a.render(w, "network", p)
|
||||
}
|
||||
|
||||
@@ -678,7 +774,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
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(f)}
|
||||
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(s, f)}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
@@ -701,6 +797,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
type advancedData struct {
|
||||
Settings string
|
||||
PingTargets string
|
||||
Theme string
|
||||
}
|
||||
|
||||
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
@@ -714,7 +811,7 @@ func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session)
|
||||
}
|
||||
|
||||
func (a *app) advancedData(settings string) advancedData {
|
||||
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", ")}
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
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
|
||||
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)
|
||||
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) any {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return nil
|
||||
}
|
||||
return math.Round(f*10) / 10
|
||||
}
|
||||
|
||||
var emptySchema = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
|
||||
var mcpTools = []mcpTool{
|
||||
{
|
||||
name: "get_status", scope: scopeRead, schema: emptySchema,
|
||||
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
|
||||
var pings []map[string]any
|
||||
for _, p := range snap.Pings {
|
||||
pings = append(pings, map[string]any{"target": p.Target, "address": p.Address, "ok": p.OK, "rttMs": p.RTT.Milliseconds()})
|
||||
}
|
||||
return map[string]any{
|
||||
"time": snap.Time,
|
||||
"uplink": map[string]any{"device": u.Device, "type": u.Type, "connected": u.Connected, "reachable": u.Reachable,
|
||||
"ipv4": u.IPv4, "ipv6": u.IPv6, "gateway": u.Gateway, "since": u.Since},
|
||||
"pings": pings,
|
||||
"system": map[string]any{"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": map[string]any{"unapplied": st.Unapplied, "applying": st.Applying,
|
||||
"lastApplyResult": st.ApplyResult, "confirmationDueInSeconds": int(st.RollbackIn.Seconds())},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_settings", scope: scopeRead, schema: emptySchema,
|
||||
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 any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"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,
|
||||
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
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, d := range a.devices(s, s.form()) {
|
||||
e := map[string]any{"name": d.Name, "kind": d.Kind, "usedAs": d.Role}
|
||||
if d.Kernel != "" {
|
||||
e["kernelName"] = d.Kernel
|
||||
}
|
||||
if d.MAC != "" {
|
||||
e["mac"] = d.MAC
|
||||
}
|
||||
if d.Custom != "" {
|
||||
e["deviceName"] = d.Custom
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema,
|
||||
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
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
|
||||
out = append(out, map[string]any{"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,
|
||||
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 map[string]any{"installed": a.sys.installedRelease(), "running": st.Running, "lastResult": st.Result, "log": st.Log}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_apply_log", scope: scopeRead, schema: emptySchema,
|
||||
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 map[string]any{"applying": st.Applying, "result": st.ApplyResult, "log": st.Log}, nil
|
||||
},
|
||||
},
|
||||
logsTool,
|
||||
{
|
||||
name: "check_settings", scope: scopeRead,
|
||||
schema: emptySchema,
|
||||
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,
|
||||
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")
|
||||
}
|
||||
a.settingsMu.Lock()
|
||||
defer a.settingsMu.Unlock()
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Etag != etag(data) {
|
||||
return nil, errStale
|
||||
}
|
||||
old, err := parseSettings(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next, err := parseSettings(data) // a copy to patch
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patched, _ := mergePatch(map[string]any(next), in.Patch).(map[string]any)
|
||||
next = Settings(patched)
|
||||
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 map[string]any{"saved": true, "applied": false, "etag": etag(after),
|
||||
"changes": diffLines(pretty(data), pretty(after)),
|
||||
"next": "Run check_settings; a person applies the changes in the web UI."}, nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rpc posts a JSON-RPC request to /mcp.
|
||||
func rpc(h http.Handler, token, method string, params any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(string(body)))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec, out
|
||||
}
|
||||
|
||||
// call runs a tool and returns its text and whether it failed.
|
||||
func call(t *testing.T, h http.Handler, token, tool string, args any) (string, bool) {
|
||||
t.Helper()
|
||||
rec, out := rpc(h, token, "tools/call", map[string]any{"name": tool, "arguments": args})
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("%s: HTTP %d", tool, rec.Code)
|
||||
}
|
||||
if out["error"] != nil {
|
||||
return out["error"].(map[string]any)["message"].(string), true
|
||||
}
|
||||
res := out["result"].(map[string]any)
|
||||
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
failed, _ := res["isError"].(bool)
|
||||
return text, failed
|
||||
}
|
||||
|
||||
func TestMCPAuth(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
if rec, _ := rpc(h, "", "ping", nil); rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatalf("no token: %d", rec.Code)
|
||||
}
|
||||
if rec, _ := rpc(h, "rtr_nonsense", "ping", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong token: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "GET", "/mcp", nil, nil); rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET: %d", rec.Code)
|
||||
}
|
||||
_, secret, err := a.tokens.Create("test", "read")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign origin: %d", rec.Code)
|
||||
}
|
||||
// A session cookie is no token.
|
||||
cookie := login(t, h)
|
||||
req = httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`))
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session cookie: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPProtocol(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
_, read, _ := a.tokens.Create("reader", "read")
|
||||
_, change, _ := a.tokens.Create("writer", "change")
|
||||
|
||||
rec, out := rpc(h, read, "initialize", map[string]any{"protocolVersion": "2025-03-26"})
|
||||
if rec.Code != 200 || out["result"].(map[string]any)["protocolVersion"] != "2025-03-26" {
|
||||
t.Fatalf("initialize: %s", rec.Body)
|
||||
}
|
||||
// A notification gets no body.
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+read)
|
||||
nrec := httptest.NewRecorder()
|
||||
h.ServeHTTP(nrec, req)
|
||||
if nrec.Code != http.StatusAccepted || nrec.Body.Len() != 0 {
|
||||
t.Fatalf("notification: %d %q", nrec.Code, nrec.Body)
|
||||
}
|
||||
if _, out := rpc(h, read, "nonsense", nil); out["error"] == nil {
|
||||
t.Fatal("unknown method accepted")
|
||||
}
|
||||
|
||||
names := func(token string) string {
|
||||
_, out := rpc(h, token, "tools/list", nil)
|
||||
var n []string
|
||||
for _, t := range out["result"].(map[string]any)["tools"].([]any) {
|
||||
n = append(n, t.(map[string]any)["name"].(string))
|
||||
}
|
||||
return strings.Join(n, " ")
|
||||
}
|
||||
if got := names(read); strings.Contains(got, "update_settings") || !strings.Contains(got, "get_settings") {
|
||||
t.Fatalf("read tools: %s", got)
|
||||
}
|
||||
if got := names(change); !strings.Contains(got, "update_settings") || !strings.Contains(got, "discard_changes") {
|
||||
t.Fatalf("change tools: %s", got)
|
||||
}
|
||||
if text, failed := call(t, h, read, "update_settings", map[string]any{}); !failed || !strings.Contains(text, "unknown tool") {
|
||||
t.Fatalf("a read token edited settings: %s", text)
|
||||
}
|
||||
|
||||
// Reading.
|
||||
text, failed := call(t, h, read, "get_settings", nil)
|
||||
if failed {
|
||||
t.Fatal(text)
|
||||
}
|
||||
var got struct {
|
||||
Etag string
|
||||
Settings map[string]any
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &got); err != nil || got.Etag == "" || got.Settings["router"] == nil {
|
||||
t.Fatalf("get_settings: %v %s", err, text)
|
||||
}
|
||||
if _, failed := call(t, h, read, "get_status", nil); failed {
|
||||
t.Fatal("get_status failed")
|
||||
}
|
||||
if text, failed := call(t, h, read, "list_interfaces", nil); failed || !strings.Contains(text, "WAN") {
|
||||
t.Fatalf("list_interfaces: %s", text)
|
||||
}
|
||||
if _, failed := call(t, h, read, "list_dhcp_leases", nil); failed {
|
||||
t.Fatal("list_dhcp_leases failed")
|
||||
}
|
||||
|
||||
// Editing: a patch merges, a stale etag is refused, nothing is applied.
|
||||
patch := map[string]any{"etag": got.Etag, "patch": map[string]any{"system": map[string]any{"hostName": "assistant-was-here"}}}
|
||||
text, failed = call(t, h, change, "update_settings", patch)
|
||||
if failed || !strings.Contains(text, "assistant-was-here") || !strings.Contains(text, `"applied": false`) {
|
||||
t.Fatalf("update_settings: %s", text)
|
||||
}
|
||||
if data, _ := os.ReadFile(sys.settingsPath()); !strings.Contains(string(data), "assistant-was-here") {
|
||||
t.Fatal("not saved")
|
||||
}
|
||||
if text, failed := call(t, h, change, "update_settings", patch); !failed || !strings.Contains(text, "etag") {
|
||||
t.Fatalf("a stale etag was accepted: %s", text)
|
||||
}
|
||||
// A change shows up for review (the confirmed copy is the sample).
|
||||
os.WriteFile(sys.confirmedPath(), []byte(sample), 0o600)
|
||||
if text, _ := call(t, h, read, "get_pending_changes", nil); !strings.Contains(text, "+") || !strings.Contains(text, "assistant-was-here") {
|
||||
t.Fatalf("pending: %s", text)
|
||||
}
|
||||
if text, failed := call(t, h, change, "discard_changes", nil); failed {
|
||||
t.Fatal(text)
|
||||
}
|
||||
if data, _ := os.ReadFile(sys.settingsPath()); strings.Contains(string(data), "assistant-was-here") {
|
||||
t.Fatal("discard did not restore the settings")
|
||||
}
|
||||
|
||||
// Every call was logged, with the token's name.
|
||||
audit := sys.RecentAudit(50)
|
||||
if len(audit) < 5 || audit[0].Tool != "discard_changes" || audit[0].Token != "writer" {
|
||||
t.Fatalf("audit: %+v", audit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPEditLimits(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
_, change, _ := a.tokens.Create("writer", "change")
|
||||
etagNow := func() string {
|
||||
data, _ := os.ReadFile(sys.settingsPath())
|
||||
return etag(data)
|
||||
}
|
||||
for name, patch := range map[string]map[string]any{
|
||||
"ssh keys": {"system": map[string]any{"sshKeys": []string{"ssh-ed25519 AAAA attacker"}}},
|
||||
"state version": {"system": map[string]any{"stateVersion": "20.09"}},
|
||||
"schema": {"version": 99},
|
||||
"top level": {"users": map[string]any{"x": 1}},
|
||||
"secret path": {"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": "/etc/shadow"}}}}},
|
||||
"path escape": {"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": sys.StateDir + "/../etc/shadow"}}}}},
|
||||
"nested file": {"router": map[string]any{"wireguard": map[string]any{"wg0": map[string]any{"privateKeyFile": "/root/.ssh/id_rsa"}}}},
|
||||
} {
|
||||
before, _ := os.ReadFile(sys.settingsPath())
|
||||
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": patch}); !failed {
|
||||
t.Errorf("%s accepted: %s", name, text)
|
||||
}
|
||||
if after, _ := os.ReadFile(sys.settingsPath()); string(after) != string(before) {
|
||||
t.Errorf("%s changed the file", name)
|
||||
}
|
||||
}
|
||||
// Files in the state directory, and null to remove a key, are fine.
|
||||
ok := map[string]any{"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": sys.StateDir + "/pppoe-password"}}}}}
|
||||
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": ok}); failed {
|
||||
t.Fatalf("a file in the state directory refused: %s", text)
|
||||
}
|
||||
rm := map[string]any{"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": nil}}}}
|
||||
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": rm}); failed || strings.Contains(text, "pppoe-password\"") && !strings.Contains(text, "-") {
|
||||
t.Fatalf("null did not remove: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePatchAndDiff(t *testing.T) {
|
||||
got, _ := json.Marshal(mergePatch(
|
||||
map[string]any{"a": map[string]any{"b": 1, "c": 2}, "d": []any{1, 2}, "e": "x"},
|
||||
map[string]any{"a": map[string]any{"b": nil, "z": 3}, "d": []any{9}}))
|
||||
if string(got) != `{"a":{"c":2,"z":3},"d":[9],"e":"x"}` {
|
||||
t.Fatalf("merge: %s", got)
|
||||
}
|
||||
if diffLines("a\nb", "a\nb") != "" {
|
||||
t.Fatal("equal texts differ")
|
||||
}
|
||||
d := diffLines("1\n2\n3\n4\n5\n6\n7\n8\n9", "1\n2\n3\nfour\n5\n6\n7\n8\n9")
|
||||
if !strings.Contains(d, "- 4\n+ four\n") || !strings.Contains(d, "…") {
|
||||
t.Fatalf("diff:\n%s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensPage(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
token := csrfFrom(t, do(h, "GET", "/tokens", nil, cookie).Body.String())
|
||||
rec := do(h, "POST", "/tokens", map[string][]string{"csrf": {token}, "name": {"laptop"}, "scope": {"read"}}, cookie)
|
||||
body := rec.Body.String()
|
||||
i := strings.Index(body, "rtr_")
|
||||
if rec.Code != 200 || i < 0 {
|
||||
t.Fatalf("create: %d\n%s", rec.Code, body)
|
||||
}
|
||||
secret := body[i : i+4+43]
|
||||
if _, ok := a.tokens.Lookup(secret); !ok {
|
||||
t.Fatal("the shown token does not work")
|
||||
}
|
||||
// The secret is not stored or shown again.
|
||||
if data, _ := os.ReadFile(a.sys.tokensPath()); strings.Contains(string(data), secret) {
|
||||
t.Fatal("the secret is stored")
|
||||
}
|
||||
if strings.Contains(do(h, "GET", "/tokens", nil, cookie).Body.String(), secret) {
|
||||
t.Fatal("the secret is shown again")
|
||||
}
|
||||
toks := a.tokens.List()
|
||||
do(h, "POST", "/tokens/revoke", map[string][]string{"csrf": {token}, "id": {toks[0].ID}}, cookie)
|
||||
if _, ok := a.tokens.Lookup(secret); ok {
|
||||
t.Fatal("a revoked token works")
|
||||
}
|
||||
if rec := do(h, "GET", "/changes", nil, cookie); rec.Code != 200 {
|
||||
t.Fatalf("changes page: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,10 @@ type Device struct {
|
||||
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
|
||||
@@ -377,6 +381,7 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
|
||||
pppoe["vlan"] = wanVLAN
|
||||
} else {
|
||||
delete(pppoe, "vlan")
|
||||
delete(pppoe, "vlanName")
|
||||
}
|
||||
if f.WAN.PPPoEPassword != "" {
|
||||
pppoe["passwordFile"] = passwordFile
|
||||
@@ -431,6 +436,7 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
|
||||
l["vlan"] = lanVLAN
|
||||
} else {
|
||||
delete(l, "vlan")
|
||||
delete(l, "deviceName")
|
||||
}
|
||||
l["internet"] = lf.Internet
|
||||
if lf.IPv6PD {
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
// Menus close on a click elsewhere; forms with data-confirm ask first.
|
||||
// Close an open menu when the click is somewhere else.
|
||||
document.addEventListener("click", function (e) {
|
||||
document.querySelectorAll("nav details[open]").forEach(function (d) {
|
||||
if (!d.contains(e.target)) d.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
|
||||
// Ask before a form marked data-confirm is sent.
|
||||
document.addEventListener("submit", function (e) {
|
||||
var q = e.target.getAttribute("data-confirm");
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
/* Minimal, neutral, system fonts. Colours are tokens; dark follows the system. */
|
||||
/* 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: #c8e6c9; --muted: #6f8f72; --bg: #0a0c0a; --surface: #0d110d; --line: #1f3322; --field: #0a0c0a;
|
||||
--accent: #4ade80; --on-accent: #04140a; --warn: #2b2a10; --bad: #ff6b6b; --ok: #10281a; --good: #4ade80;
|
||||
--radius: 0; --shadow: none;
|
||||
--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
--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;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: dark; scrollbar-color: #2e4a33 var(--bg); scrollbar-width: thin; }
|
||||
* { scrollbar-color: #2e4a33 var(--bg); scrollbar-width: thin; }
|
||||
body { margin: 0; font: 13.5px/1.55 var(--mono); color: var(--fg); background: var(--bg); -webkit-font-smoothing: antialiased; }
|
||||
@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; }
|
||||
header { position: sticky; top: 0; z-index: 20; display: flex; gap: 2rem; align-items: center; padding: .6rem 1.25rem;
|
||||
background: var(--bg); border-bottom: 1px solid var(--accent); flex-wrap: wrap; }
|
||||
header > strong { font-weight: 500; }
|
||||
header > strong::before { content: "> "; color: var(--accent); }
|
||||
header > strong::after { content: "_"; color: var(--accent); }
|
||||
nav { display: flex; gap: .25rem; align-items: center; flex-wrap: wrap; }
|
||||
nav > a, nav summary, nav button.link { padding: .3rem .65rem; color: var(--fg); text-decoration: none; cursor: pointer; }
|
||||
nav > a:hover, nav summary:hover, nav details[open] > summary, nav button.link:hover { background: color-mix(in srgb, var(--fg) 7%, transparent); }
|
||||
nav form { display: inline; }
|
||||
nav details { position: relative; margin: 0; }
|
||||
nav summary { list-style: none; }
|
||||
nav summary::-webkit-details-marker { display: none; }
|
||||
nav summary::after { content: ""; display: inline-block; width: .35rem; height: .35rem; margin: 0 0 .15rem .45rem; border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); opacity: .55; }
|
||||
nav details > div { position: absolute; z-index: 30; top: 2.2rem; left: 0; min-width: 12rem; display: flex; flex-direction: column; gap: 1px;
|
||||
padding: .35rem; background: var(--surface); border: 1px solid var(--accent); }
|
||||
nav details > div a { padding: .4rem .65rem; border-radius: 0; color: var(--fg); text-decoration: none; }
|
||||
nav details > div a:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); }
|
||||
main { max-width: 70rem; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; }
|
||||
h1 { font-size: 1.15rem; font-weight: 500; text-transform: uppercase; letter-spacing: .12em; margin: .25rem 0 1.25rem; }
|
||||
h1::before { content: "# "; color: var(--accent); }
|
||||
h2, legend { font-size: .85rem; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; color: var(--accent); }
|
||||
h2 { margin: 0 0 .6rem; }
|
||||
|
||||
/* 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 { max-width: 74rem; padding: 1.5rem 2rem 3rem; }
|
||||
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; }
|
||||
@@ -43,61 +61,82 @@ section.busy, section.pending { padding: .75rem 1rem; border-radius: var(--radiu
|
||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: .3rem 1.25rem; }
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
fieldset { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); margin: 0 0 1rem; padding: 1rem 1.15rem; overflow-x: auto; min-width: 0; }
|
||||
legend { float: left; width: 100%; padding: 0; margin: 0 0 .7rem; }
|
||||
|
||||
/* 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; }
|
||||
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: 0; padding: .4rem .6rem; }
|
||||
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, summary:focus-visible { outline: none; box-shadow: 0 0 0 1px var(--accent); border-color: var(--accent); }
|
||||
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 70%, transparent); }
|
||||
textarea { width: 100%; font-size: 13px; }
|
||||
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 { 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 small box inside tables */
|
||||
/* 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; width: 2.4rem; height: 1.2rem; padding: 0; margin: 0; border-radius: 0; position: relative; cursor: pointer;
|
||||
background: #1c2a1e; border: 0; transition: background .15s; vertical-align: middle; }
|
||||
input[type=checkbox]::after { content: ""; position: absolute; top: .15rem; left: .15rem; width: .9rem; height: .9rem; border-radius: 0; background: var(--muted); transition: transform .15s; }
|
||||
input[type=checkbox]:checked { background: #14532d; }
|
||||
input[type=checkbox]:checked::after { transform: translateX(1.2rem); background: var(--accent); }
|
||||
td > input[type=checkbox], th > input[type=checkbox], p > label > input[type=checkbox] { width: 1.05rem; height: 1.05rem; border-radius: 0; border: 1.5px solid color-mix(in srgb, var(--fg) 40%, transparent); background: var(--field); }
|
||||
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 { 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='%2304140a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 90% no-repeat; border-color: var(--accent); }
|
||||
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: transparent; color: var(--accent); border-color: var(--accent); cursor: pointer; text-transform: uppercase; letter-spacing: .06em; padding: .4rem 1.05rem; }
|
||||
button:hover { background: var(--accent); color: var(--on-accent); }
|
||||
button.secondary { border-color: var(--line); color: var(--fg); }
|
||||
button.secondary:hover { border-color: var(--accent); background: transparent; color: var(--accent); }
|
||||
button.link { background: none; border: none; color: var(--fg); padding: .3rem .65rem; text-transform: none; letter-spacing: 0; }
|
||||
button.link:hover { background: none; }
|
||||
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-weight: 500; color: var(--accent); font-size: .8em; text-transform: uppercase; letter-spacing: .03em; padding: 0 .4rem .3rem; white-space: nowrap; }
|
||||
td { padding: .2rem .4rem; }
|
||||
pre.log { background: color-mix(in srgb, var(--fg) 5%, transparent); 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: color-mix(in srgb, var(--fg) 7%, transparent); padding: .1em .35em; border-radius: 0; }
|
||||
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; }
|
||||
.muted { color: var(--muted); }
|
||||
.bad { color: var(--bad); }
|
||||
.ok { color: var(--good); }
|
||||
:root { --s0: #8ba4ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc; }
|
||||
|
||||
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; }
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
body { padding-left: 0; }
|
||||
header { position: static; width: auto; flex-direction: row; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; padding: .6rem 1rem; }
|
||||
nav { flex-direction: row; flex-wrap: wrap; align-items: center; gap: .1rem .25rem; }
|
||||
nav > a, nav .group a, nav button.link { width: auto; padding: .3rem .55rem; }
|
||||
nav .group { display: contents; }
|
||||
nav .group > span, nav form { display: none; }
|
||||
nav form:has(button) { display: block; margin: 0; }
|
||||
main { padding: 1rem; }
|
||||
}
|
||||
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
|
||||
.ranges a { padding: .2rem .75rem; border: 1px solid var(--line); border-radius: 0; text-decoration: none; color: var(--fg); background: var(--surface); }
|
||||
.ranges a:hover, .ranges a.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.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-left-width: 4px; border-radius: var(--radius); box-shadow: var(--shadow); padding: .85rem 1.1rem; margin: 0; }
|
||||
.card.up { border-left-color: var(--good); }
|
||||
.card.degraded { border-left-color: #e8a600; }
|
||||
.card.down { border-left-color: var(--bad); }
|
||||
.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; }
|
||||
|
||||
@@ -10,6 +10,22 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<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>
|
||||
<h2>Dashboard</h2>
|
||||
<form method="post" action="/prefs" class="narrow">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{{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>
|
||||
<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}}
|
||||
@@ -34,6 +34,7 @@
|
||||
<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}}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<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.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||
{{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>
|
||||
@@ -69,7 +69,7 @@
|
||||
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. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en"{{with .Theme}} data-theme="{{.}}"{{end}}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -7,28 +7,29 @@
|
||||
<title>{{.Title}} · router</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<body{{if not .LoggedIn}} class="bare"{{end}}>
|
||||
<header>
|
||||
<strong>router</strong>
|
||||
{{if .LoggedIn}}
|
||||
<nav>
|
||||
<a href="/">Overview</a>
|
||||
<details name="menu"><summary>Network</summary><div>
|
||||
<a href="/network">Interfaces</a>
|
||||
<a href="/dhcp">DHCP</a>
|
||||
<a href="/dns">DNS</a>
|
||||
<a href="/wireguard">WireGuard</a>
|
||||
<a href="/firewall">Firewall</a>
|
||||
</div></details>
|
||||
<details name="menu"><summary>Services</summary><div>
|
||||
<a href="/ddns">Dynamic DNS</a>
|
||||
<a href="/dnsproviders">DNS providers</a>
|
||||
<a href="/certificate">Certificate</a>
|
||||
</div></details>
|
||||
<details name="menu"><summary>System</summary><div>
|
||||
<a href="/update">Update</a>
|
||||
<a href="/advanced">Advanced</a>
|
||||
</div></details>
|
||||
<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="/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}}
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<form method="post" action="/vlan">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Interfaces</legend>
|
||||
<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>Kind</th><th>Used as</th><th></th></tr>
|
||||
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
|
||||
<td>{{if .Removable}}<button name="remove" value="{{.Name}}" class="link">remove</button>{{end}}</td></tr>{{end}}
|
||||
<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 class="sub">
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
<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}}">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</section>
|
||||
{{else if $s.Unapplied}}
|
||||
<section class="pending">
|
||||
<p>The settings have changes that are not part of the confirmed system.</p>
|
||||
<p>The settings have changes that are not part of the confirmed system. <a href="/changes">Review the changes</a>.</p>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{{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>). 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>Last used</th><th></th></tr>
|
||||
{{range .Tokens}}<tr><td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created.Format "2006-01-02"}}</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>
|
||||
<button>Create token</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>Recent calls</legend>
|
||||
{{if .Audit}}
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Time</th><th>Token</th><th>Tool</th><th>Arguments</th><th>Result</th></tr>
|
||||
{{range .Audit}}<tr><td>{{.Time.Format "2006-01-02 15:04:05"}}</td><td>{{.Token}}</td><td><code>{{.Tool}}</code></td>
|
||||
<td><code>{{.Args}}</code></td><td>{{if .Error}}<span class="bad">{{.Error}}</span>{{else}}ok{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{else}}<p class="hint">Nothing yet.</p>{{end}}
|
||||
</fieldset>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API tokens for programmatic access (the MCP endpoint). A token is shown once
|
||||
// when it is made; only its SHA-256 is stored. Scopes:
|
||||
//
|
||||
// read state and settings
|
||||
// change also edit the settings (they stay unapplied until a person applies
|
||||
// them in the web UI)
|
||||
//
|
||||
// An "apply" scope is reserved for later.
|
||||
const (
|
||||
scopeRead = "read"
|
||||
scopeChange = "change"
|
||||
tokenPrefix = "rtr_"
|
||||
maxTokens = 32
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Hash string `json:"hash"` // hex SHA-256 of the secret
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||
}
|
||||
|
||||
func (s System) tokensPath() string { return filepath.Join(s.StateDir, "webui", "tokens.json") }
|
||||
func (s System) auditPath() string { return filepath.Join(s.StateDir, "webui", "audit.log") }
|
||||
|
||||
// tokenStore keeps the tokens in a file; every call reads it, so edits and
|
||||
// restarts need no care.
|
||||
type tokenStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
lastSave map[string]time.Time // when LastUsed of a token was last written
|
||||
}
|
||||
|
||||
func newTokenStore(path string) *tokenStore {
|
||||
return &tokenStore{path: path, lastSave: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
func (t *tokenStore) load() []Token {
|
||||
data, err := os.ReadFile(t.path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var toks []Token
|
||||
if json.Unmarshal(data, &toks) != nil {
|
||||
return nil
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
func (t *tokenStore) save(toks []Token) error {
|
||||
data, err := json.MarshalIndent(toks, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(t.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(t.path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func hashSecret(secret string) string {
|
||||
h := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (t *tokenStore) List() []Token {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.load()
|
||||
}
|
||||
|
||||
// Create makes a token and returns its secret, which is not stored.
|
||||
func (t *tokenStore) Create(name, scope string) (Token, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
switch {
|
||||
case name == "" || len(name) > 40:
|
||||
return Token{}, "", errors.New("give the token a name of up to 40 characters")
|
||||
case scope != scopeRead && scope != scopeChange:
|
||||
return Token{}, "", fmt.Errorf("unknown scope %q", scope)
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
toks := t.load()
|
||||
if len(toks) >= maxTokens {
|
||||
return Token{}, "", errors.New("too many tokens; revoke one first")
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Token{}, "", err
|
||||
}
|
||||
id := make([]byte, 4)
|
||||
if _, err := rand.Read(id); err != nil {
|
||||
return Token{}, "", err
|
||||
}
|
||||
secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
|
||||
tok := Token{ID: hex.EncodeToString(id), Name: name, Scope: scope, Hash: hashSecret(secret), Created: time.Now().UTC().Truncate(time.Second)}
|
||||
return tok, secret, t.save(append(toks, tok))
|
||||
}
|
||||
|
||||
func (t *tokenStore) Revoke(id string) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
toks := t.load()
|
||||
n := len(toks)
|
||||
toks = slices.DeleteFunc(toks, func(k Token) bool { return k.ID == id })
|
||||
if len(toks) == n {
|
||||
return errors.New("no such token")
|
||||
}
|
||||
return t.save(toks)
|
||||
}
|
||||
|
||||
// Lookup finds the token for a secret and notes that it was used.
|
||||
func (t *tokenStore) Lookup(secret string) (Token, bool) {
|
||||
if !strings.HasPrefix(secret, tokenPrefix) {
|
||||
return Token{}, false
|
||||
}
|
||||
want := hashSecret(secret)
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
toks := t.load()
|
||||
found := -1
|
||||
for i, k := range toks {
|
||||
if subtle.ConstantTimeCompare([]byte(k.Hash), []byte(want)) == 1 {
|
||||
found = i
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
return Token{}, false
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
tok := toks[found]
|
||||
tok.LastUsed = now
|
||||
// Writing on every call would wear the disk; once in a few minutes is enough.
|
||||
if now.Sub(t.lastSave[tok.ID]) > 5*time.Minute {
|
||||
toks[found] = tok
|
||||
if t.save(toks) == nil {
|
||||
t.lastSave[tok.ID] = now
|
||||
}
|
||||
}
|
||||
return tok, true
|
||||
}
|
||||
|
||||
func (k Token) allows(scope string) bool {
|
||||
return scope == scopeRead || k.Scope == scopeChange
|
||||
}
|
||||
|
||||
// Audit log: one JSON line per call of a tool, kept to the last 1 MB (and the
|
||||
// file before it).
|
||||
type AuditEntry struct {
|
||||
Time time.Time `json:"time"`
|
||||
Token string `json:"token"`
|
||||
Tool string `json:"tool"`
|
||||
Args string `json:"args,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var auditMu sync.Mutex
|
||||
|
||||
func (s System) audit(e AuditEntry) {
|
||||
auditMu.Lock()
|
||||
defer auditMu.Unlock()
|
||||
if len(e.Args) > 300 {
|
||||
e.Args = e.Args[:300] + "…"
|
||||
}
|
||||
path := s.auditPath()
|
||||
if fi, err := os.Stat(path); err == nil && fi.Size() > 1<<20 {
|
||||
_ = os.Rename(path, path+".1")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
line, _ := json.Marshal(e)
|
||||
fmt.Fprintf(f, "%s\n", line)
|
||||
}
|
||||
|
||||
// RecentAudit returns the last n entries, newest first.
|
||||
func (s System) RecentAudit(n int) []AuditEntry {
|
||||
auditMu.Lock()
|
||||
defer auditMu.Unlock()
|
||||
f, err := os.Open(s.auditPath())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var all []AuditEntry
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
for sc.Scan() {
|
||||
var e AuditEntry
|
||||
if json.Unmarshal(sc.Bytes(), &e) == nil {
|
||||
all = append(all, e)
|
||||
}
|
||||
}
|
||||
slices.Reverse(all)
|
||||
if len(all) > n {
|
||||
all = all[:n]
|
||||
}
|
||||
return all
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type tokensData struct {
|
||||
Tokens []Token
|
||||
Audit []AuditEntry
|
||||
URL string // the endpoint, for the example
|
||||
Secret string // a token made just now: shown once
|
||||
Name string
|
||||
HasDoc bool
|
||||
}
|
||||
|
||||
func (a *app) tokensData(r *http.Request) tokensData {
|
||||
return tokensData{Tokens: a.tokens.List(), Audit: a.sys.RecentAudit(30), URL: "https://" + r.Host + "/mcp", HasDoc: a.optionsFile != ""}
|
||||
}
|
||||
|
||||
func (a *app) tokensPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
a.render(w, "tokens", page{Title: "API access", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"), Data: a.tokensData(r)})
|
||||
}
|
||||
|
||||
func (a *app) tokenCreate(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
tok, secret, err := a.tokens.Create(r.PostFormValue("name"), r.PostFormValue("scope"))
|
||||
p := page{Title: "API access", CSRF: sess.csrf, LoggedIn: true}
|
||||
d := a.tokensData(r)
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
} else {
|
||||
d.Secret, d.Name = secret, tok.Name
|
||||
p.Flash = "Token " + tok.Name + " created. Copy it now: it is not shown again."
|
||||
}
|
||||
p.Data = d
|
||||
a.render(w, "tokens", p)
|
||||
}
|
||||
|
||||
func (a *app) tokenRevoke(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
msg := "Token revoked."
|
||||
if err := a.tokens.Revoke(strings.TrimSpace(r.PostFormValue("id"))); err != nil {
|
||||
msg = "Could not revoke: " + err.Error()
|
||||
}
|
||||
http.Redirect(w, r, "/tokens?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// changesPage shows what differs between the confirmed settings and the saved
|
||||
// ones, for a person to review before applying.
|
||||
func (a *app) changesPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
a.render(w, "changes", page{Title: "Changes", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{
|
||||
"Diff": a.sys.pendingChanges(), "Unapplied": a.sys.Status().Unapplied,
|
||||
}})
|
||||
}
|
||||
@@ -208,5 +208,95 @@ pkgs.testers.runNixOSTest {
|
||||
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
|
||||
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
|
||||
assert "match the confirmed system" in curl(f"{ui}/")
|
||||
|
||||
with subtest("naming the network cards in the web UI is staged for the next boot"):
|
||||
before = current()
|
||||
mac = {n: target.succeed(f"cat /sys/class/net/{n}/address").strip() for n in ("eth1", "eth2")}
|
||||
names = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
||||
"csrf": csrf("/network"), "rows": "2",
|
||||
"dev.0": "eth1", "mac.0": mac["eth1"], "from.0": "eth1", "name.0": "wan",
|
||||
"dev.1": "eth2", "mac.1": mac["eth2"], "from.1": "eth2", "name.1": "lan",
|
||||
}.items())
|
||||
assert curl(f"-o /dev/null -w '%{{http_code}}' {names} {ui}/interfaces") == "303"
|
||||
# The settings follow the names at once; the system does not.
|
||||
saved = target.succeed("cat /var/lib/router/config/settings.json")
|
||||
assert '"interface": "lan"' in saved and '"interface": "wan"' in saved and mac["eth2"] in saved, saved
|
||||
assert "after the next reboot" in curl(f"{ui}/network")
|
||||
curl(f"-d csrf={csrf('/')} {ui}/apply")
|
||||
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-apply.service) = inactive"
|
||||
" && test $(systemctl show -p Result --value router-apply.service) = success", timeout=600)
|
||||
assert current() == before
|
||||
target.fail("systemctl is-active router-rollback.timer")
|
||||
staged = target.succeed("readlink -f /nix/var/nix/profiles/system").strip()
|
||||
assert staged != before
|
||||
target.succeed(f"grep -q '^nic {mac['eth2']} lan$' {staged}/etc/router/interface-names")
|
||||
target.succeed("ip link show eth2")
|
||||
# The overview samples the system every few seconds.
|
||||
client.wait_until_succeeds(f"curl -sk -b /tmp/jar {ui}/ | grep -c 'starts at the next reboot' >/dev/null", timeout=60)
|
||||
|
||||
with subtest("after the reboot the cards have their names and the router works"):
|
||||
target.shutdown()
|
||||
target.start()
|
||||
target.wait_for_unit("multi-user.target")
|
||||
target.succeed("ip link show wan && ip link show lan")
|
||||
target.fail("ip link show eth1")
|
||||
target.wait_until_succeeds("ip -4 addr show dev lan | grep -q 192.168.10.1/24")
|
||||
target.wait_for_unit("kea-dhcp4-server.service")
|
||||
target.wait_for_unit("router-webui.service")
|
||||
client.wait_until_succeeds(f"curl -sk -o /dev/null {ui}/login", timeout=120)
|
||||
assert "match the confirmed system" in curl(f"-d password=hunter2hunter2 -L {ui}/login")
|
||||
assert "starts at the next reboot" not in curl(f"{ui}/")
|
||||
|
||||
with subtest("an assistant with a change token edits and checks the settings through MCP"):
|
||||
secret = curl(f"-d csrf={csrf('/tokens')} -d name=assistant -d scope=change {ui}/tokens | grep -o 'rtr_[A-Za-z0-9_-]*' | sed -n 1p").strip()
|
||||
assert secret.startswith("rtr_"), secret
|
||||
import json
|
||||
|
||||
def mcp(tool, args=None, token=secret):
|
||||
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args or {}}})
|
||||
out = client.succeed(f"curl -sk -H 'Authorization: Bearer {token}' -d '{body}' {ui}/mcp")
|
||||
res = json.loads(out)["result"]
|
||||
return res["content"][0]["text"], res.get("isError", False)
|
||||
|
||||
assert client.succeed(f"curl -sk -o /dev/null -w '%{{http_code}}' -d '{{}}' {ui}/mcp").strip() == "401"
|
||||
text, failed = mcp("get_settings")
|
||||
got = json.loads(text)
|
||||
assert not failed and got["settings"]["router"]["lans"]["main"], text
|
||||
assert "WAN" in mcp("list_interfaces")[0]
|
||||
assert json.loads(mcp("get_status")[0])["uplink"] is not None
|
||||
# A bad pool: saved, not applied, and the check reports the router's assertion.
|
||||
text, failed = mcp("update_settings", {"etag": got["etag"], "patch": {"router": {"lans": {"main": {"dhcp": {"pool": {"end": "10.9.9.9"}}}}}}})
|
||||
assert not failed and '"applied": false' in text, text
|
||||
text, failed = mcp("check_settings")
|
||||
for _ in range(20):
|
||||
if "running" not in text:
|
||||
break
|
||||
text, failed = mcp("check_settings")
|
||||
res = json.loads(text)
|
||||
assert res["ok"] is False and "pool is not inside" in res["error"], text
|
||||
assert current() == target.succeed("readlink -f /nix/var/nix/profiles/system").strip()
|
||||
assert "10.9.9.9" in mcp("get_pending_changes")[0]
|
||||
# Discarded, the settings evaluate again.
|
||||
assert not mcp("discard_changes")[1]
|
||||
text, failed = mcp("check_settings")
|
||||
for _ in range(20):
|
||||
if "running" not in text:
|
||||
break
|
||||
text, failed = mcp("check_settings")
|
||||
assert json.loads(text)["ok"] is True, text
|
||||
# The journal can be read, through MCP and through the REST API (same tokens).
|
||||
text, failed = mcp("get_logs", {"unit": "router-webui", "lines": 5})
|
||||
assert not failed and "router-webui" in text, text
|
||||
text, failed = mcp("get_logs", {"unit": "--vacuum-time=1s"})
|
||||
assert failed, text
|
||||
rest = f"curl -sk -H 'Authorization: Bearer {secret}'"
|
||||
assert "router-webui" in client.succeed(f"{rest} '{ui}/api/v1/logs?unit=router-webui&lines=5'")
|
||||
assert json.loads(client.succeed(f"{rest} {ui}/api/v1/status"))["uplink"] is not None
|
||||
assert client.succeed(f"{rest} -o /dev/null -w '%{{http_code}}' -X PATCH -d '{{}}' {ui}/api/v1/settings").strip() == "400"
|
||||
assert client.succeed(f"curl -sk -o /dev/null -w '%{{http_code}}' {ui}/api/v1/status").strip() == "401"
|
||||
spec = json.loads(client.succeed(f"curl -sk {ui}/api/openapi.json"))
|
||||
assert spec["openapi"].startswith("3.") and "/api/v1/logs" in spec["paths"]
|
||||
# The log shows what was done, by whom.
|
||||
assert "update_settings" in curl(f"{ui}/tokens") and "assistant" in curl(f"{ui}/tokens")
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -227,6 +227,64 @@ let
|
||||
};
|
||||
expect = [ "WAN interface `eth0` is also used by a LAN" ];
|
||||
};
|
||||
namesValid = {
|
||||
router = {
|
||||
interfaces = {
|
||||
wan.mac = "52:54:00:12:34:56";
|
||||
lan.mac = "52:54:00:12:34:57";
|
||||
};
|
||||
wan.interface = "wan";
|
||||
lans = {
|
||||
main = lan { interface = "lan"; };
|
||||
dmz = lan2 {
|
||||
interface = "lan";
|
||||
vlan = 1002;
|
||||
deviceName = "dmz";
|
||||
};
|
||||
};
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
nameKernelLike = {
|
||||
router.lans.main = lan { };
|
||||
router.interfaces = {
|
||||
eth7.mac = "52:54:00:12:34:56";
|
||||
wlp1s0.mac = "52:54:00:12:34:57";
|
||||
};
|
||||
expect = [ "do not look like a kernel name" ];
|
||||
};
|
||||
nameTooLong = {
|
||||
router.lans.main = lan { };
|
||||
router.interfaces.a-very-long-interface-name.mac = "52:54:00:12:34:56";
|
||||
expect = [ "at most 15 characters" ];
|
||||
};
|
||||
sameMacTwice = {
|
||||
router.lans.main = lan { };
|
||||
router.interfaces = {
|
||||
wan.mac = "52:54:00:12:34:56";
|
||||
lan.mac = "52:54:00:12:34:56";
|
||||
};
|
||||
expect = [ "same MAC address" ];
|
||||
};
|
||||
vlanNameClash = {
|
||||
router = {
|
||||
interfaces.dmz.mac = "52:54:00:12:34:56";
|
||||
lans.a = lan {
|
||||
vlan = 20;
|
||||
deviceName = "dmz";
|
||||
};
|
||||
};
|
||||
expect = [ "used twice among `interfaces`" ];
|
||||
};
|
||||
deviceNameWithoutVlan = {
|
||||
router.lans.main = lan { deviceName = "dmz"; };
|
||||
expect = [ "deviceName needs a vlan" ];
|
||||
};
|
||||
pppoeVlanNameWithoutVlan = {
|
||||
router.lans.main = lan { };
|
||||
router.wan.ipv4.pppoe.vlanName = "isp";
|
||||
expect = [ "vlanName needs pppoe.vlan" ];
|
||||
};
|
||||
deviceNameTooLong = {
|
||||
router.lans.main = lan {
|
||||
interface = "enp1s0f1np1";
|
||||
@@ -764,6 +822,62 @@ let
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
localValid = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "local";
|
||||
protocol = "tcp";
|
||||
ports = [ 22 ];
|
||||
sources = [ "198.51.100.0/24" ];
|
||||
rateLimit = "5/minute";
|
||||
}
|
||||
{
|
||||
from = "main";
|
||||
to = "local";
|
||||
protocol = "tcp";
|
||||
ports = [ 8443 ];
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
localNatOnly = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "local";
|
||||
protocol = "tcp";
|
||||
ports = [ 22 ];
|
||||
natOnly = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "natOnly does not apply to the router itself" ];
|
||||
};
|
||||
localNoPorts = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "local";
|
||||
protocol = "tcp";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "must list the ports they open" ];
|
||||
};
|
||||
localZoneName = {
|
||||
router = {
|
||||
lans.main = lan { zone = "local"; };
|
||||
};
|
||||
expect = [ "`local` is a reserved zone name" ];
|
||||
};
|
||||
wanNoDestination = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
@@ -1446,6 +1560,7 @@ let
|
||||
"pdOnOneLan"
|
||||
"pppoeValid"
|
||||
"vlansOnOneTrunk"
|
||||
"namesValid"
|
||||
"ruleValid"
|
||||
"wgValid"
|
||||
"wanValid"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
wan4 = "pppoe";
|
||||
};
|
||||
vlans = import ./vlans.nix { inherit self pkgs; };
|
||||
names = import ./names.nix { inherit self pkgs; };
|
||||
onestick = import ./onestick.nix { inherit self pkgs; };
|
||||
rollback = import ./rollback.nix { inherit self pkgs; };
|
||||
dns = import ./dns.nix { inherit self pkgs; };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Network cards are named by MAC address: the WAN and the LAN card get the
|
||||
# names wan and lan, and a VLAN on the LAN card gets the name dmz.
|
||||
{ self, pkgs }:
|
||||
let
|
||||
common = import ./common.nix;
|
||||
# The test driver gives the NIC on VLAN n of machine m the MAC 52:54:00:12:0n:0m.
|
||||
mac = net: "52:54:00:12:0${toString net}:01";
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "router-names";
|
||||
|
||||
defaults.imports = [ common.tools ];
|
||||
interactive.defaults = common.interactiveDefaults;
|
||||
|
||||
nodes.router =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.router ];
|
||||
virtualisation.vlans = [
|
||||
1
|
||||
2
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
|
||||
|
||||
router = {
|
||||
enable = true;
|
||||
interfaces = {
|
||||
wan.mac = mac 1;
|
||||
lan.mac = mac 2;
|
||||
};
|
||||
wan.interface = "wan";
|
||||
lans = {
|
||||
main = {
|
||||
interface = "lan";
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
guests = {
|
||||
interface = "lan";
|
||||
vlan = 1002;
|
||||
deviceName = "dmz";
|
||||
ipv4.address = "192.168.20.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.20.100";
|
||||
end = "192.168.20.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
router.start()
|
||||
router.wait_for_unit("network-online.target")
|
||||
|
||||
with subtest("the cards have the names given by MAC address"):
|
||||
router.succeed("ip -br link show wan | grep -qi ${mac 1}")
|
||||
router.succeed("ip -br link show lan | grep -qi ${mac 2}")
|
||||
router.fail("ip link show eth1")
|
||||
router.fail("ip link show eth2")
|
||||
|
||||
with subtest("the VLAN device has the name given to it"):
|
||||
out = router.succeed("ip -d link show dmz")
|
||||
assert "vlan protocol 802.1Q id 1002" in out and "lan" in out, out
|
||||
router.fail("ip link show lan.1002")
|
||||
router.wait_until_succeeds("ip -4 addr show dev lan | grep -q 192.168.10.1/24")
|
||||
router.wait_until_succeeds("ip -4 addr show dev dmz | grep -q 192.168.20.1/24")
|
||||
|
||||
with subtest("services use the names"):
|
||||
router.wait_for_unit("nftables.service")
|
||||
rules = router.succeed("nft list ruleset")
|
||||
assert '"lan"' in rules and '"dmz"' in rules and "eth2" not in rules, rules
|
||||
router.wait_for_unit("kea-dhcp4-server.service")
|
||||
assert sorted(router.succeed("cat /etc/router/dhcp-interfaces").split()) == ["dmz", "lan"], "dhcp interfaces"
|
||||
router.succeed("grep -q '^nic ${mac 2} lan$' /etc/router/interface-names")
|
||||
'';
|
||||
}
|
||||
+7
-4
@@ -2,7 +2,8 @@
|
||||
# the three LANs sharing a zone.
|
||||
#
|
||||
# zone trusted: main (untagged, internet)
|
||||
# zone iot: iot (VLAN 20) + cams (VLAN 30), no internet, no management
|
||||
# zone iot: iot (VLAN 20) + cams (VLAN 30, its device is called dmz), no internet,
|
||||
# no management
|
||||
#
|
||||
# rules: trusted -> iot (anything)
|
||||
# iot -> trusted, tcp 8080, only to 192.168.10.100
|
||||
@@ -133,6 +134,7 @@ pkgs.testers.runNixOSTest {
|
||||
cams = {
|
||||
interface = "eth2";
|
||||
vlan = 30;
|
||||
deviceName = "dmz"; # a VLAN device with a name of its own
|
||||
zone = "iot";
|
||||
allowManagement = false;
|
||||
ipv4.address = "192.168.30.1";
|
||||
@@ -261,12 +263,13 @@ pkgs.testers.runNixOSTest {
|
||||
router.wait_for_unit("nftables.service")
|
||||
router.wait_for_unit("kea-dhcp4-server.service")
|
||||
router.wait_for_unit("unbound.service")
|
||||
for vid in (20, 30):
|
||||
out = router.succeed(f"ip -d link show eth2.{vid}")
|
||||
for dev, vid in (("eth2.20", 20), ("dmz", 30)):
|
||||
out = router.succeed(f"ip -d link show {dev}")
|
||||
assert f"vlan protocol 802.1Q id {vid}" in out, out
|
||||
router.fail("ip link show eth2.30")
|
||||
router.succeed("ip -4 addr show dev eth2 | grep -q 192.168.10.1")
|
||||
router.succeed("ip -4 addr show dev eth2.20 | grep -q 192.168.20.1")
|
||||
router.succeed("ip -4 addr show dev eth2.30 | grep -q 192.168.30.1")
|
||||
router.succeed("ip -4 addr show dev dmz | grep -q 192.168.30.1")
|
||||
|
||||
with subtest("each host gets an address and gateway from its own VLAN"):
|
||||
main.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.100")
|
||||
|
||||
Reference in New Issue
Block a user