summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--docs/canonical-laptop.md67
-rw-r--r--justfile16
-rw-r--r--nix/flake.nix1
-rw-r--r--scripts/canonical_bond.py302
-rw-r--r--tests/test_canonical_bond.py102
-rw-r--r--tests/test_recipes.py14
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
diff --git a/justfile b/justfile
index 9c4edaa..ea69469 100644
--- a/justfile
+++ b/justfile
@@ -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"]: