# A router configured by a settings file instead of Nix code, so that it can # be changed on the box itself (by the web UI or by hand) and rebuilt there. # # settings.json holds the `router` options as JSON plus a few system settings: # # { # "system": { "hostName": "router", "timeZone": "UTC", "sshKeys": [ "ssh-ed25519 ..." ], # "stateVersion": "26.11" }, # "router": { "wan": { "interface": "enp1s0" }, "lans": { "lan": { ... } } } # } # # The module system validates it like any configuration: unknown keys, wrong # types and the router's assertions all fail the build. Secrets stay out of it # (files under /var/lib/router, referenced by path). # # On the device the configuration lives in /var/lib/router/config: a flake # (pinned to this router's source), settings.json and an optional local.nix. # `router-apply` builds it and activates it with commit-confirm: unless # `router-confirm` follows within router.rollback.timeout, the previous system # comes back. A change of interface names is only staged for the next boot. { config, lib, pkgs, options, ... }: let cfg = config.routerSettings; profile = "/nix/var/nix/profiles/system"; settings = lib.importJSON cfg.file; system = settings.system or { }; pending = "/run/router-pending-settings.json"; confirmed = "${cfg.stateDirectory}/settings.confirmed.json"; apply = pkgs.writeShellApplication { name = "router-apply"; runtimeInputs = [ config.nix.package pkgs.coreutils pkgs.gawk pkgs.gnugrep ]; text = '' timeout=''${1:-${config.router.rollback.timeout}} # Remember what is being built: confirming records it (the web UI shows # settings that differ from it as not yet applied). building=$(mktemp /run/router-building.XXXXXX) cp ${cfg.directory}/settings.json "$building" echo "router: building ${cfg.directory}" new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \ "path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel") if [ "$new" = "$(readlink -f /run/current-system)" ]; then echo "router: no changes" if [ "$new" = "$(readlink -f ${profile})" ]; then install -m 0600 "$building" ${confirmed} fi rm -f "$building" exit 0 fi # Interface names cannot change on a running system, and the new # configuration refers to them: stage it for the next boot instead. names() { cat "$1/etc/router/interface-names" 2>/dev/null || true; } staged= # A card gets, loses or changes its name. [ "$(names "$new" | grep '^nic ' || true)" = "$(names /run/current-system | grep '^nic ' || true)" ] || staged=1 # A VLAN device that exists keeps its parent and ID but gets another name. awk 'NR == FNR { if ($1 == "vlan") old[$2 " " $3] = $4; next } $1 == "vlan" && ($2 " " $3) in old && old[$2 " " $3] != $4 { bad = 1 } END { exit bad }' <(names /run/current-system) <(names "$new") || staged=1 if [ -n "$staged" ]; then nix-env -p ${profile} --set "$new" "$new/bin/switch-to-configuration" boot install -m 0600 "$building" ${confirmed} rm -f "$building" echo "router: interface names change; the new configuration starts at the next reboot" exit 0 fi mv "$building" ${pending} router-arm "$timeout" echo "router: activating $new" "$new/bin/switch-to-configuration" test ''; }; # What the web UI's MCP endpoint offers an assistant as the router's options. optionsJson = (pkgs.nixosOptionsDoc { options = { inherit (options) router; }; warningsAreErrors = false; # Where the options are declared is the source's store path, which differs # between builds of the same release (an update's test sources, a copy # into the store): without it the file is the same everywhere, so a router # that rebuilds itself offline already has it. transformOptions = opt: opt // { declarations = [ ]; }; }).optionsJSON + "/share/doc/nixos/options.json"; # Evaluate the saved settings like an apply would, without building: the # option types and the router's assertions. Started by the web UI. check = pkgs.writeShellApplication { name = "router-check"; runtimeInputs = [ config.nix.package pkgs.coreutils pkgs.jq ]; text = '' etag=$(sha256sum ${cfg.directory}/settings.json | cut -c1-16) mkdir -p ${cfg.stateDirectory}/check out=$(mktemp ${cfg.stateDirectory}/check/result.XXXXXX) if msg=$(nix eval --raw "path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel.drvPath" 2>&1); then jq -n --arg etag "$etag" --arg time "$(date -u +%FT%TZ)" '{ok: true, etag: $etag, time: $time}' > "$out" else jq -n --arg etag "$etag" --arg time "$(date -u +%FT%TZ)" --arg error "$msg" '{ok: false, error: $error, etag: $etag, time: $time}' > "$out" fi mv "$out" ${cfg.stateDirectory}/check/result.json ''; }; webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { }; in { imports = [ ./update.nix ./upgrade.nix ]; # Outside `router`: the settings file defines that whole tree. options.routerSettings = { file = lib.mkOption { type = lib.types.path; description = "The settings file (JSON) this system is built from."; }; directory = lib.mkOption { type = lib.types.str; default = "/var/lib/router/config"; description = "Where the configuration (flake and settings) lives on the device."; }; stateDirectory = lib.mkOption { type = lib.types.str; default = "/var/lib/router"; description = "Secrets and web UI state (password hash, certificate)."; }; webui = { enable = lib.mkOption { type = lib.types.bool; default = true; description = "Serve the web UI (HTTPS) to LANs and tunnels with `allowManagement`."; }; port = lib.mkOption { type = lib.types.port; default = 443; }; httpPort = lib.mkOption { type = lib.types.nullOr lib.types.port; default = 80; description = "Redirect plain HTTP on this port to HTTPS (to the LANs and tunnels with `allowManagement`); null: do not listen."; }; }; }; config = { router = lib.mkMerge [ (settings.router // { enable = true; }) { rollback.onConfirm = '' if [ -e ${pending} ]; then mv ${pending} ${confirmed}; fi ''; } (lib.mkIf cfg.webui.enable { managementPorts = [ cfg.webui.port ] ++ lib.optional (cfg.webui.httpPort != null) cfg.webui.httpPort; }) ]; networking.hostName = system.hostName or "router"; time.timeZone = system.timeZone or "UTC"; users.users.root.openssh.authorizedKeys.keys = system.sshKeys or [ ]; # Written by router-setup (the web UI password). Users are mutable, so it # only sets the password when the account is created, at installation; # `passwd` changes it later. users.users.root.hashedPasswordFile = lib.mkDefault "${cfg.stateDirectory}/root-password"; system.stateVersion = system.stateVersion or config.system.nixos.release; nix.settings.experimental-features = [ "nix-command" "flakes" ]; environment.systemPackages = [ apply webui pkgs.jq # for looking at and editing settings.json by hand pkgs.wireguard-tools # wg: the tunnels' state, from a shell ]; # The web UI starts these rather than running anything itself, so a switch # that restarts the UI does not cut them short; a switch must not restart # or stop them either (router-apply is what runs the switch). systemd.services.router-apply = { description = "Build and activate the router configuration"; restartIfChanged = false; stopIfChanged = false; path = [ "/run/current-system/sw" ]; serviceConfig = { Type = "oneshot"; ExecStart = lib.getExe apply; TimeoutStartSec = "1h"; }; }; # Puts root's password hash (written by the web UI) into /etc/shadow now, # rather than at the next switch; the web UI itself may not touch /etc. systemd.services.router-check = lib.mkIf cfg.webui.enable { description = "Evaluate the router configuration (no build)"; restartIfChanged = false; stopIfChanged = false; path = [ "/run/current-system/sw" ]; serviceConfig = { Type = "oneshot"; ExecStart = lib.getExe check; TimeoutStartSec = "10min"; }; }; systemd.services.router-root-password = lib.mkIf cfg.webui.enable { description = "Apply root's password from the web UI"; restartIfChanged = false; stopIfChanged = false; serviceConfig = { Type = "oneshot"; ExecStart = pkgs.writeShellScript "router-root-password" '' exec ${pkgs.shadow}/bin/usermod --password "$(cat ${cfg.stateDirectory}/root-password)" root ''; }; }; systemd.services.router-confirm = { description = "Confirm the active router configuration"; restartIfChanged = false; stopIfChanged = false; path = [ "/run/current-system/sw" ]; serviceConfig = { Type = "oneshot"; ExecStart = "/run/current-system/sw/bin/router-confirm"; }; }; systemd.services.router-webui = lib.mkIf cfg.webui.enable { description = "Router web UI"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; # iproute2: the dashboard reads the default route and the PPP peer; # wireguard-tools: the tunnels' status; qrencode: client configs. path = [ config.systemd.package pkgs.iproute2 pkgs.wireguard-tools pkgs.qrencode pkgs.mkpasswd # the hash of root's password ]; serviceConfig = { ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port}${ lib.optionalString (cfg.webui.httpPort != null) " -http :${toString cfg.webui.httpPort}" }${ lib.optionalString ( config.router.acme.enable && config.router.acme.domains != [ ] ) " -acme-dir /var/lib/acme/${lib.head config.router.acme.domains}" } -options ${optionsJson} -config ${cfg.directory} -state ${cfg.stateDirectory}"; Restart = "on-failure"; # It edits settings and secrets and talks to systemd; nothing else. ProtectSystem = "strict"; ReadWritePaths = [ cfg.stateDirectory ]; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; NoNewPrivileges = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ]; LockPersonality = true; MemoryDenyWriteExecute = true; }; }; # The DHCP clients' names in DNS (router.dns.dynamicLeases): reads Kea's # leases and publishes them in the running Unbound. systemd.services.router-leases-dns = lib.mkIf (config.router.enable && config.router.dns.dynamicLeases) { description = "Publish DHCP clients' names in DNS"; wantedBy = [ "multi-user.target" ]; after = [ "unbound.service" "kea-dhcp4-server.service" ]; path = [ config.services.unbound.package ]; serviceConfig = { ExecStart = "${lib.getExe webui} leases-dns -leases /var/lib/kea/dhcp4.leases -static /etc/router/dns-static.json -socket ${config.services.unbound.localControlSocketPath} -run /run/router-leases-dns"; RuntimeDirectory = "router-leases-dns"; Restart = "always"; RestartSec = 5; # It reads leases and talks to Unbound's socket; nothing else. ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateNetwork = true; PrivateDevices = true; NoNewPrivileges = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictAddressFamilies = [ "AF_UNIX" ]; LockPersonality = true; MemoryDenyWriteExecute = true; }; }; # Rebuilding on the device must work without internet (the uplink may be # what is being configured). What a changed configuration rebuilds is all # generated by NixOS, but those builds need these packages; found by # diffing the derivation graphs of typical changes (pool, addresses, host # name, PPPoE on a VLAN, a VLAN LAN, IPv6 PD/ULA, a port forward, DNS # records) against the installed system. system.extraDependencies = with pkgs; [ stdenvNoCC findXMLCatalogs jq.dev kmod.dev libcap-text-verifier libxslt.bin # nftables checks its ruleset at build time inside lkl. libredirect lklWithFirewall.lib lndir makeShellWrapper mdadm nukeReferences # Switching the uplink to PPPoE is a typical first change. ppp ]; }; }