From f5909fbdcb9746fd94fe67e154b61a5aa3d172b5 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Thu, 29 Oct 2026 09:19:14 +0100 Subject: [PATCH] Add a generic installer that asks for the basics and builds offline The installer ISO no longer needs a prebuilt system: `router-setup` asks for the disk, the WAN and LAN interfaces, DHCP or PPPoE (optionally on a VLAN), host name, LAN address and a password, writes settings.json and the secrets, builds that router offline (the ISO carries a reference router system whose closure has everything the build needs) and installs it with `router-install --system`. The password is used for the web UI and, hashed, as root's console password (routerSettings sets root's hashedPasswordFile; it applies when the account is created at installation). checks.setup answers router-setup on the console and checks the installed box: its settings, the web UI and console login with that password, and a rebuild that reproduces the installed system. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- flake.nix | 66 ++++++- modules/installer/install-script.nix | 23 ++- modules/installer/iso.nix | 21 ++- modules/installer/setup.nix | 248 +++++++++++++++++++++++++++ modules/settings/default.nix | 4 + tests/default.nix | 1 + tests/setup.nix | 122 +++++++++++++ 7 files changed, 468 insertions(+), 17 deletions(-) create mode 100644 modules/installer/setup.nix create mode 100644 tests/setup.nix diff --git a/flake.nix b/flake.nix index aedf85e..72da5aa 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,8 @@ # The installer pieces; `lib.mkInstaller` wires them up. installer = ./modules/installer/install-script.nix; installer-iso = ./modules/installer/iso.nix; + # The generic installer's `router-setup` (see lib.setupModule). + setup = ./modules/installer/setup.nix; # Appliance disk image: dm-verity store, volatile root, state on /var. image = ./modules/image; # Configuration from a settings file (JSON), rebuilt on the device. @@ -64,7 +66,16 @@ }: pkgs.runCommand "router-config" { } '' mkdir -p $out - cat > $out/flake.nix <<'EOF' + cp ${self.lib.configFlake pkgs} $out/flake.nix + cp ${settings} $out/settings.json + ${pkgs.lib.optionalString (local != null) "cp ${local} $out/local.nix"} + ''; + + # The configuration directory's flake.nix (shared by mkConfigDir and + # router-setup, so both build exactly the same system). + lib.configFlake = + pkgs: + pkgs.writeText "flake.nix" '' { inputs.nixos-router.url = "path:${self.outPath}"; outputs = @@ -76,11 +87,58 @@ }; }; } - EOF - cp ${settings} $out/settings.json - ${pkgs.lib.optionalString (local != null) "cp ${local} $out/local.nix"} ''; + # The generic installer: `router-setup` asks for the basics, builds + # the router offline and installs it; the rest is done in the web UI. + # nix build .#installer + # router-setup's configuration: the flake template and the reference + # system whose closure makes the offline build possible. `local` is an + # extra module that the installed systems will import as well. + lib.setupModule = + { + local ? null, + }: + { pkgs, ... }: + { + imports = [ self.nixosModules.setup ]; + router.setup = { + flake = self.lib.configFlake pkgs; + reference = + (self.lib.mkRouterSystem { + settings = builtins.toFile "settings.json" ( + builtins.toJSON { + router = { + wan.interface = "eth0"; + lans.lan = { + interface = "eth1"; + internet = true; + ipv4.address = "192.168.1.1"; + dhcp.pool = { + start = "192.168.1.100"; + end = "192.168.1.200"; + }; + }; + }; + } + ); + # A path, not a derivation: the module system would read its attributes. + modules = pkgs.lib.optional (local != null) "${local}"; + }).config.system.build.toplevel; + }; + }; + + # The generic installer ISO: + # nix build .#installer + nixosConfigurations.installer = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + self.nixosModules.installer-iso + (self.lib.setupModule { }) + ]; + }; + packages.x86_64-linux.installer = self.nixosConfigurations.installer.config.system.build.isoImage; + # Installer ISO for a given nixosSystem: # nix build .#installer-example (see packages) lib.mkInstaller = diff --git a/modules/installer/install-script.nix b/modules/installer/install-script.nix index e07bf9c..720d559 100644 --- a/modules/installer/install-script.nix +++ b/modules/installer/install-script.nix @@ -21,25 +21,30 @@ let ]; text = '' usage() { - echo "usage: router-install [--yes] [--secrets DIR] " >&2 - echo " Wipes (e.g. /dev/nvme0n1) and installs ${cfg.system}." >&2 + echo "usage: router-install [--yes] [--secrets DIR] [--system PATH] " >&2 + echo " Wipes (e.g. /dev/nvme0n1) and installs a router system." >&2 echo " --secrets DIR copied to /var/lib/router (PPPoE password, WireGuard keys, ...)" >&2 + echo " --system PATH the system to install${lib.optionalString (cfg.system != null) " (default: ${cfg.system})"}" >&2 exit 2 } yes=0 secrets= + system=${if cfg.system != null then toString cfg.system else ""} disk= while [ $# -gt 0 ]; do case $1 in --yes) yes=1 ;; --secrets) shift; [ $# -gt 0 ] || usage; secrets=$1 ;; + --system) shift; [ $# -gt 0 ] || usage; system=$1 ;; -*) usage ;; *) [ -z "$disk" ] || usage; disk=$1 ;; esac shift done [ -n "$disk" ] || usage + [ -n "$system" ] || usage + [ -x "$system/bin/switch-to-configuration" ] || { echo "router-install: $system is not a NixOS system" >&2; exit 1; } [ "$(id -u)" -eq 0 ] || { echo "router-install: must run as root" >&2; exit 1; } [ -b "$disk" ] || { echo "router-install: $disk is not a block device" >&2; exit 1; } @@ -94,8 +99,8 @@ let echo "note: no --secrets given; put the PPPoE password etc. in /var/lib/router later" >&2 fi - echo ">> installing ${cfg.system}" - nixos-install --system ${cfg.system} --root /mnt --no-root-passwd --no-channel-copy + echo ">> installing $system" + nixos-install --system "$system" --root /mnt --no-root-passwd --no-channel-copy echo ">> unmounting" umount -R /mnt @@ -106,13 +111,17 @@ let in { options.router.installer.system = lib.mkOption { - type = lib.types.package; - description = "The system (a `system.build.toplevel`) `router-install` writes to disk."; + type = lib.types.nullOr lib.types.package; + default = null; + description = '' + The system (a `system.build.toplevel`) `router-install` writes to disk; + null if it is always given with `--system` (as `router-setup` does). + ''; }; config = { environment.systemPackages = [ installScript ]; # The closure must be on the installer itself. - system.extraDependencies = [ cfg.system ]; + system.extraDependencies = lib.optional (cfg.system != null) cfg.system; }; } diff --git a/modules/installer/iso.nix b/modules/installer/iso.nix index 7e93890..eeee5f8 100644 --- a/modules/installer/iso.nix +++ b/modules/installer/iso.nix @@ -21,11 +21,20 @@ settings.PermitRootLogin = "prohibit-password"; }; - users.motd = '' + users.motd = + if config.router.installer.system != null then + '' - nixos-router installer - 1. (optional) scp -r secrets root@:secrets - 2. router-install --secrets /root/secrets /dev/ - The disk is erased after confirmation. - ''; + nixos-router installer + 1. (optional) scp -r secrets root@:secrets + 2. router-install --secrets /root/secrets /dev/ + The disk is erased after confirmation. + '' + else + '' + + nixos-router installer + Run `sudo router-setup`: it asks a few questions, erases the chosen disk + and installs the router. Everything else is set up in the web UI. + ''; } diff --git a/modules/installer/setup.nix b/modules/installer/setup.nix new file mode 100644 index 0000000..3c29700 --- /dev/null +++ b/modules/installer/setup.nix @@ -0,0 +1,248 @@ +# `router-setup`: the generic installer. Asks for the basics on the console +# (disk, uplink and LAN interfaces, PPPoE credentials, LAN address, web UI +# password), writes them as settings.json, builds that router on the spot +# (offline: the closure of `reference` brings the sources and everything the +# build needs) and installs it with `router-install`. Everything else is done +# in the web UI afterwards. +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.router.setup; + webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { }; + + setup = pkgs.writeShellApplication { + name = "router-setup"; + runtimeInputs = [ + config.nix.package + webui + pkgs.coreutils + pkgs.gawk + pkgs.gnugrep + pkgs.jq + pkgs.mkpasswd + pkgs.util-linux + "/run/current-system/sw" + ]; + text = '' + usage() { + echo "usage: router-setup [--local FILE]" >&2 + echo " Asks a few questions, then ERASES the chosen disk and installs the router." >&2 + echo " --local FILE extra NixOS module for this box (installed as config/local.nix)" >&2 + exit 2 + } + local_nix="" + while [ $# -gt 0 ]; do + case $1 in + --local) shift; [ $# -gt 0 ] || usage; local_nix=$1 ;; + *) usage ;; + esac + shift + done + [ "$(id -u)" -eq 0 ] || { echo "router-setup: must run as root" >&2; exit 1; } + + # ask VAR PROMPT [DEFAULT] + # (Their locals must not share a name with the caller's variable.) + ask() { + local reply + read -r -p "$2''${3:+ [$3]}: " reply || { echo; echo "aborted" >&2; exit 1; } + printf -v "$1" '%s' "''${reply:-''${3:-}}" + } + ask_secret() { + local reply + read -r -s -p "$2: " reply || { echo; echo "aborted" >&2; exit 1; } + echo >&2 + printf -v "$1" '%s' "$reply" + } + # choose VAR PROMPT ITEM...: by number or by name + choose() { + local var=$1 prompt=$2 pick="" i + shift 2 + while :; do + i=1 + for item in "$@"; do echo " $i) $item" >&2; i=$((i + 1)); done + ask pick "$prompt" + if [[ $pick =~ ^[0-9]+$ ]] && [ "$pick" -ge 1 ] && [ "$pick" -le $# ]; then + pick=''${!pick} + pick=''${pick%% *} + fi + for item in "$@"; do + if [ "''${item%% *}" = "$pick" ]; then + printf -v "$var" '%s' "$pick" + return + fi + done + echo " please pick one of the above" >&2 + done + } + ipv4_ok() { + [[ $1 =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1 + for o in "''${BASH_REMATCH[@]:1}"; do [ "$o" -le 255 ] || return 1; done + } + + # Set by ask/choose (printf -v). + disk="" wan="" lan="" wan_type="" host="" lan_address="" ssh_key="" answer="" + webui_password="" webui_password2="" + + echo + echo "nixos-router setup" + echo "==================" + echo "A few questions; everything else can be changed later in the web UI." + echo + + # Disks, without the medium this installer runs from. + boot_disk="" + if src=$(findmnt -no SOURCE /iso 2>/dev/null) && [ -n "$src" ]; then + boot_disk=/dev/$(lsblk -no PKNAME "$src" | head -n1) + fi + mapfile -t disks < <(lsblk -dnpo NAME,SIZE,MODEL,TYPE | awk '$NF == "disk" { $NF = ""; print }' \ + | grep -v "^$boot_disk " || true) + [ ''${#disks[@]} -gt 0 ] || { echo "router-setup: no disk to install to" >&2; exit 1; } + echo "Disk to install to (it will be ERASED):" + choose disk "Disk" "''${disks[@]}" + + nics=() + for dev in /sys/class/net/*; do + n=$(basename "$dev") + [ -e "$dev/device" ] || continue + state=$(cat "$dev/operstate" 2>/dev/null || echo "?") + nics+=("$n $(cat "$dev/address") link $state") + done + [ ''${#nics[@]} -gt 0 ] || { echo "router-setup: no network interfaces found" >&2; exit 1; } + echo + echo "Interface towards the internet (WAN):" + choose wan "WAN interface" "''${nics[@]}" + echo + echo "Interface for the local network (LAN):" + choose lan "LAN interface" "''${nics[@]}" + + echo + echo "Internet connection:" + choose wan_type "Connection" \ + "dhcp (behind a cable modem, an ONT or another router)" \ + "pppoe (DSL or fibre with a username and password)" + pppoe_user="" pppoe_password="" pppoe_vlan="" + if [ "$wan_type" = pppoe ]; then + while [ -z "$pppoe_user" ]; do ask pppoe_user "PPPoE username"; done + while [ -z "$pppoe_password" ]; do ask_secret pppoe_password "PPPoE password"; done + while :; do + ask pppoe_vlan "VLAN ID the provider requires (empty for none)" "" + [ -z "$pppoe_vlan" ] && break + [[ $pppoe_vlan =~ ^[0-9]+$ ]] && [ "$pppoe_vlan" -ge 1 ] && [ "$pppoe_vlan" -le 4094 ] && break + echo " a number from 1 to 4094" >&2 + done + fi + if [ "$wan" = "$lan" ] && [ -z "$pppoe_vlan" ]; then + echo "router-setup: WAN and LAN can only share an interface when PPPoE runs on a VLAN" >&2 + exit 1 + fi + + echo + ask host "Host name" "router" + while :; do + ask lan_address "Router address on the LAN (a /24 network)" "192.168.1.1" + ipv4_ok "$lan_address" && break + echo " an IPv4 address like 192.168.1.1" >&2 + done + net=''${lan_address%.*} + + echo + echo "Password for the web UI and for root on the console (at least 8 characters):" + while :; do + ask_secret webui_password "Password" + ask_secret webui_password2 "Again" + [ "$webui_password" = "$webui_password2" ] && [ ''${#webui_password} -ge 8 ] && break + echo " the passwords differ or are too short" >&2 + done + ask ssh_key "SSH public key for root (optional, one line)" "" + + echo + echo "Summary" + echo " disk: $disk (will be erased)" + echo " WAN: $wan, $wan_type''${pppoe_user:+ as $pppoe_user}''${pppoe_vlan:+ on VLAN $pppoe_vlan}" + echo " LAN: $lan, $lan_address/24, DHCP $net.100 - $net.200" + echo " host name: $host" + echo " web UI: https://$lan_address" + ask answer "Type the disk name ($disk) to install" + [ "$answer" = "$disk" ] || { echo "aborted" >&2; exit 1; } + + work=$(mktemp -d /run/router-setup.XXXXXX) + chmod 0700 "$work" + trap 'rm -rf "$work"' EXIT + mkdir -p "$work/secrets/config" + config=$work/secrets/config + cp ${cfg.flake} "$config/flake.nix" + chmod 0644 "$config/flake.nix" + if [ -n "$local_nix" ]; then cp "$local_nix" "$config/local.nix"; fi + + keys=$( [ -n "$ssh_key" ] && jq -n --arg k "$ssh_key" '[$k]' || echo '[]') + jq -n \ + --arg host "$host" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \ + --arg wan "$wan" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$pppoe_vlan" \ + --arg lan "$lan" --arg addr "$lan_address" --arg net "$net" ' + { + system: { hostName: $host, stateVersion: $state, sshKeys: $keys }, + router: { + wan: ({ interface: $wan, ipv4: { type: $type } } + | if $type == "pppoe" then .ipv4.pppoe = ({ username: $user, passwordFile: "/var/lib/router/pppoe-password" } + + (if $vlan == "" then {} else { vlan: ($vlan | tonumber) } end)) else . end), + lans: { + lan: { + interface: $lan, internet: true, + ipv4: { address: $addr }, + dhcp: { pool: { start: ($net + ".100"), end: ($net + ".200") } } + } + } + } + }' > "$config/settings.json" + + if [ "$wan_type" = pppoe ]; then + printf '%s' "$pppoe_password" > "$work/secrets/pppoe-password" + fi + printf '%s\n' "$webui_password" | router-webui passwd "$work/secrets/webui-password" + (umask 077; printf '%s\n' "$webui_password" | mkpasswd -s > "$work/secrets/root-password") + + echo + echo ">> building the router system (no internet needed; takes a few minutes)" + system=$(nix build --no-link --print-out-paths --out-link "$work/system" \ + "path:$config#nixosConfigurations.router.config.system.build.toplevel") + router-install --yes --secrets "$work/secrets" --system "$system" "$disk" + echo + echo "After rebooting, open https://$lan_address from the LAN." + ''; + }; +in +{ + imports = [ ./install-script.nix ]; + + options.router.setup = { + flake = lib.mkOption { + type = lib.types.path; + description = "The configuration flake (flake.nix) installed systems are built from."; + }; + reference = lib.mkOption { + type = lib.types.package; + description = '' + A router system built the same way (`lib.mkRouterSystem`). Its closure + is what lets the installer build the chosen configuration offline. + ''; + }; + stateVersion = lib.mkOption { + type = lib.types.str; + default = config.system.nixos.release; + description = "system.stateVersion of installed routers."; + }; + }; + + config = { + environment.systemPackages = [ setup ]; + system.extraDependencies = [ cfg.reference ]; + nix.settings.experimental-features = [ + "nix-command" + "flakes" + ]; + }; +} diff --git a/modules/settings/default.nix b/modules/settings/default.nix index e93f6ad..38248fb 100644 --- a/modules/settings/default.nix +++ b/modules/settings/default.nix @@ -109,6 +109,10 @@ in 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 = [ diff --git a/tests/default.nix b/tests/default.nix index e507426..90a96b0 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -11,6 +11,7 @@ dns = import ./dns.nix { inherit self pkgs; }; installer = import ./installer.nix { inherit self pkgs; }; apply = import ./apply.nix { inherit self pkgs; }; + setup = import ./setup.nix { inherit self pkgs; }; image = import ./image.nix { inherit self pkgs; }; wireguard = import ./wireguard.nix { inherit self pkgs; }; lib = import ./lib.nix { inherit pkgs; }; diff --git a/tests/setup.nix b/tests/setup.nix new file mode 100644 index 0000000..e77c0b9 --- /dev/null +++ b/tests/setup.nix @@ -0,0 +1,122 @@ +# The generic installer: router-setup is answered on its console (DHCP uplink, +# LAN address, web UI password), builds the router offline and installs it. +# The installed box must come up with those settings, serve the web UI with +# that password, and rebuild to exactly itself. +{ self, pkgs }: +let + common = import ./common.nix; + + # Only so the test driver can talk to the installed system. + local = pkgs.writeText "local.nix" '' + { lib, modulesPath, ... }: + { + imports = [ (modulesPath + "/testing/test-instrumentation.nix") ]; + # The instrumentation empties root's password; keep router-setup's. + users.users.root.hashedPasswordFile = lib.mkForce "/var/lib/router/root-password"; + } + ''; + + sharedDisk = { + virtualisation.diskImage = "./target.qcow2"; + virtualisation.diskSize = 8192; + # Evaluating and building a NixOS system takes memory. + virtualisation.memorySize = 3072; + }; + + answers = pkgs.writeText "answers" '' + /dev/vda + eth1 + eth2 + dhcp + gw + 192.168.10.1 + hunter2hunter2 + hunter2hunter2 + + /dev/vda + ''; +in +pkgs.testers.runNixOSTest { + name = "router-setup"; + + nodes = { + installer = + { lib, ... }: + { + imports = [ + sharedDisk + (self.lib.setupModule { inherit local; }) + common.tools + ]; + environment.etc."router-test-local.nix".source = local; + # The interfaces router-setup offers. + virtualisation.vlans = [ + 1 + 2 + ]; + virtualisation.fileSystems."/".autoFormat = true; + virtualisation.emptyDiskImages = [ 1024 ]; + virtualisation.rootDevice = "/dev/vdb"; + nix.settings.substituters = lib.mkForce [ ]; + }; + + target = { + imports = [ sharedDisk ]; + virtualisation.vlans = [ + 1 + 2 + ]; + virtualisation.useBootLoader = true; + virtualisation.useEFIBoot = true; + virtualisation.useDefaultFilesystems = false; + virtualisation.efi.keepVariables = false; + virtualisation.fileSystems."/" = { + device = "/dev/disk/by-label/this-is-not-real-and-will-never-be-used"; + fsType = "ext4"; + }; + }; + }; + + testScript = '' + installer.start() + installer.wait_for_unit("multi-user.target") + + with subtest("router-setup refuses to install without the disk name"): + installer.fail("printf '/dev/vda\\neth1\\neth2\\ndhcp\\n\\n\\nhunter2hunter2\\nhunter2hunter2\\n\\nno\\n' | router-setup") + installer.fail("blkid /dev/vda1") + + with subtest("router-setup builds the answered configuration offline and installs it"): + installer.succeed("router-setup --local /etc/router-test-local.nix < ${answers} >&2") + installer.shutdown() + + target.state_dir = installer.state_dir + target.start() + target.wait_for_unit("multi-user.target") + + with subtest("the installed router has the answered settings"): + assert target.succeed("hostname").strip() == "gw" + target.wait_until_succeeds("ip -4 addr show dev eth2 | grep -q 192.168.10.1/24") + target.succeed("grep -q '192.168.10.100 - 192.168.10.200' /etc/kea/dhcp4-server.conf") + settings = target.succeed("cat /var/lib/router/config/settings.json") + assert '"eth1"' in settings and '"dhcp"' in settings, settings + assert target.succeed("stat -c %a /var/lib/router/webui-password").strip() == "600" + + with subtest("the web UI accepts the password given to router-setup"): + target.wait_for_unit("router-webui.service") + target.wait_until_succeeds("curl -sk -o /dev/null https://127.0.0.1/login") + code = target.succeed("curl -sk -o /dev/null -w '%{http_code}' -d password=hunter2hunter2 https://127.0.0.1/login") + assert code == "303", code + + with subtest("root logs in on the console with the same password"): + target.wait_until_tty_matches("1", "login: ") + target.send_chars("root\n") + target.wait_until_tty_matches("1", "Password: ") + target.send_chars("hunter2hunter2\n") + target.wait_until_succeeds("pgrep -u root -t tty1 bash") + assert target.succeed("stat -c %a /var/lib/router/root-password").strip() == "600" + + with subtest("the box rebuilds to exactly the installed system"): + out = target.succeed("router-apply 2>&1") + assert "no changes" in out, out + ''; +}