router-webui leases-dns reads Kea's leases and publishes <name>.<domain> with reverse records in the running Unbound (router.dns.dynamicLeases). Names the configuration publishes are never taken over; a client's name is cut to its first label and kept inside the local domain; of two clients the newer lease wins. The DNS page switches it on and shows what is published and what was skipped; router-setup enables it with a domain. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
462 lines
19 KiB
Nix
462 lines
19 KiB
Nix
# `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] [--config-only DIR]" >&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
|
|
echo " --config-only DIR only write the settings and secrets to DIR; install nothing" >&2
|
|
exit 2
|
|
}
|
|
local_nix="" config_only=""
|
|
while [ $# -gt 0 ]; do
|
|
case $1 in
|
|
--local) shift; [ $# -gt 0 ] || usage; local_nix=$1 ;;
|
|
--config-only) shift; [ $# -gt 0 ] || usage; config_only=$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
|
|
}
|
|
yes_no() { # yes_no VAR PROMPT DEFAULT(y|n)
|
|
local yn
|
|
while :; do
|
|
ask yn "$2 (y/n)" "$3"
|
|
case $yn in
|
|
y|Y|yes) printf -v "$1" '%s' y; return ;;
|
|
n|N|no) printf -v "$1" '%s' n; return ;;
|
|
esac
|
|
echo " y or n" >&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="" domain="" wan="" lan="" wan_type="" want_vlans="" want_v6="" vlan_parent="" vlan_id="" 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
|
|
|
|
# Devices WAN and LAN can be assigned to: the NICs, plus VLANs on them.
|
|
devices=("''${nics[@]}")
|
|
echo
|
|
echo "VLANs are separate networks sharing one cable: some providers deliver the internet"
|
|
echo "tagged with a VLAN ID, and one NIC can then also serve the LAN untagged."
|
|
yes_no want_vlans "Create VLANs now?" n
|
|
while [ "$want_vlans" = y ]; do
|
|
choose vlan_parent "VLAN on which interface" "''${nics[@]}"
|
|
while :; do
|
|
ask vlan_id "VLAN ID (1-4094)"
|
|
[[ $vlan_id =~ ^[0-9]+$ ]] && [ "$vlan_id" -ge 1 ] && [ "$vlan_id" -le 4094 ] && break
|
|
echo " a number from 1 to 4094" >&2
|
|
done
|
|
devices+=("$vlan_parent.$vlan_id VLAN $vlan_id on $vlan_parent")
|
|
yes_no want_vlans "Another VLAN?" n
|
|
done
|
|
|
|
echo
|
|
echo "Interface towards the internet (WAN):"
|
|
choose wan "WAN interface" "''${devices[@]}"
|
|
echo
|
|
echo "Interface for the local network (LAN):"
|
|
choose lan "LAN interface" "''${devices[@]}"
|
|
|
|
# A VLAN device is its NIC plus an ID in the settings.
|
|
wan_dev=''${wan%.*} wan_vlan=""
|
|
[ "$wan_dev" = "$wan" ] || wan_vlan=''${wan##*.}
|
|
lan_dev=''${lan%.*} lan_vlan=""
|
|
[ "$lan_dev" = "$lan" ] || lan_vlan=''${lan##*.}
|
|
if [ "$wan" = "$lan" ]; then
|
|
echo "router-setup: WAN and LAN cannot be the same interface" >&2
|
|
exit 1
|
|
fi
|
|
|
|
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=""
|
|
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
|
|
fi
|
|
if [ -n "$wan_vlan" ] && [ "$wan_type" != pppoe ]; then
|
|
echo "router-setup: an uplink on a VLAN is only supported with PPPoE" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$wan_dev" = "$lan_dev" ] && [ -z "$wan_vlan$lan_vlan" ]; then
|
|
echo "router-setup: WAN and LAN cannot share an interface without a VLAN" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$wan_dev" = "$lan_dev" ] && [ -z "$wan_vlan" ]; then
|
|
echo "router-setup: with WAN and LAN on one interface, the WAN must be the VLAN" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo
|
|
yes_no want_v6 "Enable IPv6 (the provider delegates a prefix to the LAN)?" y
|
|
echo
|
|
ask host "Host name" "router"
|
|
while :; do
|
|
ask domain "Local domain for DNS and DHCP, like home.example.org (optional)" ""
|
|
[ -z "$domain" ] && break
|
|
[[ $domain =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] && break
|
|
echo " lower case letters, digits, dashes and dots" >&2
|
|
done
|
|
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}, IPv6 $([ "$want_v6" = y ] && echo on || echo off)"
|
|
echo " LAN: $lan, $lan_address/24, DHCP $net.100 - $net.200"
|
|
echo " host name: $host''${domain:+ (domain $domain)}"
|
|
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 domain "$domain" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \
|
|
--argjson schema ${toString cfg.schemaVersion} \
|
|
--arg wan "$wan_dev" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$wan_vlan" \
|
|
--arg lan "$lan_dev" --arg lanvlan "$lan_vlan" --arg v6 "$want_v6" \
|
|
--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
|
|
| if $v6 == "y" then .ipv6 = { enable: true, transport: (if $type == "pppoe" then "ppp" else "native" end) } else . end),
|
|
lans: {
|
|
lan: ({
|
|
interface: $lan, internet: true,
|
|
ipv4: { address: $addr },
|
|
dhcp: { pool: { start: ($net + ".100"), end: ($net + ".200") } }
|
|
}
|
|
+ (if $lanvlan == "" then {} else { vlan: ($lanvlan | tonumber) } end)
|
|
+ (if $v6 == "y" then { ipv6: { pd: true } } else {} end))
|
|
}
|
|
}
|
|
+ (if $domain == "" then {} else { dns: { domain: $domain, dynamicLeases: true } } end))
|
|
}' > "$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
|
|
|
|
if [ -n "$config_only" ]; then
|
|
mkdir -p "$config_only"
|
|
cp -a "$work/secrets/." "$config_only/"
|
|
echo "router-setup: wrote the configuration to $config_only"
|
|
exit 0
|
|
fi
|
|
|
|
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"
|
|
];
|
|
};
|
|
}
|