diff --git a/deployments/ansible/roles/lychd_app/files/tether_tools.py b/deployments/ansible/roles/lychd_app/files/tether_tools.py new file mode 100644 index 00000000..2b302e4f --- /dev/null +++ b/deployments/ansible/roles/lychd_app/files/tether_tools.py @@ -0,0 +1,77 @@ +"""Read-only proof of the distribution WireGuard tool used by native key custody.""" + +import json +import os +import stat +import subprocess +import sys +from pathlib import Path + + +class PrerequisiteError(Exception): + """The host package or executable requires administrator preparation.""" + + +def require(condition, message): + if not condition: + raise PrerequisiteError(message) + + +def trusted_executable(name): + original = Path(name) + path = original.resolve(strict=True) + for item in (path, *path.parents, *original.parents): + metadata = item.stat() + require(metadata.st_uid == 0 and not metadata.st_mode & 0o022, "Untrusted WireGuard prerequisite executable") + require(stat.S_ISREG(path.stat().st_mode) and os.access(path, os.X_OK), "WireGuard prerequisite is not executable") + return str(path) + + +def run(executable, *arguments): + result = subprocess.run( + [executable, *arguments], + capture_output=True, + text=True, + timeout=20, + check=False, + env={"PATH": "/usr/sbin:/usr/bin", "LC_ALL": "C"}, + ) + require( + len(result.stdout.encode()) <= 65536 and len(result.stderr.encode()) <= 65536, + "Tool observation exceeded its bound", + ) + require( + result.returncode == 0, + "Install wireguard-tools through administrator preparation; verification never installs it", + ) + return result.stdout.strip() + + +def observe(command=run, resolve=trusted_executable): + rpm = resolve("/usr/bin/rpm") + require( + command(rpm, "-q", "--qf", "%{NAME}", "wireguard-tools") == "wireguard-tools", "WireGuard tools RPM is absent" + ) + wg = resolve("/usr/bin/wg") + require( + command(rpm, "-qf", "--qf", "%{NAME}", wg) == "wireguard-tools", + "WireGuard executable is not supplied by its distribution package", + ) + require(bool(command(wg, "--version")), "WireGuard executable did not report its version") + return {"package_installed": True, "executable_verified": True} + + +def main(): + try: + print(json.dumps(observe(), sort_keys=True)) + except (OSError, ValueError, subprocess.SubprocessError, PrerequisiteError): + print( + "WireGuard tools prerequisite refused; install or repair the distribution wireguard-tools package before native preparation.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deployments/ansible/roles/lychd_app/tasks/apply.yml b/deployments/ansible/roles/lychd_app/tasks/apply.yml index 4060999b..cedf4153 100644 --- a/deployments/ansible/roles/lychd_app/tasks/apply.yml +++ b/deployments/ansible/roles/lychd_app/tasks/apply.yml @@ -73,7 +73,7 @@ environment: "{{ lychd_app_env }}" when: lychd_app_profile == 'tether-container-v1' register: lychd_tether_preparation - changed_when: (lychd_tether_preparation.stdout | from_json).changed + changed_when: lychd_tether_preparation.rc == 0 and (lychd_tether_preparation.stdout | from_json).changed - name: Re-classify preparation and key admission ansible.builtin.include_tasks: classify.yml diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_kernel_prepare.yml b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_prepare.yml index 3323df69..f371653d 100644 --- a/deployments/ansible/roles/lychd_app/tasks/tether_kernel_prepare.yml +++ b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_prepare.yml @@ -2,6 +2,9 @@ - name: Check the host kernel and reserved boot entry before preparation ansible.builtin.import_tasks: tether_kernel.yml +- name: Prepare the host WireGuard key-generation tool + ansible.builtin.import_tasks: tether_tools_prepare.yml + - name: Load only an absent WireGuard module with administrator policy intact ansible.builtin.command: argv: [/usr/sbin/modprobe, --use-blacklist, wireguard] diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_kernel_verify.yml b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_verify.yml index 9c55a0db..9f1d62e0 100644 --- a/deployments/ansible/roles/lychd_app/tasks/tether_kernel_verify.yml +++ b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_verify.yml @@ -17,3 +17,6 @@ ansible.builtin.assert: that: lychd_tether_kernel_loaded.stat.isdir | default(false) fail_msg: "WireGuard is not loaded in the running host kernel; read-only verification does not load it." + +- name: Verify the host WireGuard key-generation tool without mutation + ansible.builtin.import_tasks: tether_tools_verify.yml diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_tools_prepare.yml b/deployments/ansible/roles/lychd_app/tasks/tether_tools_prepare.yml new file mode 100644 index 00000000..a5c67dab --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/tether_tools_prepare.yml @@ -0,0 +1,24 @@ +--- +- name: Query the installed WireGuard tools package + ansible.builtin.command: + argv: [/usr/bin/rpm, -q, wireguard-tools] + register: lychd_tether_tools_package + failed_when: lychd_tether_tools_package.rc not in [0, 1] + changed_when: false + check_mode: false + +- name: Predict only the missing WireGuard tools package in check mode + ansible.builtin.debug: + msg: "wireguard-tools would be installed from existing repositories; executable readiness is not yet proved." + when: ansible_check_mode and lychd_tether_tools_package.rc == 1 + changed_when: true + +- name: Install only the absent WireGuard tools package from existing repositories + ansible.builtin.command: + argv: [/usr/bin/zypper, --non-interactive, --no-refresh, install, --no-recommends, --, wireguard-tools] + when: not ansible_check_mode and lychd_tether_tools_package.rc == 1 + changed_when: true + +- name: Prove actual WireGuard tools after preparation when the package exists + ansible.builtin.import_tasks: tether_tools_verify.yml + when: not ansible_check_mode or lychd_tether_tools_package.rc == 0 diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_tools_verify.yml b/deployments/ansible/roles/lychd_app/tasks/tether_tools_verify.yml new file mode 100644 index 00000000..8ea94d17 --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/tether_tools_verify.yml @@ -0,0 +1,10 @@ +--- +- name: Verify the installed WireGuard package and trusted executable + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -I + - -c + - "{{ lookup('ansible.builtin.file', role_path + '/files/tether_tools.py') }}" + changed_when: false + check_mode: false diff --git a/deployments/ansible/tests/test_host_readonly.py b/deployments/ansible/tests/test_host_readonly.py index 26f267d4..32a7b458 100644 --- a/deployments/ansible/tests/test_host_readonly.py +++ b/deployments/ansible/tests/test_host_readonly.py @@ -78,7 +78,7 @@ def test_extra_actions_cannot_add_mutators_to_actual_verification_entrypoints(se actions = {task.action.removeprefix("ansible.builtin.") for task in tasks} self.assertLessEqual(actions, {"assert", "command", "stat", "slurp", "debug", "meta"}) commands = [task for task in tasks if task.action.removeprefix("ansible.builtin.") == "command"] - self.assertEqual(len(commands), 4) + self.assertEqual(len(commands), 5) self.assertEqual(commands[0].args["argv"], ["/usr/bin/id", "-u"]) self.assertEqual(commands[1].args["argv"], ["/usr/bin/uname", "-r"]) self.assertEqual( @@ -92,13 +92,20 @@ def test_extra_actions_cannot_add_mutators_to_actual_verification_entrypoints(se "wireguard", ], ) - for task in commands[:3]: + for task in commands[:-1]: self.assertFalse(task.check_mode) self.assertIs(task.get_ds()["changed_when"], False) for task in commands: context = variables.get_vars(play=play, task=task) self.assertEqual(context["lychd_tether_kernel_action"], obsolete_action) self.assertEqual(context["lychd_tether_firewall_action"], obsolete_action) + tools = commands[-2] + self.assertEqual(tools.args["argv"][:3], ["/usr/bin/python3", "-I", "-c"]) + self.assertEqual( + tools.args["argv"][3], + "{{ lookup('ansible.builtin.file', role_path + '/files/tether_tools.py') }}", + ) + self.assertEqual(tools.when, []) firewall = commands[-1] context = variables.get_vars(play=play, task=firewall) context["ansible_check_mode"] = check @@ -124,8 +131,26 @@ def test_extra_actions_cannot_add_mutators_to_actual_verification_entrypoints(se def test_preparation_retains_effects_and_check_prediction_despite_obsolete_verify_variable(self): play, variables, tasks = compiled(["tether_kernel_prepare", "firewall"], "verify") commands = [task for task in tasks if task.action.removeprefix("ansible.builtin.") == "command"] - self.assertEqual(len(commands), 5) - load = commands[3] + self.assertEqual(len(commands), 8) + package = commands[3] + self.assertEqual(package.args["argv"], ["/usr/bin/rpm", "-q", "wireguard-tools"]) + install = commands[4] + self.assertEqual( + install.args["argv"], + [ + "/usr/bin/zypper", + "--non-interactive", + "--no-refresh", + "install", + "--no-recommends", + "--", + "wireguard-tools", + ], + ) + self.assertEqual(install.when, ["not ansible_check_mode and lychd_tether_tools_package.rc == 1"]) + tools = commands[5] + self.assertEqual(tools.when, ["not ansible_check_mode or lychd_tether_tools_package.rc == 0"]) + load = commands[6] self.assertEqual(load.args["argv"], ["/usr/sbin/modprobe", "--use-blacklist", "wireguard"]) self.assertEqual(load.args["creates"], "/sys/module/wireguard") self.assertNotIn("check_mode", load.get_ds()) diff --git a/deployments/ansible/tests/test_tether_kernel.py b/deployments/ansible/tests/test_tether_kernel.py index 34c7a24a..a5054822 100644 --- a/deployments/ansible/tests/test_tether_kernel.py +++ b/deployments/ansible/tests/test_tether_kernel.py @@ -73,6 +73,7 @@ def test_effect_inventory_contains_only_one_fixed_load_and_boot_file(self): "ansible.builtin.slurp", "ansible.builtin.copy", "ansible.builtin.debug", + "ansible.builtin.import_tasks", }, ) diff --git a/deployments/ansible/tests/test_tether_prepare_reporting.py b/deployments/ansible/tests/test_tether_prepare_reporting.py new file mode 100644 index 00000000..56efac3e --- /dev/null +++ b/deployments/ansible/tests/test_tether_prepare_reporting.py @@ -0,0 +1,59 @@ +"""Native preparation failures retain command diagnostics without parsing failure output.""" + +import json +import unittest +from pathlib import Path + +import yaml +from jinja2 import Environment, StrictUndefined, UndefinedError + +ROOT = Path(__file__).parents[1] + + +def prepare_task(): + tasks = yaml.safe_load((ROOT / "roles/lychd_app/tasks/apply.yml").read_text()) + block = next(row for row in tasks if row["name"].startswith("Prepare or resume preparation")) + return next(row for row in block["block"] if row.get("register") == "lychd_tether_preparation") + + +class PreparationReportingTests(unittest.TestCase): + def evaluate(self, rc, stdout): + self.parses = 0 + environment = Environment(undefined=StrictUndefined) + + def from_json(value): + self.parses += 1 + return json.loads(value) + + environment.filters["from_json"] = from_json + return environment.compile_expression(prepare_task()["changed_when"], undefined_to_none=False)( + lychd_tether_preparation={"rc": rc, "stdout": stdout} + ) + + def test_failed_command_never_parses_empty_non_json_or_apparent_success_output(self): + for rc in (1, 2): + for stdout in ("", "native command refused", '{"changed":true}'): + with self.subTest(rc=rc, stdout=stdout): + self.assertIs(self.evaluate(rc, stdout), False) + self.assertEqual(self.parses, 0) + task = prepare_task() + self.assertNotIn("failed_when", task) + self.assertNotIn("ignore_errors", task) + self.assertNotIn("ignore_unreachable", task) + + def test_success_reports_the_native_changed_boolean(self): + for changed in (False, True): + with self.subTest(changed=changed): + self.assertIs(self.evaluate(0, json.dumps({"state": "prepared", "changed": changed})), changed) + self.assertEqual(self.parses, 1) + + def test_successful_malformed_or_missing_receipt_does_not_silently_become_unchanged(self): + for stdout in ("", "not-json"): + with self.subTest(stdout=stdout), self.assertRaises(json.JSONDecodeError): + self.evaluate(0, stdout) + with self.assertRaises(UndefinedError): + bool(self.evaluate(0, '{"state":"prepared"}')) + + +if __name__ == "__main__": + unittest.main() diff --git a/deployments/ansible/tests/test_tether_tools.py b/deployments/ansible/tests/test_tether_tools.py new file mode 100644 index 00000000..87353f07 --- /dev/null +++ b/deployments/ansible/tests/test_tether_tools.py @@ -0,0 +1,139 @@ +"""Prove package preparation effects and independent read-only tool evidence.""" + +import importlib.util +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import yaml +from jinja2 import Environment, StrictUndefined + +ROOT = Path(__file__).parents[1] +TASKS = ROOT / "roles/lychd_app/tasks" +SPEC = importlib.util.spec_from_file_location("tether_tools", ROOT / "roles/lychd_app/files/tether_tools.py") +TOOLS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(TOOLS) +ENV = Environment(undefined=StrictUndefined) + + +class ToolPrerequisiteTests(unittest.TestCase): + def test_only_prepare_can_install_and_check_mode_only_predicts(self): + rows = yaml.safe_load((TASKS / "tether_tools_prepare.yml").read_text()) + query, prediction, install, verification = rows + self.assertEqual(query["ansible.builtin.command"]["argv"], ["/usr/bin/rpm", "-q", "wireguard-tools"]) + self.assertFalse(query["check_mode"]) + self.assertFalse(query["changed_when"]) + self.assertEqual( + install["ansible.builtin.command"]["argv"], + [ + "/usr/bin/zypper", + "--non-interactive", + "--no-refresh", + "install", + "--no-recommends", + "--", + "wireguard-tools", + ], + ) + self.assertTrue(install["changed_when"]) + self.assertNotIn("check_mode", install) + self.assertEqual(verification["ansible.builtin.import_tasks"], "tether_tools_verify.yml") + for check in (False, True): + for code in (0, 1, 2): + context = {"ansible_check_mode": check, "lychd_tether_tools_package": {"rc": code}} + + def evaluate(expression): + return ENV.compile_expression(expression)(**context) + + self.assertEqual(evaluate(query["failed_when"]), code not in (0, 1)) + self.assertEqual(evaluate(prediction["when"]), check and code == 1) + self.assertEqual(evaluate(install["when"]), not check and code == 1) + self.assertEqual(evaluate(verification["when"]), not check or code == 0) + verify = yaml.safe_load((TASKS / "tether_tools_verify.yml").read_text()) + self.assertEqual(len(verify), 1) + self.assertFalse(verify[0]["changed_when"]) + self.assertFalse(verify[0]["check_mode"]) + self.assertNotIn("when", verify[0]) + + def test_kernel_preflight_precedes_package_effect_and_verify_import_is_literal(self): + prepare = yaml.safe_load((TASKS / "tether_kernel_prepare.yml").read_text()) + self.assertEqual(prepare[0]["ansible.builtin.import_tasks"], "tether_kernel.yml") + self.assertEqual(prepare[1]["ansible.builtin.import_tasks"], "tether_tools_prepare.yml") + self.assertIn("modprobe", prepare[2]["ansible.builtin.command"]["argv"][0]) + verify = yaml.safe_load((TASKS / "tether_kernel_verify.yml").read_text()) + self.assertEqual(verify[-1]["ansible.builtin.import_tasks"], "tether_tools_verify.yml") + self.assertNotIn("when", verify[-1]) + + def test_actual_package_file_owner_and_executable_are_observed(self): + calls = [] + resolved = [] + + def resolve(path): + resolved.append(path) + return path + + def command(path, *arguments): + calls.append((path, *arguments)) + return "wireguard-tools v1.0" if path.endswith("/wg") else "wireguard-tools" + + self.assertEqual(TOOLS.observe(command, resolve), {"package_installed": True, "executable_verified": True}) + self.assertEqual(resolved, ["/usr/bin/rpm", "/usr/bin/wg"]) + self.assertEqual( + calls, + [ + ("/usr/bin/rpm", "-q", "--qf", "%{NAME}", "wireguard-tools"), + ("/usr/bin/rpm", "-qf", "--qf", "%{NAME}", "/usr/bin/wg"), + ("/usr/bin/wg", "--version"), + ], + ) + + def test_missing_package_foreign_file_owner_or_empty_tool_response_refuses(self): + for position in range(3): + responses = ["wireguard-tools", "wireguard-tools", "wireguard-tools v1.0"] + responses[position] = "" + with self.subTest(position=position): + with self.assertRaises(TOOLS.PrerequisiteError): + TOOLS.observe(lambda *args: responses.pop(0), lambda path: path) + with self.assertRaises(FileNotFoundError): + TOOLS.observe(resolve=lambda path: (_ for _ in ()).throw(FileNotFoundError())) + + def test_command_is_readonly_bounded_and_never_uses_ambient_path(self): + result = subprocess.CompletedProcess([], 0, "wireguard-tools\n", "") + with patch.object(TOOLS.subprocess, "run", return_value=result) as called: + self.assertEqual(TOOLS.run("/usr/bin/rpm", "-q", "wireguard-tools"), "wireguard-tools") + self.assertEqual(called.call_args.args[0], ["/usr/bin/rpm", "-q", "wireguard-tools"]) + self.assertEqual(called.call_args.kwargs["env"], {"PATH": "/usr/sbin:/usr/bin", "LC_ALL": "C"}) + self.assertEqual(called.call_args.kwargs["timeout"], 20) + for result in (subprocess.CompletedProcess([], 1, "", ""), subprocess.CompletedProcess([], 0, "x" * 65537, "")): + with patch.object(TOOLS.subprocess, "run", return_value=result): + with self.assertRaises(TOOLS.PrerequisiteError): + TOOLS.run("/usr/bin/rpm", "-q", "wireguard-tools") + + def test_trusted_executable_refuses_foreign_writable_or_unexecutable_paths(self): + with tempfile.TemporaryDirectory() as temporary: + executable = Path(temporary) / "wg" + executable.write_text("fixture") + for uid, mode, executable_bit, accepted in ( + (0, stat.S_IFREG | 0o755, True, True), + (1000, stat.S_IFREG | 0o755, True, False), + (0, stat.S_IFREG | 0o775, True, False), + (0, stat.S_IFREG | 0o755, False, False), + (0, stat.S_IFDIR | 0o755, True, False), + ): + with ( + patch.object(Path, "stat", return_value=SimpleNamespace(st_uid=uid, st_mode=mode)), + patch.object(TOOLS.os, "access", return_value=executable_bit), + ): + if accepted: + self.assertEqual(TOOLS.trusted_executable(str(executable)), str(executable)) + else: + with self.assertRaises(TOOLS.PrerequisiteError): + TOOLS.trusted_executable(str(executable)) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/deployments/tumbleweed/index.md b/docs/deployments/tumbleweed/index.md index 5c8c849c..f5d19da1 100644 --- a/docs/deployments/tumbleweed/index.md +++ b/docs/deployments/tumbleweed/index.md @@ -646,25 +646,36 @@ Automation verifies and imports that image and stages the public intent before r and active `tether/default.toml` are journaled by that original initialization. Existing original bytes must agree on repeat runs; there is no post-init settings edit or receipt replacement. -Every native Tether profile, including import-only installations, requires WireGuard in the -running host kernel. Between runtime staging and native initialization, the administrator phase -checks that kernel's module inventory, loads `wireguard` only when absent, and observes it in +Every native Tether profile, including import-only installations, requires the distribution's +`wireguard-tools` userspace package and WireGuard support in the running host kernel. The host +uses `wg` for key generation and identity checks; a loaded kernel module alone is insufficient. +Between runtime staging and native initialization, the administrator preparation phase installs +`wireguard-tools` when absent and verifies its executable before native key operations. It also +checks the running kernel's module inventory, loads `wireguard` only when absent, and observes it in `/sys/module/wireguard`. Only after that observation does it create the root-owned `/etc/modules-load.d/lychd-tether.conf` containing `wireguard` for subsequent boots. Existing reserved-file bytes and ownership must match; unsafe or conflicting entries are refused before loading. Repeated preparation is a no-op. Kernel loading respects the administrator's blacklist; -no force option, kernel/package update, module unload or service restart is performed. +no force option, kernel update, module unload or service restart is performed. -Check mode observes availability and predicts a missing load or boot entry without performing -either. It does not claim the module is active. Healthy deployment and `app-verify.yml` require -both the loaded module and exact boot entry without repairing them. Preparation and verification -use separate task entry points: saved action variables cannot turn kernel or firewall verification +Check mode predicts a missing userspace package, module load or boot entry without performing +those changes. It does not claim the tools or module are ready. Healthy deployment and +`app-verify.yml` require the installed userspace package and executable, loaded module and exact +boot entry without repairing them. Preparation and verification use separate task entry points: saved action variables cannot turn kernel or firewall verification into preparation. If modules for the running kernel are missing, stop and restore the -distribution's matching kernel modules or separately -arrange a boot into a supported installed kernel, then rerun preparation. A failed or blocked load -also stops before boot-file publication or native lifecycle work. Installing WireGuard tools alone +distribution's matching kernel modules or separately arrange a boot into a supported installed +kernel, then rerun preparation. A failed or blocked load also stops before boot-file publication or native lifecycle work. Installing WireGuard tools alone does not establish this kernel prerequisite. +If native preparation fails after original initialization because a host prerequisite is missing, +complete that prerequisite through administrator preparation and rerun deployment with the exact +same pinned source, images and original campaign inputs. Preserve the normalized intent, settings, +Rune, custody receipts and any pending or ready keys. Classification must prove the original +unbound, quiescent installation before preparation resumes; it must not initialize the body again. +Missing imported keys still require explicit `tether import-key`. Unknown or replaced material is +a refusal, not permission to delete receipts or regenerate keys. A failed prepare command remains +a failure with its native diagnostic; only successful JSON receipts supply its change result. + For an offered network, set `lychd_tether_firewall_zone` to the existing firewalld zone of the concrete host IPv4 listener in that normalized intent. The deployment uses the administrator connection between runtime staging and original initialization to add only that exact UDP port