summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.chezmoiignore1
-rw-r--r--.gitignore3
-rw-r--r--docs/canonical-laptop.md52
-rw-r--r--justfile11
-rw-r--r--meta/canonical/apt.txt1
-rw-r--r--scripts/canonical_vpn.py201
-rw-r--r--tests/test_canonical_vpn.py136
7 files changed, 405 insertions, 0 deletions
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
+<https://platform-qa-jenkins.ps5.ubuntu.com/>. 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"
+ )