Web UI: a WireGuard page (tunnels, peers, keys made on the router, client configs with a QR code, import of wg-quick configs, live status), a DNS page (records, wildcards, forward zones), interface dropdowns with VLAN devices, IPv6 options and a local domain. router-setup asks about VLANs, IPv6 and the domain. The router publishes its own name in DNS and hands the local domain to DHCP clients; wg and jq are on the router. Includes a script that takes over antigua's tunnels. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
218 lines
7.4 KiB
Nix
218 lines
7.4 KiB
Nix
# A router configured by a settings file instead of Nix code, so that it can
|
|
# be changed on the box itself (by the web UI or by hand) and rebuilt there.
|
|
#
|
|
# settings.json holds the `router` options as JSON plus a few system settings:
|
|
#
|
|
# {
|
|
# "system": { "hostName": "router", "timeZone": "UTC", "sshKeys": [ "ssh-ed25519 ..." ],
|
|
# "stateVersion": "26.11" },
|
|
# "router": { "wan": { "interface": "enp1s0" }, "lans": { "lan": { ... } } }
|
|
# }
|
|
#
|
|
# The module system validates it like any configuration: unknown keys, wrong
|
|
# types and the router's assertions all fail the build. Secrets stay out of it
|
|
# (files under /var/lib/router, referenced by path).
|
|
#
|
|
# On the device the configuration lives in /var/lib/router/config: a flake
|
|
# (pinned to this router's source), settings.json and an optional local.nix.
|
|
# `router-apply` builds it and activates it with commit-confirm: unless
|
|
# `router-confirm` follows within router.rollback.timeout, the previous system
|
|
# comes back.
|
|
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.routerSettings;
|
|
profile = "/nix/var/nix/profiles/system";
|
|
settings = lib.importJSON cfg.file;
|
|
system = settings.system or { };
|
|
|
|
pending = "/run/router-pending-settings.json";
|
|
confirmed = "${cfg.stateDirectory}/settings.confirmed.json";
|
|
|
|
apply = pkgs.writeShellApplication {
|
|
name = "router-apply";
|
|
runtimeInputs = [
|
|
config.nix.package
|
|
pkgs.coreutils
|
|
];
|
|
text = ''
|
|
timeout=''${1:-${config.router.rollback.timeout}}
|
|
# Remember what is being built: confirming records it (the web UI shows
|
|
# settings that differ from it as not yet applied).
|
|
building=$(mktemp /run/router-building.XXXXXX)
|
|
cp ${cfg.directory}/settings.json "$building"
|
|
echo "router: building ${cfg.directory}"
|
|
new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \
|
|
"path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel")
|
|
if [ "$new" = "$(readlink -f /run/current-system)" ]; then
|
|
echo "router: no changes"
|
|
if [ "$new" = "$(readlink -f ${profile})" ]; then
|
|
install -m 0600 "$building" ${confirmed}
|
|
fi
|
|
rm -f "$building"
|
|
exit 0
|
|
fi
|
|
mv "$building" ${pending}
|
|
router-arm "$timeout"
|
|
echo "router: activating $new"
|
|
"$new/bin/switch-to-configuration" test
|
|
'';
|
|
};
|
|
|
|
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
|
|
in
|
|
{
|
|
imports = [ ./update.nix ];
|
|
|
|
# Outside `router`: the settings file defines that whole tree.
|
|
options.routerSettings = {
|
|
file = lib.mkOption {
|
|
type = lib.types.path;
|
|
description = "The settings file (JSON) this system is built from.";
|
|
};
|
|
directory = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "/var/lib/router/config";
|
|
description = "Where the configuration (flake and settings) lives on the device.";
|
|
};
|
|
stateDirectory = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "/var/lib/router";
|
|
description = "Secrets and web UI state (password hash, certificate).";
|
|
};
|
|
webui = {
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = "Serve the web UI (HTTPS) to LANs and tunnels with `allowManagement`.";
|
|
};
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 443;
|
|
};
|
|
};
|
|
};
|
|
|
|
config = {
|
|
router = lib.mkMerge [
|
|
(settings.router // { enable = true; })
|
|
{
|
|
rollback.onConfirm = ''
|
|
if [ -e ${pending} ]; then mv ${pending} ${confirmed}; fi
|
|
'';
|
|
}
|
|
(lib.mkIf cfg.webui.enable { managementPorts = [ cfg.webui.port ]; })
|
|
];
|
|
|
|
networking.hostName = system.hostName or "router";
|
|
time.timeZone = system.timeZone or "UTC";
|
|
users.users.root.openssh.authorizedKeys.keys = system.sshKeys or [ ];
|
|
# Written by router-setup (the web UI password). Users are mutable, so it
|
|
# only sets the password when the account is created, at installation;
|
|
# `passwd` changes it later.
|
|
users.users.root.hashedPasswordFile = lib.mkDefault "${cfg.stateDirectory}/root-password";
|
|
system.stateVersion = system.stateVersion or config.system.nixos.release;
|
|
|
|
nix.settings.experimental-features = [
|
|
"nix-command"
|
|
"flakes"
|
|
];
|
|
|
|
environment.systemPackages = [
|
|
apply
|
|
webui
|
|
pkgs.jq # for looking at and editing settings.json by hand
|
|
pkgs.wireguard-tools # wg: the tunnels' state, from a shell
|
|
];
|
|
|
|
# The web UI starts these rather than running anything itself, so a switch
|
|
# that restarts the UI does not cut them short; a switch must not restart
|
|
# or stop them either (router-apply is what runs the switch).
|
|
systemd.services.router-apply = {
|
|
description = "Build and activate the router configuration";
|
|
restartIfChanged = false;
|
|
stopIfChanged = false;
|
|
path = [ "/run/current-system/sw" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = lib.getExe apply;
|
|
TimeoutStartSec = "1h";
|
|
};
|
|
};
|
|
systemd.services.router-confirm = {
|
|
description = "Confirm the active router configuration";
|
|
restartIfChanged = false;
|
|
stopIfChanged = false;
|
|
path = [ "/run/current-system/sw" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "/run/current-system/sw/bin/router-confirm";
|
|
};
|
|
};
|
|
|
|
systemd.services.router-webui = lib.mkIf cfg.webui.enable {
|
|
description = "Router web UI";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
# iproute2: the dashboard reads the default route and the PPP peer;
|
|
# wireguard-tools: the tunnels' status; qrencode: client configs.
|
|
path = [
|
|
config.systemd.package
|
|
pkgs.iproute2
|
|
pkgs.wireguard-tools
|
|
pkgs.qrencode
|
|
];
|
|
serviceConfig = {
|
|
ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port} -config ${cfg.directory} -state ${cfg.stateDirectory}";
|
|
Restart = "on-failure";
|
|
# It edits settings and secrets and talks to systemd; nothing else.
|
|
ProtectSystem = "strict";
|
|
ReadWritePaths = [ cfg.stateDirectory ];
|
|
ProtectHome = true;
|
|
PrivateTmp = true;
|
|
PrivateDevices = true;
|
|
NoNewPrivileges = true;
|
|
ProtectKernelTunables = true;
|
|
ProtectKernelModules = true;
|
|
ProtectControlGroups = true;
|
|
RestrictAddressFamilies = [
|
|
"AF_UNIX"
|
|
"AF_INET"
|
|
"AF_INET6"
|
|
"AF_NETLINK"
|
|
];
|
|
LockPersonality = true;
|
|
MemoryDenyWriteExecute = true;
|
|
};
|
|
};
|
|
|
|
# Rebuilding on the device must work without internet (the uplink may be
|
|
# what is being configured). What a changed configuration rebuilds is all
|
|
# generated by NixOS, but those builds need these packages; found by
|
|
# diffing the derivation graphs of typical changes (pool, addresses, host
|
|
# name, PPPoE on a VLAN, a VLAN LAN, IPv6 PD/ULA, a port forward, DNS
|
|
# records) against the installed system.
|
|
system.extraDependencies = with pkgs; [
|
|
stdenvNoCC
|
|
findXMLCatalogs
|
|
jq.dev
|
|
kmod.dev
|
|
libcap-text-verifier
|
|
libxslt.bin
|
|
# nftables checks its ruleset at build time inside lkl.
|
|
libredirect
|
|
lklWithFirewall.lib
|
|
lndir
|
|
makeShellWrapper
|
|
mdadm
|
|
nukeReferences
|
|
# Switching the uplink to PPPoE is a typical first change.
|
|
ppp
|
|
];
|
|
};
|
|
}
|