diff --git a/nix/image-installer/module.nix b/nix/image-installer/module.nix index 07a1c5b..44a2100 100644 --- a/nix/image-installer/module.nix +++ b/nix/image-installer/module.nix @@ -40,8 +40,9 @@ in imports = [ (modulesPath + "/installer/cd-dvd/installation-cd-base.nix") ../installer.nix - ./wifi.nix + ../noveau-workaround.nix ./hidden-ssh-announcement.nix + ./wifi.nix ]; systemd.tmpfiles.rules = [ "d /var/shared 0777 root root - -" ]; services.openssh.settings.PermitRootLogin = "yes"; diff --git a/nix/kexec-installer/local-network-restore-test.sh b/nix/kexec-installer/local-network-restore-test.sh new file mode 100755 index 0000000..447ace9 --- /dev/null +++ b/nix/kexec-installer/local-network-restore-test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env -S nix shell --inputs-from .# nixos-unstable#bash nixos-unstable#iproute2 nixos-unstable#findutils nixos-unstable#coreutils nixos-unstable#python3 nixos-unstable#jq --command bash + +set -eu +SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + +# This script can be used to see what network configuration would be restored by the restore_routes.py script for the current system. + +tmp=$(mktemp -d) +trap "rm -rf $tmp" EXIT +ip --json address >"$tmp/addrs.json" +ip -6 --json route >"$tmp/routes-v6.json" +ip -4 --json route >"$tmp/routes-v4.json" +python3 "$SCRIPT_DIR/restore_routes.py" "$tmp/addrs.json" "$tmp/routes-v4.json" "$tmp/routes-v6.json" "$tmp" +ls -la "$tmp" + +find "$tmp" -type f -name "*.json" -print0 | while IFS= read -r -d '' file; do + echo -e "\033[0;31m$(basename "$file")\033[0m" + jq . "$file" + echo "" +done + +find "$tmp" -type f -name "*.network" -print0 | while IFS= read -r -d '' file; do + echo -e "\033[0;31m$(basename "$file")\033[0m" + cat "$file" + echo "" +done diff --git a/nix/kexec-installer/module.nix b/nix/kexec-installer/module.nix index a10e6a5..4bd1511 100644 --- a/nix/kexec-installer/module.nix +++ b/nix/kexec-installer/module.nix @@ -1,6 +1,6 @@ { config, lib, modulesPath, pkgs, ... }: let - restore-network = pkgs.writers.writePython3 "restore-network" { flakeIgnore = [ "E501" ]; } + restore-network = pkgs.writers.writePython3Bin "restore-network" { flakeIgnore = [ "E501" ]; } ./restore_routes.py; # does not link with iptables enabled @@ -64,7 +64,7 @@ in Type = "oneshot"; RemainAfterExit = true; ExecStart = [ - "${restore-network} /root/network/addrs.json /root/network/routes-v4.json /root/network/routes-v6.json /etc/systemd/network" + "${restore-network}/bin/restore-network /root/network/addrs.json /root/network/routes-v4.json /root/network/routes-v6.json /etc/systemd/network" ]; }; diff --git a/nix/kexec-installer/restore_routes.py b/nix/kexec-installer/restore_routes.py index 1bf1801..8bb997d 100644 --- a/nix/kexec-installer/restore_routes.py +++ b/nix/kexec-installer/restore_routes.py @@ -1,34 +1,80 @@ import json import sys from pathlib import Path -from typing import Any +from typing import Any, Iterator +from dataclasses import dataclass -def filter_interfaces(network: list[dict[str, Any]]) -> list[dict[str, Any]]: - output = [] +@dataclass +class Address: + address: str + family: str + prefixlen: int + preferred_life_time: int = 0 + valid_life_time: int = 0 + + +@dataclass +class Interface: + name: str + ifname: str | None + mac_address: str + dynamic_addresses: list[Address] + static_addresses: list[Address] + static_routes: list[dict[str, Any]] + + +def filter_interfaces(network: list[dict[str, Any]]) -> list[Interface]: + interfaces = [] for net in network: if net.get("link_type") == "loopback": continue - if not net.get("address"): + if not (mac_address := net.get("address")): # We need a mac address to match devices reliable continue - addr_info = [] - has_dynamic_address = False - for addr in net.get("addr_info", []): + static_addresses = [] + dynamic_addresses = [] + for info in net.get("addr_info", []): # no link-local ipv4/ipv6 - if addr.get("scope") == "link": + if info.get("scope") == "link": + continue + if (preferred_life_time := info.get("preferred_life_time")) is None: + continue + if (valid_life_time := info.get("valid_life_time")) is None: + continue + if (prefixlen := info.get("prefixlen")) is None: + continue + if (family := info.get("family")) not in ["inet", "inet6"]: continue - # do not explicitly configure addresses from dhcp or router advertisement - if addr.get("dynamic", False): - has_dynamic_address = True + if (local := info.get("local")) is None: continue + if (dynamic := info.get("dynamic", False)) is None: + continue + + address = Address( + address=local, + family=family, + prefixlen=prefixlen, + preferred_life_time=preferred_life_time, + valid_life_time=valid_life_time, + ) + + if dynamic: + dynamic_addresses.append(address) else: - addr_info.append(addr) - if addr_info != [] or has_dynamic_address: - net["addr_info"] = addr_info - output.append(net) + static_addresses.append(address) + interfaces.append( + Interface( + name=net.get("ifname", mac_address.replace(":", "-")), + ifname=net.get("ifname"), + mac_address=mac_address, + dynamic_addresses=dynamic_addresses, + static_addresses=static_addresses, + static_routes=[], + ) + ) - return output + return interfaces def filter_routes(routes: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -42,44 +88,54 @@ def filter_routes(routes: list[dict[str, Any]]) -> list[dict[str, Any]]: return filtered +def find_most_recent_v4_lease(addresses: list[Address]) -> Address | None: + most_recent_address = None + most_recent_lifetime = -1 + for addr in addresses: + if addr.family == "inet6": + continue + lifetime = max(addr.preferred_life_time, addr.valid_life_time) + if lifetime > most_recent_lifetime: + most_recent_lifetime = lifetime + most_recent_address = addr + return most_recent_address + + +def generate_routes( + interface: Interface, routes: list[dict[str, Any]] +) -> Iterator[str]: + for route in routes: + if interface.ifname is None or route.get("dev") != interface.ifname: + continue + + # we may ignore on-link default routes here, but I don't see how + # they would be useful for internet connectivity anyway + + yield "[Route]" + if route.get("dst") != "default": + # can be skipped for default routes + yield f"Destination = {route['dst']}" + gateway = route.get("gateway") + # route v4 via v6 + route_via = route.get("via") + if route_via and route_via.get("family") == "inet6": + gateway = route_via.get("host") + if route.get("dst") == "default": + yield "Destination = 0.0.0.0/0" + if gateway: + yield f"Gateway = {gateway}" + + def generate_networkd_units( - interfaces: list[dict[str, Any]], routes: list[dict[str, Any]], directory: Path + interfaces: list[Interface], routes: list[dict[str, Any]], directory: Path ) -> None: directory.mkdir(exist_ok=True) for interface in interfaces: - name = f"00-{interface['ifname']}.network" - addresses = [ - f"Address = {addr['local']}/{addr['prefixlen']}" - for addr in interface.get("addr_info", []) - ] - - route_sections = [] - for route in routes: - if route.get("dev", "nodev") != interface.get("ifname", "noif"): - continue - - route_section = "[Route]\n" - if route.get("dst") != "default": - # can be skipped for default routes - route_section += f"Destination = {route['dst']}\n" - gateway = route.get("gateway") - # route v4 via v6 - route_via = route.get("via") - if route_via and route_via.get("family") == "inet6": - gateway = route_via.get("host") - if route.get("dst") == "default": - route_section += "Destination = 0.0.0.0/0\n" - if gateway: - route_section += f"Gateway = {gateway}\n" - - # we may ignore on-link default routes here, but I don't see how - # they would be useful for internet connectivity anyway - route_sections.append(route_section) - # FIXME in some networks we might not want to trust dhcp or router advertisements - unit = f""" + unit_sections = [ + f""" [Match] -MACAddress = {interface["address"]} +MACAddress = {interface.mac_address} [Network] # both ipv4 and ipv6 @@ -89,12 +145,24 @@ def generate_networkd_units( # ipv6 router advertisements IPv6AcceptRA = yes # allows us to ping "nixos.local" -MulticastDNS = yes +MulticastDNS = yes""" + ] + unit_sections.extend( + f"Address = {addr.address}/{addr.prefixlen}" + for addr in interface.static_addresses + ) + unit_sections.extend(generate_routes(interface, routes)) + most_recent_v4_lease = find_most_recent_v4_lease(interface.dynamic_addresses) + if most_recent_v4_lease: + unit_sections.append("[DHCPv4]") + unit_sections.append(f"RequestAddress = {most_recent_v4_lease.address}") + + # trailing newline at the end + unit_sections.append("") -""" - unit += "\n".join(addresses) - unit += "\n" + "\n".join(route_sections) - (directory / name).write_text(unit) + (directory / f"00-{interface.name}.network").write_text( + "\n".join(unit_sections) + ) def main() -> None: diff --git a/nix/noninteractive.nix b/nix/noninteractive.nix index 54690dc..ee87048 100644 --- a/nix/noninteractive.nix +++ b/nix/noninteractive.nix @@ -12,6 +12,7 @@ imports = [ ./zfs-minimal.nix ./no-bootloaders.nix + ./noveau-workaround.nix # reduce closure size by removing perl "${modulesPath}/profiles/perlless.nix" # FIXME: we still are left with nixos-generate-config due to nixos-install-tools @@ -35,10 +36,14 @@ users.users.nixos = { isSystemUser = true; isNormalUser = lib.mkForce false; + shell = "/run/current-system/sw/bin/bash"; group = "nixos"; }; users.groups.nixos = {}; + # we prefer root as this is also what we use in nixos-anywhere + services.getty.autologinUser = lib.mkForce "root"; + # we are missing this from base.nix boot.supportedFilesystems = [ "btrfs" diff --git a/nix/noveau-workaround.nix b/nix/noveau-workaround.nix new file mode 100644 index 0000000..5865852 --- /dev/null +++ b/nix/noveau-workaround.nix @@ -0,0 +1,4 @@ +{ + # fixes blank screen on boot for some cards + boot.kernelParams = [ "nouveau.modeset=0" ]; +}