diff --git a/modules/installer/setup.nix b/modules/installer/setup.nix index 3c29700..278d5ef 100644 --- a/modules/installer/setup.nix +++ b/modules/installer/setup.nix @@ -4,6 +4,11 @@ # (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. +# +# An existing router (an installed disk, or a backup from the web UI on any +# disk or USB stick) can be reinstalled instead: its whole /var/lib/router +# (settings, secrets, passwords, certificate, dashboard history) carries over +# and only interfaces this machine lacks are asked for. { config, lib, @@ -13,15 +18,20 @@ let cfg = config.router.setup; webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { }; + migrate = pkgs.callPackage ../../pkgs/router-migrate.nix { }; setup = pkgs.writeShellApplication { name = "router-setup"; runtimeInputs = [ config.nix.package webui + migrate pkgs.coreutils + pkgs.findutils pkgs.gawk pkgs.gnugrep + pkgs.gnutar + pkgs.gzip pkgs.jq pkgs.mkpasswd pkgs.util-linux @@ -83,9 +93,23 @@ let for o in "''${BASH_REMATCH[@]:1}"; do [ "$o" -le 255 ] || return 1; done } + # summary DIR: one line about the router whose state is in DIR. + summary() { + jq -r '"\(.system.hostName // "router"): WAN \(.router.wan.interface) (\(.router.wan.ipv4.type // "dhcp")), LAN \([.router.lans[]? | "\(.interface) \(.ipv4.address)"] | join(", "))"' \ + "$1/config/settings.json" 2>/dev/null + } + # Set by ask/choose (printf -v). disk="" wan="" lan="" wan_type="" host="" lan_address="" ssh_key="" answer="" - webui_password="" webui_password2="" + webui_password="" webui_password2="" reuse="" reuse_label="" new="" + + work=$(mktemp -d /run/router-setup.XXXXXX) + chmod 0700 "$work" + cleanup() { + umount "$work/mnt" 2>/dev/null || true + rm -rf "$work" + } + trap cleanup EXIT echo echo "nixos-router setup" @@ -98,6 +122,62 @@ let if src=$(findmnt -no SOURCE /iso 2>/dev/null) && [ -n "$src" ]; then boot_disk=/dev/$(lsblk -no PKNAME "$src" | head -n1) fi + + # Existing routers: installed systems and web UI backups, on any + # filesystem but the installer's own. Each found state is copied to + # $work/found/N (the disk may be the one about to be erased). + found=() found_labels=() + mkdir -p "$work/mnt" "$work/found" + # A stick plugged in a moment ago may not be probed yet. + udevadm settle --timeout=10 || true + while read -r dev fstype; do + case $fstype in ext2|ext3|ext4|vfat|exfat|ntfs|ntfs3|btrfs|xfs) ;; *) continue ;; esac + parent=$(lsblk -no PKNAME "$dev" 2>/dev/null | head -n1) + [ -n "$boot_disk" ] && [ "/dev/$parent" = "$boot_disk" ] && continue + [ -n "$boot_disk" ] && [ "$dev" = "$boot_disk" ] && continue + opts=ro + case $fstype in ext*) opts=ro,noload ;; esac + mount -o "$opts" "$dev" "$work/mnt" >/dev/null 2>&1 || continue + if [ -f "$work/mnt/var/lib/router/config/settings.json" ]; then + d=$work/found/''${#found[@]} + if cp -a "$work/mnt/var/lib/router" "$d" 2>/dev/null && line=$(summary "$d"); then + found+=("$d") found_labels+=("installed on $dev: $line") + else + rm -rf "$d" + fi + fi + while IFS= read -r -d "" backup; do + d=$work/found/''${#found[@]} + mkdir -p "$d.x" + if tar -xzf "$backup" -C "$d.x" --no-same-owner 2>/dev/null && [ -d "$d.x/router" ] \ + && mv "$d.x/router" "$d" && line=$(summary "$d"); then + found+=("$d") found_labels+=("backup ''${backup#"$work/mnt/"} on $dev: $line") + else + rm -rf "$d" + fi + rm -rf "$d.x" + done < <(find "$work/mnt" -maxdepth 2 -type f -name 'router-backup-*.tar.gz' -print0 2>/dev/null) + umount "$work/mnt" + done < <(lsblk -rnpo NAME,FSTYPE | awk 'NF == 2') + + if [ ''${#found[@]} -gt 0 ]; then + echo "Existing router configurations:" + i=1 + for l in "''${found_labels[@]}"; do echo " $i) $l"; i=$((i + 1)); done + echo " n) none, set up a new router" + while :; do + ask answer "Reinstall one of them (everything carries over: settings, passwords, keys)" "1" + [ "$answer" = n ] && break + if [[ $answer =~ ^[0-9]+$ ]] && [ "$answer" -ge 1 ] && [ "$answer" -le ''${#found[@]} ]; then + reuse=''${found[$((answer - 1))]} + reuse_label=''${found_labels[$((answer - 1))]} + break + fi + echo " a number from the list, or n" >&2 + done + echo + 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; } @@ -112,98 +192,159 @@ let 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 + if [ -n "$reuse" ]; then + settings=$reuse/config/settings.json + # Interfaces this machine does not have (other hardware, other names). + # (The list comes in on fd 3: stdin is for the answers.) + while IFS= read -r old <&3; do + [ -e "/sys/class/net/$old" ] && continue + echo + echo "This machine has no interface $old ($(jq -r --arg i "$old" ' + [(if .router.wan.interface == $i then "WAN" else empty end), + (.router.lans // {} | to_entries[] | select(.value.interface == $i) | "LAN \(.key)")] | join(", ")' "$settings")). Which one takes its place?" + choose new "Interface" "''${nics[@]}" + jq --arg o "$old" --arg n "$new" ' + .router.wan |= (if .interface == $o then .interface = $n else . end) + | .router.lans |= ((. // {}) | map_values(if .interface == $o then .interface = $n else . end))' \ + "$settings" > "$settings.new" + mv "$settings.new" "$settings" + done 3< <(jq -r '[.router.wan.interface] + [.router.lans[]?.interface] | unique | .[]' "$settings") + + # Older installs may lack the password files. + if [ ! -s "$reuse/webui-password" ] || [ ! -s "$reuse/root-password" ]; then + echo + echo "The configuration has no password for the web UI and root; choose one (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 + fi + lan_address=$(jq -r '[.router.lans[]?.ipv4.address][0] // empty' "$settings") + + echo + echo "Summary" + echo " disk: $disk (will be erased)" + echo " reinstall: $reuse_label" + echo " now: $(summary "$reuse")" + ask answer "Type the disk name ($disk) to install" + [ "$answer" = "$disk" ] || { echo "aborted" >&2; exit 1; } + + # The state as it was, with this installer's flake (the old one pins a + # nixos-router store path that will not exist on the new system). + mv "$reuse" "$work/secrets" + config=$work/secrets/config + install -m 0644 ${cfg.flake} "$config/flake.nix" + rm -f "$config/flake.lock" "$config/result" + if [ -n "$local_nix" ]; then cp "$local_nix" "$config/local.nix"; fi + ${lib.optionalString (cfg.release != null) '' + # An older configuration may need its settings brought up to date. + router-migrate-settings ${cfg.release} "$config/settings.json" + ''} + # The new system is confirmed from settings.json as installed. + rm -f "$work/secrets/settings.confirmed.json" + rm -rf "$work/found" + if [ -n "$webui_password" ]; then + 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") + fi + else # a new router + + 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 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 + 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 - 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 + net=''${lan_address%.*} - 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 "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; } - 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; } + rm -rf "$work/found" + 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 - 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") } } + keys=$( [ -n "$ssh_key" ] && jq -n --arg k "$ssh_key" '[$k]' || echo '[]') + jq -n \ + --arg host "$host" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \ + --argjson schema ${toString cfg.schemaVersion} \ + --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" ' + { + version: $schema, + 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" + }' > "$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") + 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") + fi # new router echo echo ">> building the router system (no internet needed; takes a few minutes)" @@ -211,7 +352,7 @@ let "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." + echo "After rebooting, open https://''${lan_address:-the LAN address} from the LAN." ''; }; in @@ -230,6 +371,16 @@ in is what lets the installer build the chosen configuration offline. ''; }; + release = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + description = "The release (lib.mkRelease) these installs run; its migrations update reused settings."; + }; + schemaVersion = lib.mkOption { + type = lib.types.ints.unsigned; + default = 0; + description = "The settings schema of the nixos-router these installs run (settings.json's \"version\")."; + }; stateVersion = lib.mkOption { type = lib.types.str; default = config.system.nixos.release; diff --git a/tests/setup.nix b/tests/setup.nix index e77c0b9..b68d96c 100644 --- a/tests/setup.nix +++ b/tests/setup.nix @@ -2,6 +2,12 @@ # 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. +# +# Then the same box is reinstalled twice, everything carrying over (settings +# with an unapplied change, passwords, the web UI's certificate): once from +# the installed disk itself, once from a web UI backup on a USB stick after +# the disk was wiped. The settings name an interface this machine lacks, so +# setup asks which one takes its place. { self, pkgs }: let common = import ./common.nix; @@ -35,6 +41,15 @@ let /dev/vda ''; + + # Reinstall the configuration found first, on /dev/vda; eth1 replaces the + # missing enp9s0. + reuseAnswers = pkgs.writeText "reuse-answers" '' + 1 + /dev/vda + eth1 + /dev/vda + ''; in pkgs.testers.runNixOSTest { name = "router-setup"; @@ -48,6 +63,7 @@ pkgs.testers.runNixOSTest { (self.lib.setupModule { inherit local; }) common.tools ]; + environment.systemPackages = [ pkgs.dosfstools ]; environment.etc."router-test-local.nix".source = local; # The interfaces router-setup offers. virtualisation.vlans = [ @@ -55,8 +71,15 @@ pkgs.testers.runNixOSTest { 2 ]; virtualisation.fileSystems."/".autoFormat = true; - virtualisation.emptyDiskImages = [ 1024 ]; + # vdb: the installer's root; vdc: a USB stick for a backup. + virtualisation.emptyDiskImages = [ + 1024 + 64 + ]; virtualisation.rootDevice = "/dev/vdb"; + # The installer boots several times on the same root disk, whose Nix + # database would list store paths left behind in a tmpfs overlay. + virtualisation.writableStoreUseTmpfs = false; nix.settings.substituters = lib.mkForce [ ]; }; @@ -118,5 +141,60 @@ pkgs.testers.runNixOSTest { with subtest("the box rebuilds to exactly the installed system"): out = target.succeed("router-apply 2>&1") assert "no changes" in out, out + + def install_again(prepare=None): + installer.start() + installer.wait_for_unit("multi-user.target") + if prepare: + prepare() + out = installer.succeed("router-setup --local /etc/router-test-local.nix < ${reuseAnswers} 2>&1") + installer.shutdown() + target.start() + target.wait_for_unit("multi-user.target") + return out + + def check_carried_over(): + assert target.succeed("hostname").strip() == "gw2" + settings = target.succeed("cat /var/lib/router/config/settings.json") + assert '"enp9s0"' not in settings and '"eth1"' in settings, settings + assert target.succeed("sha256sum /var/lib/router/webui/cert.pem").split()[0] == cert + 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 + assert "no changes" in target.succeed("router-apply 2>&1") + + with subtest("reinstalling from the installed disk carries everything over"): + # Saved but not applied; and an interface this machine does not have. + target.succeed("sed -i 's/\"hostName\": \"gw\"/\"hostName\": \"gw2\"/; s/\"interface\": \"eth1\"/\"interface\": \"enp9s0\"/'" + " /var/lib/router/config/settings.json") + cert = target.succeed("sha256sum /var/lib/router/webui/cert.pem").split()[0] + # A backup from the web UI, for the next subtest. + target.succeed("curl -sk -c /tmp/jar -o /dev/null -d password=hunter2hunter2 https://127.0.0.1/login") + token = target.succeed("curl -sk -b /tmp/jar https://127.0.0.1/advanced | grep -o 'name=\"csrf\" value=\"[^\"]*' | head -1 | cut -d'\"' -f4").strip() + target.succeed(f"curl -sfk -b /tmp/jar -d csrf={token} -o /tmp/router-backup-gw2.tar.gz https://127.0.0.1/backup") + assert "router/config/settings.json" in target.succeed("tar -tzf /tmp/router-backup-gw2.tar.gz").split() + # The installed system has no shared folder with the host: pass it as text. + backup = target.succeed("base64 -w0 /tmp/router-backup-gw2.tar.gz").strip() + target.shutdown() + + out = install_again() + assert "installed on /dev/vda2: gw2" in out, out + assert "no interface enp9s0 (WAN)" in out, out + check_carried_over() + + with subtest("a backup on a USB stick is found after the disk was wiped"): + target.shutdown() + + def wipe_and_plug_in_stick(): + installer.succeed("wipefs -a /dev/vda2 /dev/vda1 /dev/vda && blockdev --rereadpt /dev/vda && udevadm settle") + installer.succeed("mkfs.vfat /dev/vdc && udevadm settle && mount /dev/vdc /mnt") + installer.succeed(f"echo {backup} | base64 -d > /mnt/router-backup-gw2.tar.gz") + installer.succeed("umount /mnt") + + out = install_again(wipe_and_plug_in_stick) + assert "backup router-backup-gw2.tar.gz on /dev/vdc: gw2" in out, out + assert "installed on" not in out, out + check_carried_over() ''; }