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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-29 09:19:14 +01:00
co-authored by Claude Opus 5.5
parent 5ddff44a35
commit f5909fbdcb
7 changed files with 468 additions and 17 deletions
+16 -7
View File
@@ -21,25 +21,30 @@ let
];
text = ''
usage() {
echo "usage: router-install [--yes] [--secrets DIR] <disk>" >&2
echo " Wipes <disk> (e.g. /dev/nvme0n1) and installs ${cfg.system}." >&2
echo "usage: router-install [--yes] [--secrets DIR] [--system PATH] <disk>" >&2
echo " Wipes <disk> (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;
};
}
+15 -6
View File
@@ -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@<this-host>:secrets
2. router-install --secrets /root/secrets /dev/<disk>
The disk is erased after confirmation.
'';
nixos-router installer
1. (optional) scp -r secrets root@<this-host>:secrets
2. router-install --secrets /root/secrets /dev/<disk>
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.
'';
}
+248
View File
@@ -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"
];
};
}
+4
View File
@@ -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 = [