From 04caeee050e4f1afdc429d9209f12a027b150056 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Tue, 22 Sep 2026 14:49:06 +0100 Subject: Add secondary Canonical VPN setup and routing controls --- .chezmoiignore | 1 + .gitignore | 3 + docs/canonical-laptop.md | 52 ++++++++++++ justfile | 11 +++ meta/canonical/apt.txt | 1 + scripts/canonical_vpn.py | 201 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_canonical_vpn.py | 136 ++++++++++++++++++++++++++++++ 7 files changed, 405 insertions(+) create mode 100644 scripts/canonical_vpn.py create mode 100644 tests/test_canonical_vpn.py diff --git a/.chezmoiignore b/.chezmoiignore index cc00228..c054bca 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -19,6 +19,7 @@ selene.toml selene-globals.yml scripts/ tmp/ +.sesame/ tests/ docs/ canonical/ diff --git a/.gitignore b/.gitignore index 6d4a914..1b63a08 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ !home/.lldbinit .worktrees/ tmp/ +.sesame/ +dot_sesame/ +private_dot_sesame/ .ruff_cache/ node_modules/ *.swp diff --git a/docs/canonical-laptop.md b/docs/canonical-laptop.md index 882318f..610901a 100644 --- a/docs/canonical-laptop.md +++ b/docs/canonical-laptop.md @@ -425,6 +425,58 @@ 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. +## Secondary Work VPN + +Pulpo keeps the primary VPN identity. The laptop uses only the `@2` identity. +Connection is manual: leave it off at home and enable it when needed elsewhere. +The setup does not change bond profiles or add automatic VPN connections. + +Transfer the Enigma ZIP archive to `tmp/canonical-vpn-credentials.zip` on the +laptop through SSH or download it there from Enigma. `tmp/` is ignored by Git. +Do not add credentials to the repo, including with `git add -f`. + +On the laptop, run: + +```sh +just pkg-apply base +just canonical-vpn-install +``` + +The NetworkManager OpenVPN plug-in is an apt package because it must integrate +with the system NetworkManager service. Setup selects the UK secondary profile +and copies only its required files to `~/.sesame/canonical-secondary/`, with +directory mode `0700` and file mode `0600`. Existing profiles and different +credential files are not replaced. Setup leaves the VPN disconnected, with +split routing selected. If import succeeds but configuration fails, do not +connect it from GNOME until the routing settings have been checked. + +```sh +just canonical-vpn-up # Split routing: use the supplied VPN routes. +just canonical-vpn-down +just canonical-vpn-up full # Allow default routes and prefer VPN DNS. +just canonical-vpn-down +just canonical-vpn-up split # Return to split routing. +``` + +Disconnect before changing modes. GNOME's VPN switch uses the last selected +mode for `canonical-secondary`. Split routing keeps normal internet traffic +on the local connection and accepts VPN routes and DNS. Full routing permits +IPv4 and IPv6 default routes and selects VPN DNS for all domains. It does not +provide a kill switch: traffic can use the local connection if the VPN fails. +Do not assume IPv6 is protected unless the VPN supplies a working IPv6 path. + +After connecting, check `nmcli connection show --active`, `ip route`, +`ip -6 route`, and `resolvectl status`. Test the internal service at +. For full routing, also check +`ip route get 1.1.1.1` and `ip -6 route get 2606:4700:4700::1111`. +Check that general internet access and DNS work in each mode. After disconnecting, +check that the normal routes and DNS return. A live connection must be tested +on the laptop; the repo tests do not contact the VPN. + +To remove the profile, disconnect it, then run +`nmcli connection delete id canonical-secondary`. Keep the private files until +you no longer need the profile. Neither installation nor removal affects Pulpo. + ## External Displays The corporate-only `external-display@dotfiles` extension selects Mutter's diff --git a/justfile b/justfile index ea69469..daeb4f0 100644 --- a/justfile +++ b/justfile @@ -100,6 +100,17 @@ _require-canonical: canonical-system: _require-canonical @bash scripts/canonical-system.sh +# Import only the secondary credentials; do not connect the VPN. +canonical-vpn-install archive="tmp/canonical-vpn-credentials.zip" endpoint="uk": _require-canonical + @python3 -m scripts.canonical_vpn install {{ quote(archive) }} --endpoint {{ quote(endpoint) }} + +# Connect the secondary VPN with full or split routing. +canonical-vpn-up mode="split": _require-canonical + @python3 -m scripts.canonical_vpn up {{ quote(mode) }} + +canonical-vpn-down: _require-canonical + @python3 -m scripts.canonical_vpn down + # Prepare bond profiles without loading them or changing the active network. [positional-arguments] canonical-bond-prepare +profiles: _require-canonical diff --git a/meta/canonical/apt.txt b/meta/canonical/apt.txt index c2c033a..909470f 100644 --- a/meta/canonical/apt.txt +++ b/meta/canonical/apt.txt @@ -9,3 +9,4 @@ pinentry-gnome3 python3-gi imv gnome-shell-ubuntu-extensions +network-manager-openvpn-gnome diff --git a/scripts/canonical_vpn.py b/scripts/canonical_vpn.py new file mode 100644 index 0000000..6858e1e --- /dev/null +++ b/scripts/canonical_vpn.py @@ -0,0 +1,201 @@ +"""Install and toggle a secondary Canonical VPN without tracking credentials.""" + +import argparse +import os +import shlex +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from scripts.canonical import require_canonical + +NAME = "canonical-secondary" +REFERENCES = {"ca", "cert", "key", "tls-auth"} + + +def output(*args): + return subprocess.check_output(["nmcli", *args], text=True).strip() + + +def run(command): + subprocess.run(command, check=True) + + +def secondary_files(archive, endpoint, destination): + with zipfile.ZipFile(archive) as source: + names = source.namelist() + candidates = [ + name + for name in names + if name.startswith(f"{endpoint}-") + and name.endswith("@2.conf") + and "/" not in name + ] + if len(candidates) != 1 or len(names) != len(set(names)): + raise ValueError( + "Archive must contain one secondary profile for the selected endpoint." + ) + lines = source.read(candidates[0]).decode().splitlines() + files, rendered, found = {}, [], set() + for line in lines: + words = shlex.split(line, comments=True) + if words and words[0] in REFERENCES: + directive, filename = words[:2] + if ( + directive in found + or Path(filename).name != filename + or filename in (".", "..") + ): + raise ValueError( + "Credential references must be unique plain filenames." + ) + if directive in ("cert", "key") and not filename.endswith( + f"@2.{'crt' if directive == 'cert' else 'key'}" + ): + raise ValueError( + "Refusing credentials that are not for the secondary identity." + ) + files[filename] = source.read(filename) + path = ( + str(destination / filename) + .replace("\\", "\\\\") + .replace('"', '\\"') + ) + line = f'{directive} "{path}"' + ( + " " + " ".join(words[2:]) if words[2:] else "" + ) + found.add(directive) + rendered.append(line) + if found != REFERENCES: + raise ValueError( + "Profile must reference CA, secondary certificate/key, and TLS auth key." + ) + files[f"{NAME}.conf"] = ("\n".join(rendered) + "\n").encode() + return files + + +def write_credentials(destination, files): + if destination.is_symlink() or destination.parent.is_symlink(): + raise ValueError("Credential directories must not be symlinks.") + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if destination.exists(): + if any( + (destination / name).is_symlink() + or not (destination / name).is_file() + or (destination / name).read_bytes() != data + for name, data in files.items() + ): + raise ValueError("Existing credentials differ; no files were replaced.") + destination.chmod(0o700) + for name in files: + (destination / name).chmod(0o600) + return + with tempfile.TemporaryDirectory( + prefix=".vpn-", dir=destination.parent + ) as temporary: + staging = Path(temporary) / "credentials" + staging.mkdir(mode=0o700) + for name, data in files.items(): + path = staging / name + path.write_bytes(data) + path.chmod(0o600) + staging.rename(destination) + + +def routing(mode): + if mode not in ("full", "split"): + raise ValueError("Routing mode must be full or split.") + settings = [] + for family in ("ipv4", "ipv6"): + settings.extend( + [ + f"{family}.never-default", + "no" if mode == "full" else "yes", + f"{family}.dns-search", + "~." if mode == "full" else "", + f"{family}.dns-priority", + "-50" if mode == "full" else "50", + ] + ) + return settings + + +def install(archive, endpoint): + if NAME in output("-g", "NAME", "connection", "show").splitlines(): + raise ValueError(f"Profile {NAME} already exists; it was not replaced.") + destination = Path.home() / ".sesame" / "canonical-secondary" + files = secondary_files(archive, endpoint, destination) + write_credentials(destination, files) + run( + [ + "sudo", + "nmcli", + "connection", + "import", + "type", + "openvpn", + "file", + str(destination / f"{NAME}.conf"), + ] + ) + run( + [ + "sudo", + "nmcli", + "connection", + "modify", + "id", + NAME, + "connection.autoconnect", + "no", + "connection.permissions", + f"user:{os.environ['USER']}", + *routing("split"), + ] + ) + print(f"Installed {NAME}, disconnected, with split routing selected.") + + +def up(mode): + if NAME in output("-g", "NAME", "connection", "show", "--active").splitlines(): + raise ValueError( + "Disconnect the secondary VPN before changing its routing mode." + ) + run(["sudo", "nmcli", "connection", "modify", "id", NAME, *routing(mode)]) + run(["nmcli", "--ask", "connection", "up", "id", NAME]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="action", required=True) + setup = commands.add_parser("install") + setup.add_argument("archive", type=Path) + setup.add_argument("--endpoint", choices=("uk", "us", "tw"), default="uk") + connect = commands.add_parser("up") + connect.add_argument("mode", choices=("full", "split"), default="split", nargs="?") + commands.add_parser("down") + args = parser.parse_args() + require_canonical() + if args.action == "install": + install(args.archive, args.endpoint) + elif args.action == "up": + up(args.mode) + else: + run(["nmcli", "connection", "down", "id", NAME]) + + +if __name__ == "__main__": + try: + main() + except ValueError as error: + raise SystemExit(str(error)) from None + except ( + KeyError, + OSError, + zipfile.BadZipFile, + subprocess.CalledProcessError, + ): + raise SystemExit( + "VPN setup failed. Check the archive, installed OpenVPN plug-in, and profile state." + ) from None diff --git a/tests/test_canonical_vpn.py b/tests/test_canonical_vpn.py new file mode 100644 index 0000000..a81bbb1 --- /dev/null +++ b/tests/test_canonical_vpn.py @@ -0,0 +1,136 @@ +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +from scripts import canonical_vpn as vpn + + +class VpnTests(unittest.TestCase): + def archive(self, root, key="person@2.key"): + archive = root / "credentials.zip" + with zipfile.ZipFile(archive, "w") as output: + output.writestr( + "uk-person@2.conf", + f"client\nremote uk.sesame.canonical.com 673\nca ca.crt\ncert person@2.crt\nkey {key}\ntls-auth ta.key 1\nverify-x509-name 'access.is' name\n", + ) + for name in ("ca.crt", "ta.key", "person@2.crt", key, "primary.key"): + output.writestr(name, f"test data for {name}") + return archive + + def test_extracts_only_secondary_dependencies_and_rewrites_paths(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + destination = root / "private credentials" + files = vpn.secondary_files(self.archive(root), "uk", destination) + self.assertEqual( + set(files), + { + "canonical-secondary.conf", + "ca.crt", + "ta.key", + "person@2.crt", + "person@2.key", + }, + ) + config = files["canonical-secondary.conf"].decode() + self.assertIn(str(destination / "person@2.key"), config) + self.assertIn("tls-auth", config) + self.assertIn(" 1\n", config) + self.assertIn("verify-x509-name 'access.is' name", config) + + def test_rejects_primary_identity_reference(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaisesRegex(ValueError, "secondary"): + vpn.secondary_files(self.archive(root, "person.key"), "uk", root) + + def test_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaises(ValueError): + vpn.secondary_files(self.archive(root, "../person@2.key"), "uk", root) + + def test_private_files_are_not_overwritten(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "secondary" + vpn.write_credentials(target, {"file": b"first"}) + self.assertEqual(target.stat().st_mode & 0o777, 0o700) + self.assertEqual((target / "file").stat().st_mode & 0o777, 0o600) + vpn.write_credentials(target, {"file": b"first"}) + with self.assertRaises(ValueError): + vpn.write_credentials(target, {"file": b"changed"}) + self.assertEqual((target / "file").read_bytes(), b"first") + + def test_modes_control_both_ip_families_and_dns(self): + full = vpn.routing("full") + split = vpn.routing("split") + for family in ("ipv4", "ipv6"): + self.assertEqual(full[full.index(f"{family}.never-default") + 1], "no") + self.assertEqual(split[split.index(f"{family}.never-default") + 1], "yes") + self.assertEqual(full[full.index(f"{family}.dns-search") + 1], "~.") + with self.assertRaises(ValueError): + vpn.routing("invalid") + + def test_up_refuses_to_change_an_active_profile(self): + with ( + patch.object(vpn, "output", return_value=vpn.NAME), + patch.object(vpn, "run") as run, + self.assertRaisesRegex(ValueError, "Disconnect"), + ): + vpn.up("split") + run.assert_not_called() + + def test_up_applies_mode_before_connection(self): + with ( + patch.object(vpn, "output", return_value=""), + patch.object(vpn, "run") as run, + ): + vpn.up("full") + self.assertEqual(run.call_args_list[0].args[0][-12:], vpn.routing("full")) + self.assertEqual( + run.call_args_list[1].args[0], + ["nmcli", "--ask", "connection", "up", "id", vpn.NAME], + ) + + def test_existing_profile_is_not_replaced(self): + with ( + patch.object(vpn, "output", return_value=vpn.NAME), + patch.object(vpn, "write_credentials") as write, + self.assertRaisesRegex(ValueError, "already exists"), + ): + vpn.install(Path("archive.zip"), "uk") + write.assert_not_called() + + def test_symlink_directory_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "secondary" + target.symlink_to(root, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlinks"): + vpn.write_credentials(target, {"key": b"secret"}) + self.assertFalse((root / "key").exists()) + + def test_install_does_not_connect_or_attach_to_bond(self): + with ( + patch.object(vpn, "output", return_value=""), + patch.object(vpn, "secondary_files", return_value={}), + patch.object(vpn, "write_credentials"), + patch.object(vpn, "run") as run, + ): + vpn.install(Path("archive.zip"), "uk") + commands = [call.args[0] for call in run.call_args_list] + self.assertTrue(any("import" in command for command in commands)) + self.assertFalse( + any( + "up" in command or "connection.secondaries" in command + for command in commands + ) + ) + modified = commands[-1] + self.assertEqual(modified[modified.index("connection.autoconnect") + 1], "no") + for family in ("ipv4", "ipv6"): + self.assertEqual( + modified[modified.index(f"{family}.never-default") + 1], "yes" + ) -- cgit v1.3.1