Reuse an existing install or backup in router-setup

router-setup finds earlier installs and router-backup-*.tar.gz files and
reinstalls them with everything in /var/lib/router carried over.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-29 13:34:21 +01:00
co-authored by Claude Sonnet 5.5
parent 8f65587f99
commit e684ddb06c
2 changed files with 314 additions and 85 deletions
+235 -84
View File
@@ -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;