From 90fbf4e69a9044a6281f4a1f5361b572c0188ba0 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Tue, 22 Sep 2026 13:57:08 +0100 Subject: Add optional NetworkManager bonding with timed recovery --- scripts/canonical_bond.py | 302 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 scripts/canonical_bond.py (limited to 'scripts') 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)) -- cgit v1.3.1