# `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. # # 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, pkgs, ... }: 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 "/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 } # 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="" 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" 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 # 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; } 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; } 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 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; } 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 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" 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)" 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:-the 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. ''; }; 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; description = "system.stateVersion of installed routers."; }; }; config = { environment.systemPackages = [ setup ]; system.extraDependencies = [ cfg.reference ]; nix.settings.experimental-features = [ "nix-command" "flakes" ]; }; }