commit dcef7665b4f789f877f90886d51690ac68938f51 Author: Nils Stinnesbeck Date: Tue Oct 27 13:38:41 2026 +0100 Add MVP router flake with VM test and tmux lab router.* schema (wan, lans, dns.forwarders) compiled to systemd-networkd, a generated nftables ruleset with NAT, Kea DHCPv4 and Unbound, plus build-time assertions against lock-out and interface conflicts. tests/basic.nix is a three-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP and DNS, NAT, WAN-deny and LAN SSH/ping. `nix run .#lab` boots the same topology in tmux with one console pane per VM and the driver REPL. Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c85ede --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +result +result-* +.nixos-test-history diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..23c27d8 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,86 @@ +# nixos-router — Plan + +Working name: `nixos-router`. A NixOS-based distribution for routers/firewalls, delivered as a flake exporting NixOS modules and bootable images (not a nixpkgs fork). + +## Decisions + +| Area | Decision | +|---|---| +| Audience | Own homelab first; keep structure clean enough to publish later. | +| Config model | Opinionated `router.*` option schema compiled to systemd-networkd + nftables, with build-time assertions. | +| Hardware | x86_64 UEFI first. aarch64 is a planned target; avoid x86-only assumptions. | +| v1 features | IPv6 + prefix delegation, VLANs / multiple LANs, WireGuard. | +| Deferred | Multi-WAN failover, QoS (CAKE/SQM), web UI. | +| Updates (v1) | Push from workstation (`nixos-rebuild --target-host` / colmena) + commit-confirm rollback timer. | +| Updates (later) | **A/B image updates** with automatic rollback. | +| Footprint | Minimal closure, read-only root, no Nix daemon/compiler on device, small persistent `/var`. | + +## Architecture + +1. **`router.*` schema** — users describe intent (wan, lan, vlans, zones, port forwards, wireguard); modules compile it to: + - systemd-networkd (DHCPv4/v6, DHCPv6-PD, VLANs, bridges, PPPoE later) + - nftables zone-based ruleset (generated, not `networking.firewall`) + - DHCP / DNS / RA services +2. **Build-time validation** — Nix assertions reject lock-out or inconsistent configs (no management access rule, overlapping subnets, zone referencing missing interface); generated ruleset checked with `nft -c`. +3. **Images** — x86_64 first (installer ISO + raw disk), aarch64 later. Read-only root (erofs/squashfs store), small persistent `/var`. +4. **Deployment** — deploy a prebuilt closure (`nix copy` + `switch-to-configuration`); never evaluate or build on the router. This keeps the device light and leads naturally to A/B updates. + +## Implications + +- No on-device evaluation: all designs must work from a prebuilt closure. +- Flake exposes `x86_64-linux` and `aarch64-linux` outputs from the start; hardware specifics (bootloader, kernel, NIC naming) live in separate `boards/` modules. +- IPv6 PD fan-out to multiple VLANs needs stable per-LAN subnet-ID allocation; this is the hardest part of the schema. + +## Default zone policy (confirmed) + +- WAN: deny inbound (except established/related, required ICMPv6, explicit port forwards). +- LAN: allow outbound to WAN. +- Other zones (e.g. IoT): isolated from each other and from LAN unless a rule opens them. + +## Management access (confirmed) + +- Key-only SSH, reachable from LAN zones (trusted, non-WAN, non-isolated zones). No GUI in v1. +- Build-time assertion fails if no management path exists. +- Isolated zones (e.g. IoT) get no router access beyond DHCP/DNS/NDP. + +## Services (confirmed) + +- DHCP: Kea (DHCPv4 + DHCPv6 incl. PD). +- DNS: Unbound. +- RA: systemd-networkd (or radvd if networkd falls short). + +## Repo layout (confirmed) + +``` +flake.nix +modules/ # router.* schema and service modules +boards/ # hardware-specific modules (x86_64 first, aarch64 later) +tests/ # nixosTest VM tests +images/ # image/ISO builds +examples/ # sample configs +``` + +## Status + +**Phase 1 (MVP) skeleton done**: flake, `router.*` schema (wan, lans, dns.forwarders), networkd, nftables (zone-less for now: WAN/LAN), Kea DHCPv4, Unbound, assertions. `nix build .#checks.x86_64-linux.basic` runs a 3-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP+DNS, NAT, WAN-deny and LAN SSH. + +Interactive lab: `nix run .#lab` (x86_64) opens tmux with one console pane per VM plus the test-driver REPL (`router.shell_interact()`, `client.succeed(...)`, `test_script()`). The REPL pane also shows all VM output interleaved. + +Learned along the way: +- The default policy now explicitly answers ICMP echo on LANs only; the WAN gets only ICMP error types and ND. +- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check. +- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module). +- Subnets are given explicitly (`subnet` + `address`) for now; a small CIDR helper lib is needed before IPv6 PD / VLAN subnet-ID allocation. +- Not yet covered: IPv6, VLANs, WireGuard, formal zones, a test for the assertions themselves (verified manually via `nix eval`). + +## Open items + +- **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system. + +## Phases + +1. **MVP** — flake with `router.*` schema: single WAN (DHCP), single LAN, NAT, basic firewall, DHCP + DNS. NixOS VM tests (`nixosTest`) in CI. +2. **Real-world features** — IPv6 PD, VLANs, zone policy, port forwards, WireGuard. +3. **Images and hardware** — installer ISO, read-only root, aarch64. +4. **Operations** — commit-confirm rollback, monitoring (node exporter, nftables counters), A/B image updates; multi-WAN failover. +5. **Optional** — web UI (stats, then configuration). diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3ddc189 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1792773339, + "narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..53957b6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,46 @@ +{ + description = "nixos-router: a NixOS-based distribution for routers/firewalls"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + nixosModules = { + router = ./modules/router; + default = self.nixosModules.router; + }; + + # VM tests need KVM; only wired up for x86_64 until aarch64 CI exists. + checks.x86_64-linux = import ./tests { + inherit self; + pkgs = nixpkgs.legacyPackages.x86_64-linux; + }; + + # `nix run .#lab`: boot the basic test topology in tmux, one console pane + # per VM plus the interactive test-driver REPL. + apps.x86_64-linux.lab = + let + pkgs = nixpkgs.legacyPackages.x86_64-linux; + test = self.checks.x86_64-linux.basic; + lab = pkgs.writeShellApplication { + name = "router-lab"; + runtimeInputs = with pkgs; [ tmux util-linux gnugrep coreutils ]; + text = import ./tests/lab.nix { + inherit (pkgs) lib; + driver = test.driverInteractive; + nodes = builtins.attrNames test.nodes; + }; + }; + in + { + type = "app"; + program = "${lab}/bin/router-lab"; + }; + + formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); + }; +} diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix new file mode 100644 index 0000000..9ba8baa --- /dev/null +++ b/modules/router/assertions.nix @@ -0,0 +1,31 @@ +{ config, lib, ... }: +let + cfg = config.router; + lans = lib.attrValues cfg.lans; +in +{ + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = lans != [ ]; + message = "router: at least one LAN must be defined."; + } + { + assertion = lib.any (l: l.allowManagement) lans; + message = "router: no LAN has allowManagement = true; you would be locked out of the router."; + } + { + assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans)); + message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN."; + } + { + assertion = + let + ifaces = map (l: l.interface) lans; + in + lib.length ifaces == lib.length (lib.unique ifaces); + message = "router: two LANs share the same interface."; + } + ]; + }; +} diff --git a/modules/router/default.nix b/modules/router/default.nix new file mode 100644 index 0000000..ad1d190 --- /dev/null +++ b/modules/router/default.nix @@ -0,0 +1,10 @@ +{ + imports = [ + ./options.nix + ./assertions.nix + ./networking.nix + ./firewall.nix + ./dhcp.nix + ./dns.nix + ]; +} diff --git a/modules/router/dhcp.nix b/modules/router/dhcp.nix new file mode 100644 index 0000000..27075ab --- /dev/null +++ b/modules/router/dhcp.nix @@ -0,0 +1,35 @@ +{ config, lib, ... }: +let + cfg = config.router; + dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans); +in +{ + config = lib.mkIf (cfg.enable && dhcpLans != [ ]) { + services.kea.dhcp4 = { + enable = true; + settings = { + interfaces-config = { + interfaces = map (l: l.interface) dhcpLans; + # Interfaces are configured asynchronously by networkd. + re-detect = true; + }; + lease-database = { + type = "memfile"; + persist = true; + name = "/var/lib/kea/dhcp4.leases"; + }; + valid-lifetime = 3600; + subnet4 = lib.imap1 (id: l: { + inherit id; + inherit (l.ipv4) subnet; + interface = l.interface; + pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ]; + option-data = [ + { name = "routers"; data = l.ipv4.address; } + { name = "domain-name-servers"; data = l.ipv4.address; } + ]; + }) dhcpLans; + }; + }; + }; +} diff --git a/modules/router/dns.nix b/modules/router/dns.nix new file mode 100644 index 0000000..9c8b5be --- /dev/null +++ b/modules/router/dns.nix @@ -0,0 +1,29 @@ +{ config, lib, ... }: +let + cfg = config.router; + lans = lib.attrValues cfg.lans; +in +{ + config = lib.mkIf cfg.enable { + services.unbound = { + enable = true; + settings = { + server = { + interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans; + # LAN addresses are assigned by networkd, possibly after Unbound starts. + ip-freebind = true; + access-control = [ + "127.0.0.0/8 allow" + ] ++ map (l: "${l.ipv4.subnet} allow") lans; + }; + } // lib.optionalAttrs (cfg.dns.forwarders != [ ]) { + forward-zone = [ + { + name = "."; + forward-addr = cfg.dns.forwarders; + } + ]; + }; + }; + }; +} diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix new file mode 100644 index 0000000..c8fa601 --- /dev/null +++ b/modules/router/firewall.nix @@ -0,0 +1,78 @@ +{ config, lib, ... }: +let + cfg = config.router; + lans = lib.attrValues cfg.lans; + + set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }"; + lanIfaces = map (l: l.interface) lans; + mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans); + dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans); +in +{ + config = lib.mkIf cfg.enable { + # The generated ruleset replaces networking.firewall entirely. + networking.firewall.enable = false; + networking.nftables.enable = true; + + networking.nftables.tables.router = { + family = "inet"; + content = '' + chain input { + type filter hook input priority filter; policy drop; + + iifname "lo" accept + ct state established,related accept + ct state invalid drop + + # Error types and ND are required on every interface (WAN included); + # echo is only answered on LANs. + icmp type { destination-unreachable, time-exceeded, parameter-problem } accept + icmpv6 type { + destination-unreachable, packet-too-big, time-exceeded, parameter-problem, + nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert + } accept + iifname ${set lanIfaces} icmp type echo-request accept + iifname ${set lanIfaces} icmpv6 type echo-request accept + + iifname "${cfg.wan.interface}" udp dport 68 accept + + iifname ${set lanIfaces} udp dport 53 accept + iifname ${set lanIfaces} tcp dport 53 accept + ${lib.optionalString (dhcpIfaces != [ ]) ''iifname ${set dhcpIfaces} udp dport 67 accept''} + ${lib.optionalString (mgmtIfaces != [ ]) ''iifname ${set mgmtIfaces} tcp dport 22 accept''} + } + + chain forward { + type filter hook forward priority filter; policy drop; + + ct state established,related accept + ct state invalid drop + + icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept + + iifname ${set lanIfaces} oifname "${cfg.wan.interface}" accept + } + ''; + }; + + networking.nftables.tables.router-nat = { + family = "ip"; + content = '' + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + oifname "${cfg.wan.interface}" masquerade + } + ''; + }; + + services.openssh = { + enable = true; + openFirewall = false; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PermitRootLogin = "prohibit-password"; + }; + }; + }; +} diff --git a/modules/router/networking.nix b/modules/router/networking.nix new file mode 100644 index 0000000..fc7d9d9 --- /dev/null +++ b/modules/router/networking.nix @@ -0,0 +1,30 @@ +{ config, lib, ... }: +let + cfg = config.router; +in +{ + config = lib.mkIf cfg.enable { + networking.useNetworkd = true; + networking.useDHCP = false; + + boot.kernel.sysctl = { + "net.ipv4.conf.all.forwarding" = 1; + }; + + systemd.network.networks = { + "10-wan" = { + matchConfig.Name = cfg.wan.interface; + networkConfig.DHCP = "ipv4"; + # The router runs its own resolver; never adopt the ISP's DNS for the host. + dhcpV4Config.UseDNS = false; + }; + } // lib.mapAttrs' ( + name: lan: + lib.nameValuePair "20-lan-${name}" { + matchConfig.Name = lan.interface; + address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ]; + networkConfig.ConfigureWithoutCarrier = true; + } + ) cfg.lans; + }; +} diff --git a/modules/router/options.nix b/modules/router/options.nix new file mode 100644 index 0000000..1ffd4aa --- /dev/null +++ b/modules/router/options.nix @@ -0,0 +1,79 @@ +{ lib, ... }: +let + inherit (lib) mkOption mkEnableOption types; +in +{ + options.router = { + enable = mkEnableOption "the router role"; + + wan = { + interface = mkOption { + type = types.str; + example = "eth0"; + description = "Name of the uplink interface."; + }; + }; + + lans = mkOption { + default = { }; + description = "LAN segments served by this router, keyed by name."; + type = types.attrsOf ( + types.submodule { + options = { + interface = mkOption { + type = types.str; + example = "eth1"; + description = "Interface (or bridge/VLAN device) carrying this LAN."; + }; + + ipv4 = { + address = mkOption { + type = types.str; + example = "192.168.10.1"; + description = "Router address on this LAN."; + }; + prefixLength = mkOption { + type = types.ints.between 8 30; + default = 24; + }; + subnet = mkOption { + type = types.str; + example = "192.168.10.0/24"; + description = "Network in CIDR notation; must contain `address`."; + }; + }; + + dhcp = { + enable = mkOption { + type = types.bool; + default = true; + }; + pool = { + start = mkOption { + type = types.str; + example = "192.168.10.100"; + }; + end = mkOption { + type = types.str; + example = "192.168.10.199"; + }; + }; + }; + + allowManagement = mkOption { + type = types.bool; + default = true; + description = "Whether clients on this LAN may reach SSH on the router."; + }; + }; + } + ); + }; + + dns.forwarders = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Upstream resolvers. When empty, Unbound resolves recursively."; + }; + }; +} diff --git a/tests/basic.nix b/tests/basic.nix new file mode 100644 index 0000000..4331d87 --- /dev/null +++ b/tests/basic.nix @@ -0,0 +1,128 @@ +# Single WAN (DHCP) + single LAN: DHCP, DNS, NAT and the default firewall policy. +# +# upstream (ISP, 203.0.113.1) -- vlan 1 -- router -- vlan 2 -- client +{ self, pkgs }: +let + # `fail` in the test script also passes on "command not found", so make sure + # the tools the script relies on are really there. + tools = { pkgs, ... }: { + environment.systemPackages = with pkgs; [ dnsutils netcat-openbsd curl tcpdump ]; + }; +in +pkgs.testers.runNixOSTest { + name = "router-basic"; + + defaults.imports = [ tools ]; + + nodes = { + # Plays the ISP: hands out one WAN lease, answers DNS, and serves a page + # that echoes the client address it sees (to prove NAT happens). + upstream = { lib, ... }: { + virtualisation.vlans = [ 1 ]; + networking.useDHCP = false; + networking.firewall.enable = false; + networking.interfaces.eth1.ipv4 = { + addresses = lib.mkForce [ { address = "203.0.113.1"; prefixLength = 24; } ]; + # Route back to the LAN via the router so that "unreachable" below + # is due to the firewall and not a missing route. + routes = [ { address = "192.168.10.0"; prefixLength = 24; via = "203.0.113.100"; } ]; + }; + + services.dnsmasq = { + enable = true; + settings = { + interface = "eth1"; + bind-interfaces = true; + dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h"; + dhcp-option = [ "option:router,203.0.113.1" ]; + address = "/example.test/203.0.113.1"; + }; + }; + + services.nginx = { + enable = true; + virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"''; + }; + }; + + router = { lib, ... }: { + imports = [ self.nixosModules.router ]; + virtualisation.vlans = [ 1 2 ]; + + # The test driver preconfigures static addresses on every vlan interface. + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ]; + + router = { + enable = true; + wan.interface = "eth1"; + lans.main = { + interface = "eth2"; + ipv4 = { address = "192.168.10.1"; subnet = "192.168.10.0/24"; }; + dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; }; + }; + dns.forwarders = [ "203.0.113.1" ]; + }; + + # Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC + # against a fake ISP resolver that cannot provide it; neither applies to + # a real forwarder, so relax both for the made-up test domain only. + services.unbound.settings.server = { + local-zone = [ "test. transparent" ]; + domain-insecure = [ "example.test" ]; + }; + }; + + client = { lib, ... }: { + virtualisation.vlans = [ 2 ]; + networking.useNetworkd = true; + networking.useDHCP = false; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + systemd.network.networks."10-lan" = { + matchConfig.Name = "eth1"; + networkConfig.DHCP = "ipv4"; + }; + networking.firewall.enable = false; + }; + }; + + testScript = '' + start_all() + + upstream.wait_for_unit("dnsmasq.service") + upstream.wait_for_unit("nginx.service") + + with subtest("router obtains its WAN lease and brings up services"): + router.wait_for_unit("network-online.target") + router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100") + router.wait_for_unit("nftables.service") + router.wait_for_unit("kea-dhcp4-server.service") + router.wait_for_unit("unbound.service") + + with subtest("client gets address, gateway and DNS from the router"): + client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.") + client.succeed("ip route show default | grep -q 'via 192.168.10.1'") + client.succeed("resolvectl status eth1 | grep -q 192.168.10.1") + + with subtest("client resolves names through the router"): + client.wait_until_succeeds("dig +short +time=2 +tries=1 @192.168.10.1 example.test | grep -qx 203.0.113.1") + + with subtest("client reaches the 'internet' and is NATed"): + out = client.wait_until_succeeds("curl -sf --max-time 5 --resolve example.test:80:203.0.113.1 http://example.test/") + assert out.strip() == "203.0.113.100", f"expected NATed source 203.0.113.100, got {out!r}" + + with subtest("WAN cannot initiate to the router or the LAN"): + # Positive control: the probe tool works against a port that is open. + upstream.succeed("nc -z -w 2 203.0.113.1 80") + upstream.fail("nc -z -w 2 203.0.113.100 22") + upstream.fail("nc -z -w 2 203.0.113.100 53") + upstream.fail("ping -c 1 -W 2 192.168.10.1") + upstream.fail("ping -c 1 -W 2 203.0.113.100") + client_ip = client.succeed("ip -4 -o addr show dev eth1 | awk '{print $4}' | cut -d/ -f1").strip() + upstream.fail(f"ping -c 1 -W 2 {client_ip}") + + with subtest("LAN can reach SSH and ping on the router"): + client.succeed("nc -z -w 2 192.168.10.1 22") + client.succeed("ping -c 1 -W 2 192.168.10.1") + ''; +} diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..f148b6b --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,4 @@ +{ self, pkgs }: +{ + basic = import ./basic.nix { inherit self pkgs; }; +} diff --git a/tests/lab.nix b/tests/lab.nix new file mode 100644 index 0000000..85196fa --- /dev/null +++ b/tests/lab.nix @@ -0,0 +1,44 @@ +# Shell script for `nix run .#lab` (see flake.nix). +{ lib, driver, nodes }: +'' + sock=nixos-router-lab + log=$(mktemp -t nixos-router-lab.XXXXXX) + trap 'tmux -L $sock kill-server 2>/dev/null || true; rm -f "$log"' EXIT + tmux -L $sock kill-server 2>/dev/null || true + + # Bottom pane: the test-driver REPL. `script` keeps it on a tty while + # logging everything it prints, which the per-VM panes below filter. + # When the REPL exits, take the whole session down. + repl=$(tmux -L $sock new-session -d -x 220 -y 60 -s lab -P -F '#{pane_id}' \ + "script -qfc '${driver}/bin/nixos-test-driver --interactive' '$log'; tmux -L $sock kill-server") + tmux -L $sock set -g pane-border-status top + tmux -L $sock select-pane -t "$repl" -T repl + + # Top row: one pane per VM, console lines only (prefixed " #"). + prev="$repl" + first=1 + remaining=${toString (builtins.length nodes - 1)} + for node in ${lib.escapeShellArgs nodes}; do + cmd="tail -F '$log' 2>/dev/null | grep -a --line-buffered -E \"(^|\\[[0-9;]*m)$node #\"" + if [ "$first" = 1 ]; then + prev=$(tmux -L $sock split-window -vb -l 65% -t "$prev" -P -F '#{pane_id}' "$cmd") + first=0 + else + # Give the new pane its share of the pane being split, so the row ends up even. + prev=$(tmux -L $sock split-window -h -l "$((100 * remaining / (remaining + 1)))%" -t "$prev" -P -F '#{pane_id}' "$cmd") + remaining=$((remaining - 1)) + fi + tmux -L $sock select-pane -t "$prev" -T "$node" + done + + # Boot the VMs once the REPL is ready, and leave a reminder at the prompt. + for _ in $(seq 60); do + tmux -L $sock capture-pane -p -t "$repl" | grep -q CPython && break + sleep 0.5 + done + tmux -L $sock send-keys -t "$repl" 'start_all()' Enter + tmux -L $sock send-keys -t "$repl" -l 'print("router.shell_interact() | client.succeed(\"ip a\") | test_script() | Ctrl-D quits, Ctrl-b d detaches")' + tmux -L $sock send-keys -t "$repl" Enter + tmux -L $sock select-pane -t "$repl" + tmux -L $sock attach -t lab +''