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:
@@ -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