diff options
| -rw-r--r-- | docs/canonical-laptop.md | 67 | ||||
| -rw-r--r-- | justfile | 16 | ||||
| -rw-r--r-- | nix/flake.nix | 1 | ||||
| -rw-r--r-- | scripts/canonical_bond.py | 302 | ||||
| -rw-r--r-- | tests/test_canonical_bond.py | 102 | ||||
| -rw-r--r-- | tests/test_recipes.py | 14 |
6 files changed, 502 insertions, 0 deletions
diff --git a/docs/canonical-laptop.md b/docs/canonical-laptop.md index 79a782d..5bfb789 100644 --- a/docs/canonical-laptop.md +++ b/docs/canonical-laptop.md @@ -355,6 +355,73 @@ Inspect failures with `journalctl --user -b -g 'Corporate panel'` and enablement, unless they were changed afterwards. The extension files remain installed. The personal and VM roles do not receive them. +## Optional Ethernet and Wi-Fi Bond + +This setup keeps NetworkManager. It uses active-backup mode, a one-second +link check, the active port's MAC, and Ethernet port priority above Wi-Fi. +DHCP runs on `bond0` only. The DHCPv4 client ID uses a machine-derived DUID +and a fixed IAID. It does not use Halley2's identity or set a static address. +Router reservations are managed separately. + +There is no home-network check. Both ports must reach the same LAN for +failover to preserve an address. Changing networks can require a new lease +and interrupt existing connections. IPv6 addresses can change with the MAC. +DNS comes from the bond's automatic IP configuration. + +Preparation does not load profiles or change the active network. On Turing: + +```sh +just canonical-bond-prepare 'Wired connection 1' 'netplan-enp195s0f0' 'TellMyWifiLoveHer' +``` + +The named profiles must each refer to a different physical interface present +on the machine. Wi-Fi needs its PSK saved in the system profile; a password +held only in the desktop keyring is not copied. Preparation stops if it cannot +find that password. Credentials stay in root-only files, outside the repo. +Do not share the generated `.nmconnection` files. + +Run activation from a local terminal, not SSH, directly after preparation: + +```sh +just canonical-bond-activate +``` + +This interrupts networking. It installs the prepared profiles, disables +autoconnection of the originals, and starts the bond. A system timer restores +the original profiles after five minutes unless you cancel it. Do not reboot +during the test: the recovery timer does not survive a reboot. + +Check the active port and address: + +```sh +cat /proc/net/bonding/bond0 +ip -br address show bond0 +ip route +resolvectl status bond0 +``` + +With both links available, check that Ethernet is active. Test communication +with Halley2 in both directions, DNS lookup, and internet access. Remove +Ethernet and repeat through Wi-Fi. Reconnect Ethernet and check that it becomes +active again. Repeat the transition and check `ip neigh show dev bond0` for +persistent failures. Confirm that the IPv4 address stays the same. + +Only after these checks pass, before the five-minute timeout, run: + +```sh +just canonical-bond-keep +``` + +To restore the original profiles at any time, run `just canonical-bond-rollback`. +It removes only the generated profiles, restores the original autoconnect +values, and requests the connections active when preparation ran. Keep +`/var/lib/dotfiles/bond0` for recovery. Preparation refuses to overwrite it. + +Only the selected profiles join the bond. New adapters and Wi-Fi profiles are +not enrolled automatically. Do not activate a separate IP profile on a bond +member. This setup does not change company VPN profiles or access controls. +Unlike Halley2's broad interface rules, it does not adopt every future device. + ## External Displays The corporate-only `external-display@dotfiles` extension selects Mutter's @@ -100,6 +100,22 @@ _require-canonical: canonical-system: _require-canonical @bash scripts/canonical-system.sh +# Prepare bond profiles without loading them or changing the active network. +[positional-arguments] +canonical-bond-prepare +profiles: _require-canonical + @sudo /usr/bin/python3 scripts/canonical_bond.py "$@" + +# Run locally: changes networking and schedules rollback after five minutes. +canonical-bond-activate: _require-canonical + @sudo /bin/sh /var/lib/dotfiles/bond0/activate.sh + +# Cancel the recovery timer only after testing both network paths. +canonical-bond-keep: _require-canonical + @sudo systemctl stop dotfiles-bond-rollback.timer + +canonical-bond-rollback: _require-canonical + @sudo /bin/sh /var/lib/dotfiles/bond0/rollback.sh + canonical-extensions: _require-canonical @python3 scripts/canonical.py extensions diff --git a/nix/flake.nix b/nix/flake.nix index a0f392a..dd62a11 100644 --- a/nix/flake.nix +++ b/nix/flake.nix @@ -286,6 +286,7 @@ just nixfmt nodejs + networkmanager prettier ruff selene diff --git a/scripts/canonical_bond.py b/scripts/canonical_bond.py new file mode 100644 index 0000000..d7783c8 --- /dev/null +++ b/scripts/canonical_bond.py @@ -0,0 +1,302 @@ +"""Prepare inactive NetworkManager bond profiles and local recovery commands.""" + +import configparser +import io +import json +import os +import shlex +import shutil +import subprocess +import sys +import uuid +from pathlib import Path + +STATE = Path("/var/lib/dotfiles/bond0") +BOND_UUID = "88919b61-3a22-4cb6-9485-440725990cea" + + +def nmcli(*args, input=None): + return subprocess.check_output(["nmcli", *args], input=input, text=True).strip() + + +def keyfile(text): + parser = configparser.ConfigParser(interpolation=None, strict=True) + parser.optionxform = lambda optionstr: optionstr + parser.read_string(text) + return parser + + +def bond_profile(): + return nmcli( + "--offline", + "connection", + "add", + "type", + "bond", + "ifname", + "bond0", + "con-name", + "dotfiles-bond0", + "connection.uuid", + BOND_UUID, + "connection.autoconnect", + "no", + "connection.autoconnect-ports", + "1", + "bond.options", + "mode=active-backup,miimon=1000,fail_over_mac=active,primary_reselect=always", + "ipv4.method", + "auto", + "ipv4.dhcp-client-id", + "duid", + "ipv4.dhcp-iaid", + "1", + "ipv6.method", + "auto", + "ipv6.dhcp-duid", + "stable-uuid", + "ipv6.dhcp-iaid", + "1", + "connection.stable-id", + "dotfiles-bond0", + ) + + +def port_profile(source, kind, identity, priority): + if kind not in ("wifi", "ethernet", "802-11-wireless", "802-3-ethernet"): + raise ValueError(f"Not an Ethernet or Wi-Fi profile: {kind}") + config = keyfile(source) + if config.get("connection", "secondaries", fallback=""): + raise ValueError( + "A profile with automatic VPN connections needs separate review." + ) + for section in ("ipv4", "ipv6", "proxy"): + config.remove_section(section) + data = io.StringIO() + config.write(data, space_around_delimiters=False) + setting = ( + "802-11-wireless" if kind in ("wifi", "802-11-wireless") else "802-3-ethernet" + ) + return nmcli( + "--offline", + "connection", + "modify", + "connection.id", + f"dotfiles-bond-{identity}", + "connection.uuid", + identity, + "connection.autoconnect", + "no", + "connection.controller", + BOND_UUID, + "connection.port-type", + "bond", + "connection.secondaries", + "", + "bond-port.prio", + str(priority), + f"{setting}.cloned-mac-address", + "permanent", + input=data.getvalue(), + ) + + +def profile_source(identity): + for directory in ("/etc", "/run", "/usr/lib"): + for path in Path(directory, "NetworkManager/system-connections").glob("*"): + if path.is_file(): + text = path.read_text() + if keyfile(text).get("connection", "uuid", fallback="") == identity: + return text + raise ValueError(f"No saved NetworkManager keyfile for {identity}") + + +def prepare_port(name): + identity = nmcli("-g", "connection.uuid", "connection", "show", name) + source = profile_source(identity) + config = keyfile(source) + kind = config["connection"]["type"] + interface = config.get("connection", "interface-name", fallback="") or nmcli( + "-g", "GENERAL.DEVICES", "con", "show", "uuid", identity + ) + if "/" in interface or not Path("/sys/class/net", interface, "device").exists(): + raise ValueError(f"Profile needs one physical interface present: {name}") + config["connection"]["interface-name"] = interface + if config.get("connection", "master", fallback="") or config.get( + "connection", "controller", fallback="" + ): + raise ValueError(f"Profile already belongs to a controller: {name}") + wireless = kind in ("wifi", "802-11-wireless") + if wireless and not config.get("wifi-security", "psk", fallback=""): + raise ValueError( + "Wi-Fi needs a system-saved PSK for unattended failover; no profile was changed." + ) + clone = str(uuid.uuid4()) + data = io.StringIO() + config.write(data, space_around_delimiters=False) + return { + "original": identity, + "autoconnect": nmcli( + "-g", "connection.autoconnect", "con", "show", "uuid", identity + ), + "uuid": clone, + "interface": interface, + "profile": port_profile(data.getvalue(), kind, clone, 0 if wireless else 100), + } + + +def shell_command(*args): + return shlex.join(str(arg) for arg in args) + + +def recovery_commands(ports): + commands = ["#!/bin/sh", "set -u"] + commands.append("systemctl stop dotfiles-bond-rollback.timer || true") + for identity in [BOND_UUID, *(port["uuid"] for port in ports)]: + commands.append( + shell_command( + "nmcli", "con", "mod", "uuid", identity, "connection.autoconnect", "no" + ) + + " || true" + ) + for identity in [BOND_UUID, *(port["uuid"] for port in ports)]: + commands.append( + shell_command("nmcli", "con", "delete", "uuid", identity) + " || true" + ) + commands.append( + shell_command( + "rm", + "-f", + f"/etc/NetworkManager/system-connections/dotfiles-{identity}.nmconnection", + ) + ) + for port in ports: + commands.append( + shell_command( + "nmcli", + "con", + "mod", + "uuid", + port["original"], + "connection.autoconnect", + port["autoconnect"], + ) + ) + active = set(nmcli("-g", "UUID", "con", "show", "--active").splitlines()) + for port in ports: + if port["original"] in active: + commands.append( + shell_command( + "nmcli", "--wait", "0", "con", "up", "uuid", port["original"] + ) + ) + return "\n".join(commands) + "\n" + + +def activation_commands(ports, active=()): + commands = ["#!/bin/sh", "set -eu"] + commands.append( + shell_command( + "systemd-run", + "--collect", + "--unit=dotfiles-bond-rollback", + "--on-active=5m", + "/bin/sh", + STATE / "rollback.sh", + ) + ) + commands.append("trap 'sh /var/lib/dotfiles/bond0/rollback.sh' EXIT") + commands.append( + "install -m 600 /var/lib/dotfiles/bond0/*.nmconnection /etc/NetworkManager/system-connections/" + ) + for identity in [BOND_UUID, *(port["uuid"] for port in ports)]: + commands.append( + shell_command( + "nmcli", + "con", + "load", + f"/etc/NetworkManager/system-connections/dotfiles-{identity}.nmconnection", + ) + ) + for port in ports: + commands.append( + shell_command( + "nmcli", + "con", + "mod", + "uuid", + port["original"], + "connection.autoconnect", + "no", + ) + ) + if port["original"] in active: + commands.append( + shell_command("nmcli", "con", "down", "uuid", port["original"]) + ) + for identity in [*(port["uuid"] for port in ports), BOND_UUID]: + commands.append( + shell_command( + "nmcli", "con", "mod", "uuid", identity, "connection.autoconnect", "yes" + ) + ) + commands.append( + shell_command("nmcli", "--wait", "0", "con", "up", "uuid", BOND_UUID) + ) + commands.append("trap - EXIT") + commands.append( + "echo 'Rollback is due in five minutes. Test locally, then run just canonical-bond-keep.'" + ) + return "\n".join(commands) + "\n" + + +def prepare(names): + if os.geteuid() != 0: + raise ValueError("Run preparation through the sudo recipe.") + if STATE.exists(): + raise ValueError( + f"Preparation already exists at {STATE}; do not overwrite recovery data." + ) + if ( + BOND_UUID in nmcli("-g", "UUID", "con", "show").splitlines() + or Path("/sys/class/net/bond0").exists() + ): + raise ValueError("A bond already exists; stop before replacing it.") + ports = [prepare_port(name) for name in names] + active = set(nmcli("-g", "UUID", "con", "show", "--active").splitlines()) + if len({port["original"] for port in ports}) != len(ports): + raise ValueError("Each source profile must be unique.") + if len({port["interface"] for port in ports}) != len(ports): + raise ValueError("Select only one profile per physical interface.") + profiles = {BOND_UUID: bond_profile(), **{p["uuid"]: p["profile"] for p in ports}} + scripts = { + "activate.sh": activation_commands(ports, active), + "rollback.sh": recovery_commands(ports), + } + os.umask(0o077) + STATE.mkdir(parents=True, mode=0o700) + try: + for identity, text in profiles.items(): + (STATE / f"dotfiles-{identity}.nmconnection").write_text(text + "\n") + for name, text in scripts.items(): + (STATE / name).write_text(text) + (STATE / "profiles.json").write_text( + json.dumps( + [{k: v for k, v in p.items() if k != "profile"} for p in ports], + indent=2, + ) + + "\n" + ) + except Exception: + shutil.rmtree(STATE) + raise + print(f"Prepared inactive profiles in {STATE}. No live connection was changed.") + + +if __name__ == "__main__": + if not sys.argv[1:]: + sys.exit("Supply the Ethernet and Wi-Fi connection profile names.") + try: + prepare(sys.argv[1:]) + except (ValueError, subprocess.CalledProcessError) as error: + sys.exit(str(error)) diff --git a/tests/test_canonical_bond.py b/tests/test_canonical_bond.py new file mode 100644 index 0000000..d9f097c --- /dev/null +++ b/tests/test_canonical_bond.py @@ -0,0 +1,102 @@ +import shutil +import subprocess +import unittest +from unittest.mock import patch + +from scripts import canonical_bond as bond + + +class BondTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("nmcli"), "nmcli is in the Nix development shell") + def test_networkmanager_accepts_offline_profiles(self): + config = bond.keyfile(bond.bond_profile()) + self.assertEqual(config["bond"]["fail_over_mac"], "active") + for kind in ("ethernet", "wifi"): + args = ["--offline", "con", "add", "type", kind, "ifname", "test0"] + if kind == "wifi": + args.extend(["ssid", "test"]) + profile = bond.port_profile( + bond.nmcli(*args), + kind, + "74019c2b-26bb-42d9-a775-b715c70bbb58", + 100 if kind == "ethernet" else 0, + ) + config = bond.keyfile(profile) + self.assertNotIn("ipv4", config) + self.assertNotIn("ipv6", config) + self.assertEqual(config["connection"]["autoconnect"], "false") + + def test_activation_schedules_recovery_before_network_changes(self): + script = bond.activation_commands( + [{"original": "old", "uuid": "new"}], active={"old"} + ) + self.assertLess(script.index("systemd-run"), script.index("install -m")) + self.assertIn("--on-active=5m", script) + self.assertIn("connection.autoconnect no", script) + self.assertIn("con down uuid old", script) + self.assertLess(script.index("con down uuid old"), script.index("con up uuid")) + + def test_recovery_restores_original_autoconnect_and_active_profiles(self): + with patch.object(bond, "nmcli", return_value="old"): + script = bond.recovery_commands( + [{"original": "old", "uuid": "new", "autoconnect": "no"}] + ) + self.assertIn("con mod uuid old connection.autoconnect no", script) + self.assertIn("con up uuid old", script) + self.assertNotIn("con delete uuid old", script) + + def test_bond_policy_and_identity(self): + with patch.object(bond, "nmcli", return_value="profile") as command: + self.assertEqual(bond.bond_profile(), "profile") + args = command.call_args.args + self.assertIn( + "mode=active-backup,miimon=1000,fail_over_mac=active,primary_reselect=always", + args, + ) + self.assertEqual(args[args.index("ipv4.dhcp-client-id") + 1], "duid") + self.assertEqual(args[args.index("ipv4.dhcp-iaid") + 1], "1") + self.assertEqual(args[args.index("connection.autoconnect") + 1], "no") + + def test_ports_keep_authentication_but_remove_ip_settings(self): + source = "[connection]\nid=wifi\nuuid=original\ntype=wifi\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n[wifi-security]\nkey-mgmt=wpa-psk\npsk=example\n" + with patch.object(bond, "nmcli", return_value="converted") as command: + bond.port_profile(source, "wifi", "new", 0) + args = command.call_args.args + data = command.call_args.kwargs["input"] + self.assertIn("psk=example", data) + self.assertNotIn("[ipv4]", data) + self.assertNotIn("[ipv6]", data) + self.assertEqual(args[args.index("connection.controller") + 1], bond.BOND_UUID) + self.assertEqual( + args[args.index("802-11-wireless.cloned-mac-address") + 1], "permanent" + ) + + def test_ethernet_has_priority_over_wifi(self): + with patch.object(bond, "nmcli", return_value="converted") as command: + bond.port_profile("[connection]\ntype=ethernet\n", "ethernet", "new", 100) + args = command.call_args.args + self.assertEqual(args[args.index("bond-port.prio") + 1], "100") + + def test_rejects_other_profile_types(self): + with self.assertRaises(ValueError): + bond.port_profile("[connection]\ntype=bridge\n", "bridge", "new", 0) + + def test_does_not_drop_automatic_vpn_connections(self): + with self.assertRaises(ValueError): + bond.port_profile( + "[connection]\ntype=ethernet\nsecondaries=vpn;\n", + "ethernet", + "new", + 100, + ) + + def test_generated_shell_scripts_parse(self): + ports = [{"original": "old", "uuid": "new", "autoconnect": "yes"}] + with patch.object(bond, "nmcli", return_value="old"): + scripts = [bond.activation_commands(ports), bond.recovery_commands(ports)] + for script in scripts: + subprocess.run(["sh", "-n"], input=script, text=True, check=True) + if shutil.which("shellcheck"): + subprocess.run( + ["shellcheck", "-s", "sh", "-"], input=script, text=True, check=True + ) diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 772833d..df04899 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -42,6 +42,20 @@ class RecipeTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertNotIn("UNEXPECTED", result.stdout) + def test_bond_recipes_reject_non_corporate_roles(self): + for role in ("host", "vm"): + for recipe in ( + "canonical-bond-activate", + "canonical-bond-keep", + "canonical-bond-rollback", + ): + result = self.invoke(role, recipe) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("UNEXPECTED", result.stdout) + result = self.invoke(role, "canonical-bond-prepare", "Wired connection 1") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("UNEXPECTED", result.stdout) + def test_non_host_maintenance_only_uses_chezmoi(self): for role in ["vm", "canonical"]: for recipe in ["diff", "merge", "re-add"]: |
