Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions deployments/ansible/roles/lychd_app/files/tether_tools.py
Original file line number Diff line number Diff line change
@@ -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())
2 changes: 1 addition & 1 deletion deployments/ansible/roles/lychd_app/tasks/apply.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
33 changes: 29 additions & 4 deletions deployments/ansible/tests/test_host_readonly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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())
Expand Down
1 change: 1 addition & 0 deletions deployments/ansible/tests/test_tether_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
)

Expand Down
59 changes: 59 additions & 0 deletions deployments/ansible/tests/test_tether_prepare_reporting.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading