diff --git a/Containerfile.tether b/Containerfile.tether new file mode 100644 index 00000000..8c3eb213 --- /dev/null +++ b/Containerfile.tether @@ -0,0 +1,17 @@ +# Project-owned standard-tools transport; immutable deployment uses its built digest. +FROM docker.io/library/debian@sha256:c67e92c8099ba22853ab4cfda7cfb494d253d6012ca82dee98ba8976772af018 +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 wireguard-tools iproute2 nftables openssh-server passwd \ + && rm -rf /var/lib/apt/lists/* /etc/ssh/ssh_host_* \ + && groupadd --system tether \ + && useradd --system --gid tether --home-dir /nonexistent --shell /usr/sbin/nologin --password '*' tether \ + && mkdir -p /usr/libexec /etc/lychd-tether /var/empty/sshd \ + && chmod 0755 /var/empty/sshd /etc/lychd-tether +COPY src/lychd/system/services/tether/runtime.py /usr/libexec/lychd_tether_runtime.py +COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/doc/lychd-tether/ +RUN chmod 0555 /usr/libexec/lychd_tether_runtime.py \ + && test -x /usr/sbin/ip && test -x /usr/sbin/nft \ + && test -x /usr/bin/wg && test -x /usr/sbin/sshd +USER 0 +ENV PYTHONPATH=/usr/libexec +CMD ["/usr/bin/python3", "-m", "lychd_tether_runtime", "/etc/lychd-tether/config.json"] diff --git a/Makefile b/Makefile index a5320abb..dae4cce5 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,13 @@ CI_PYTEST_TARGETS := tests/unit/config tests/unit/db \ tests/unit/cli/test_reach_observation.py tests/unit/system/services/test_oculus_snapshot.py \ tests/unit/cli/test_reach_operator_onboarding.py \ tests/unit/system/services/test_intercom_http.py \ + tests/unit/domain/tether tests/unit/cli/test_tether.py \ + tests/unit/system/services/test_tether_runtime.py tests/unit/system/services/test_tether_consumer.py \ + tests/unit/system/services/test_tether_broker_tls.py \ + tests/unit/system/services/test_tether_custody.py tests/unit/system/services/test_tether_keys.py \ + tests/unit/system/services/test_tether_host.py tests/unit/system/services/test_tether_service.py \ + tests/unit/system/services/test_secrets.py tests/unit/system/test_container_security.py \ + tests/unit/system/services/test_bind.py tests/unit/system/services/test_codex.py \ tests/unit/domain/test_artifact_custody.py tests/unit/domain/test_evolution.py \ tests/unit/domain/animation tests/unit/domain/cortex tests/unit/domain/delegation \ tests/unit/domain/test_naming.py \ diff --git a/deployments/ansible/README.md b/deployments/ansible/README.md index e8028655..c597c6bb 100644 --- a/deployments/ansible/README.md +++ b/deployments/ansible/README.md @@ -7,7 +7,7 @@ setup, private inventory and variables, check/apply commands, limitations and ho | --- | --- | --- | | `host.yml` / `verify.yml` | administrator + sudo | Converge / observe the installed host floor. | | `source.yml` | direct `lychd` SSH | Stage one exact source CLI while the body is inactive. | -| `app-verify.yml` | direct `lychd` SSH | Read-only application acceptance of a running body. | +| `app-verify.yml` | direct `lychd` SSH; administrator for protocol-2 host prerequisites | Read-only application and selected host-prerequisite acceptance. | | `deploy.yml` | both, one `--limit`ed host | Classify, then provision a fresh host or only verify a healthy one. | | `server.yml` | same as `deploy.yml` | Explicit headless Server entry point. | | `admin.yml` | administrator + sudo, explicit target | Replace only stock vendor sudo policy with a named administrator policy. | @@ -27,6 +27,10 @@ The [deployment index](../../docs/deployments/index.md) routes installation, Ser private access and acceptance. Private inventories, password hashes, endpoint keys, generated AutoYaST profiles and image archives belong outside the checkout, including ignored directories. -Controller and installed CLI must both implement deployment protocol 1. An older pilot whose +For the default profile, controller and installed CLI must both implement deployment protocol 1. An older pilot whose settings were edited after init is deliberately refused; retain its previous private controller for read-only observation. No automated settings migration or receipt rewrite is provided. + +For the explicit native Tether initialization and deployment protocol 2, follow +[the Tether profile](../../docs/deployments/tumbleweed/index.md#native-tether-profile-protocol-2). +The default remains protocol 1; image admission accepts `--tether` for a separate runtime image receipt. diff --git a/deployments/ansible/playbooks/app-verify.yml b/deployments/ansible/playbooks/app-verify.yml index b89db1e4..01c3831d 100644 --- a/deployments/ansible/playbooks/app-verify.yml +++ b/deployments/ansible/playbooks/app-verify.yml @@ -1,5 +1,5 @@ --- -# Read-only application acceptance over direct runtime SSH; no sudo, no effects. +# Read-only application acceptance; protocol 2 also observes the administrator-owned kernel and UDP prerequisites. - name: Observe one LychD body without changing it hosts: lychd_hosts gather_facts: false @@ -25,3 +25,21 @@ ansible.builtin.include_role: name: lychd_app tasks_from: verify + +- name: Observe the protocol-2 host prerequisites without changing them + hosts: lychd_hosts + gather_facts: false + become: true + serial: 1 + tasks: + - name: Verify the host WireGuard module and boot entry without mutation + ansible.builtin.include_role: + name: lychd_app + tasks_from: tether_kernel_verify + when: lychd_app_profile | default('empty-extensions-container') == 'tether-container-v1' + + - name: Verify the normalized listener's effective OS firewall zone and port + ansible.builtin.include_role: + name: lychd_app + tasks_from: firewall_verify + when: lychd_app_profile | default('empty-extensions-container') == 'tether-container-v1' diff --git a/deployments/ansible/playbooks/deploy.yml b/deployments/ansible/playbooks/deploy.yml index 35a192cb..d65d2fa2 100644 --- a/deployments/ansible/playbooks/deploy.yml +++ b/deployments/ansible/playbooks/deploy.yml @@ -199,6 +199,49 @@ name: lychd_image when: lychd_deploy_mode == 'mutate' and lychd_deploy_runtime_ready | bool + - name: Stage the verified normalized Tether campaign before host UDP preparation + ansible.builtin.include_role: + name: lychd_app + tasks_from: tether_stage + when: >- + lychd_app_profile | default('empty-extensions-container') == 'tether-container-v1' + and lychd_deploy_mode == 'mutate' and lychd_deploy_runtime_ready | bool + and not ansible_check_mode + +- name: Admit only declared host prerequisites before native initialization + hosts: lychd_hosts + gather_facts: false + become: true + tasks: + - name: Prepare the host WireGuard module without managing VPN state + ansible.builtin.include_role: + name: lychd_app + tasks_from: tether_kernel_prepare + when: >- + lychd_app_profile | default('empty-extensions-container') == 'tether-container-v1' + and lychd_deploy_mode == 'mutate' + and not (ansible_check_mode and lychd_app_observed.state == 'no_runtime_account') + + - name: Prepare the existing host zone without managing VPN state + ansible.builtin.include_role: + name: lychd_app + tasks_from: firewall + when: >- + lychd_app_profile | default('empty-extensions-container') == 'tether-container-v1' + and lychd_deploy_mode == 'mutate' + and not (ansible_check_mode and lychd_app_observed.state == 'no_runtime_account') + +- name: Apply native application lifecycle after host prerequisites + hosts: lychd_hosts + gather_facts: false + connection: ssh + remote_user: lychd + become: false + vars: + ansible_user: lychd + ansible_become: false + lychd_deploy_runtime_ready: "{{ not (ansible_check_mode and lychd_app_observed.state == 'no_runtime_account') }}" + tasks: - name: Initialize, configure, bind and (when authorized) start ansible.builtin.include_role: name: lychd_app @@ -219,6 +262,22 @@ tags: [floor] when: lychd_deploy_mode == 'verify' + - name: Verify the host WireGuard module and boot entry without mutation + ansible.builtin.include_role: + name: lychd_app + tasks_from: tether_kernel_verify + when: >- + lychd_app_profile | default('empty-extensions-container') == 'tether-container-v1' + and lychd_deploy_mode == 'verify' + + - name: Verify the existing normalized host UDP prerequisite without mutation + ansible.builtin.include_role: + name: lychd_app + tasks_from: firewall_verify + when: >- + lychd_app_profile | default('empty-extensions-container') == 'tether-container-v1' + and lychd_deploy_mode == 'verify' + - name: Observe application acceptance hosts: lychd_hosts gather_facts: false diff --git a/deployments/ansible/roles/lychd_app/defaults/main.yml b/deployments/ansible/roles/lychd_app/defaults/main.yml index 353d652e..da3a3a80 100644 --- a/deployments/ansible/roles/lychd_app/defaults/main.yml +++ b/deployments/ansible/roles/lychd_app/defaults/main.yml @@ -8,3 +8,13 @@ lychd_app_start_authorized: false # Bounded read-only convergence wait after start; no restart, kill or second lifecycle is attempted. lychd_app_start_poll_retries: 36 lychd_app_start_poll_delay: 10 + +# Protocol 1 remains the default; protocol 2 must be selected explicitly. +lychd_app_profile: empty-extensions-container +lychd_tether_intent: "" +lychd_tether_intent_sha256: "" +lychd_tether_image_id: "" +lychd_tether_intent_remote: /home/lychd/.local/state/lychd-deploy/tether-intent.json + +# Explicit authorization for one existing OS firewall zone; no separate port input. +lychd_tether_firewall_zone: "" diff --git a/deployments/ansible/roles/lychd_app/files/app_probe.py b/deployments/ansible/roles/lychd_app/files/app_probe.py index dadedd64..30d65c76 100644 --- a/deployments/ansible/roles/lychd_app/files/app_probe.py +++ b/deployments/ansible/roles/lychd_app/files/app_probe.py @@ -15,8 +15,14 @@ SCHEMA = "lychd.deployment" VERSION = 1 -STATES = {"absent", "initialized", "bound_inactive", "running", "foreign_revision", "unknown"} -ACTIONS = {"absent": "init", "initialized": "bind", "bound_inactive": "start", "running": "observe"} +STATES = {"absent", "initialized", "bound_inactive", "running", "foreign_revision", "unknown", "prepared_waiting_keys"} +ACTIONS = { + "absent": "init", + "initialized": "bind", + "bound_inactive": "start", + "running": "observe", + "prepared_waiting_keys": "import-key", +} class Refusal(Exception): @@ -39,18 +45,32 @@ def native_command(command, request, home): if identifier: require(re.fullmatch(r"(?:sha256:)?[0-9a-f]{64}", identifier), "exact image ID required") argv.extend(["--deployment-image-id", identifier]) + profile = request.get("profile", "empty-extensions-container") + require(profile in {"empty-extensions-container", "tether-container-v1"}, "unsupported deployment profile") + if profile == "tether-container-v1": + intent = request.get("intent", "") + image = request.get("tether_image_id", "") + require(isinstance(intent, str) and Path(intent).is_absolute(), "absolute public intent required") + require(re.fullmatch(r"[0-9a-f]{64}", image), "admitted Tether image required") + argv.extend( + ["--deployment-profile", profile, "--deployment-intent", intent, "--deployment-tether-image-id", image] + ) if command == "verify": argv.append("--deployment-verify") return source, argv -def validate_response(document): +def validate_response(document, profile="empty-extensions-container"): """Require the exact native protocol without synthesizing lifecycle truth.""" require(isinstance(document, dict), "native response is not an object") require( - document.get("schema") == SCHEMA and type(document.get("version")) is int and document["version"] == VERSION, - "native deployment protocol 1 required", + document.get("schema") == SCHEMA + and type(document.get("version")) is int + and document["version"] == (2 if profile == "tether-container-v1" else VERSION), + "matching native deployment protocol required", ) + if profile == "tether-container-v1": + require(document.get("profile") == profile, "native profile mismatch") state = document.get("state") require(state in STATES, "unsupported native deployment state") require(document.get("next_action") == ACTIONS.get(state, "refuse"), "inconsistent native action") @@ -80,7 +100,7 @@ def observe(command, request, home): raise Refusal("native deployment observation unavailable") from error require(result.returncode in {0, 1}, "native deployment observation failed") try: - document = validate_response(json.loads(result.stdout)) + document = validate_response(json.loads(result.stdout), request.get("profile", "empty-extensions-container")) except (ValueError, TypeError, KeyError) as error: raise Refusal("native deployment response unavailable or incompatible") from error require(document.get("revision", request["revision"]) == request["revision"], "native revision mismatch") @@ -101,4 +121,4 @@ def main(): try: sys.exit(main()) except (Refusal, ValueError, TypeError, KeyError, IndexError, OSError): - sys.exit("lychd deployment protocol observation refused; matching controller and native protocol 1 required") + sys.exit("lychd deployment protocol observation refused; matching controller and native protocol required") diff --git a/deployments/ansible/roles/lychd_app/files/tether_firewall.py b/deployments/ansible/roles/lychd_app/files/tether_firewall.py new file mode 100644 index 00000000..8ce2442a --- /dev/null +++ b/deployments/ansible/roles/lychd_app/files/tether_firewall.py @@ -0,0 +1,221 @@ +"""Host UDP prerequisite only; all VPN authority remains in native Tether. + +firewalld documents unbound interfaces as using the default zone: +https://firewalld.org/documentation/zone/default-zone.html +Only an existing zone and the normalized intent's exact UDP port are admitted. +""" + +import hashlib +import ipaddress +import json +import os +import pwd +import re +import stat +import subprocess +import sys +from pathlib import Path + +INTENT = Path("/home/lychd/.local/state/lychd-deploy/tether-intent.json") +LIMIT = 1024 * 1024 + + +class PrerequisiteError(Exception): + """A host prerequisite requires explicit operator correction.""" + + +def require(condition, message): + if not condition: + raise PrerequisiteError(message) + + +def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate public intent fields refused") + result[key] = value + return result + + +def trusted_tool(name): + candidates = { + "firewall-cmd": ("/usr/bin/firewall-cmd",), + "ip": ("/usr/sbin/ip", "/usr/bin/ip"), + "nmcli": ("/usr/bin/nmcli",), + } + for candidate in candidates[name]: + path = Path(candidate) + if not path.exists(): + continue + path = path.resolve(strict=True) + for item in (path, *path.parents): + metadata = item.stat() + require( + metadata.st_uid == 0 and not metadata.st_mode & 0o022, + "Host prerequisite tools must be root-owned and not group/world writable", + ) + require(path.is_file() and os.access(path, os.X_OK), "Host prerequisite tool is not executable") + return str(path) + raise PrerequisiteError("Install the host firewalld, iproute2 and NetworkManager prerequisites first") + + +def run(tool, *args): + result = subprocess.run( + [trusted_tool(tool), *args], + capture_output=True, + text=True, + timeout=20, + check=False, + env={"PATH": "/usr/sbin:/usr/bin", "LC_ALL": "C"}, + ) + require( + len(result.stdout.encode()) <= LIMIT and len(result.stderr.encode()) <= LIMIT, + "Host prerequisite observation exceeded its bound", + ) + return result.returncode, result.stdout.strip() + + +def read_intent(expected_hash, path=INTENT, owner_uid=None): + require( + isinstance(expected_hash, str) and re.fullmatch("[0-9a-f]{64}", expected_hash), + "A verified normalized campaign hash is required", + ) + uid = pwd.getpwnam("lychd").pw_uid if owner_uid is None else owner_uid + descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + for part in path.parent.parts[1:]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + metadata = os.fstat(descriptor) + require( + metadata.st_uid in {0, uid} and not metadata.st_mode & 0o022, "Public intent parent custody is unsafe" + ) + file_fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor) + try: + metadata = os.fstat(file_fd) + require( + stat.S_ISREG(metadata.st_mode) + and metadata.st_nlink == 1 + and metadata.st_uid == uid + and stat.S_IMODE(metadata.st_mode) == 0o600 + and metadata.st_size <= LIMIT, + "Public intent must be an ordinary private runtime-owned file", + ) + raw = os.read(file_fd, LIMIT + 1) + require( + hashlib.sha256(raw).hexdigest() == expected_hash and len(raw) <= LIMIT, + "Normalized public intent changed since campaign verification", + ) + return json.loads(raw, object_pairs_hook=unique) + finally: + os.close(file_fd) + finally: + os.close(descriptor) + + +def reconcile(intent, zone, mode, command=run): + require(mode in {"prepare", "plan", "verify"}, "Unsupported host prerequisite action") + offered = intent["rune"].get("offered") + if offered is None: + return {"state": "not_required", "changed": False, "planned": []} + require( + isinstance(zone, str) and re.fullmatch("[A-Za-z0-9_-]{1,64}", zone), + "Set lychd_tether_firewall_zone to the existing listener interface zone", + ) + address = ipaddress.IPv4Address(offered["listen_address"]) + require( + not (address.is_unspecified or address.is_loopback or address.is_multicast or address.is_link_local), + "Choose one concrete assigned host IPv4 listener; wildcard/loopback/link-local listeners are not admitted", + ) + port = offered["listen_port"] + require(type(port) is int and 1 <= port <= 65535, "The normalized intent requires one numeric UDP port") + + def checked(tool, *args): + code, output = command(tool, *args) + require(code == 0, "Host address/firewalld prerequisite observation failed") + return output + + def interface_zone(): + rows = json.loads(checked("ip", "-j", "-4", "address", "show"), object_pairs_hook=unique) + matches = [ + row["ifname"] + for row in rows + for item in row.get("addr_info", []) + if item.get("family") == "inet" and item.get("local") == str(address) + ] + require(len(matches) == 1, "Listener IPv4 must be assigned exactly once to one host interface") + interface = matches[0] + require( + isinstance(interface, str) and re.fullmatch("[A-Za-z0-9_.:-]{1,15}", interface), + "Host interface name is outside the closed prerequisite profile", + ) + require( + zone in checked("firewall-cmd", "--get-zones").split() + and zone in checked("firewall-cmd", "--permanent", "--get-zones").split(), + "Selected firewalld zone must already exist in runtime and permanent configuration", + ) + for permanent in (False, True): + prefix = ("--permanent",) if permanent else () + actual = checked("firewall-cmd", *prefix, "--get-zone-of-interface=" + interface) + if actual in {"", "no zone"}: + actual = checked("firewall-cmd", "--get-default-zone") + require(actual == zone, "Listener interface runtime or permanent zone differs from selected firewalld zone") + managed = checked("nmcli", "--get-values", "GENERAL.NM-MANAGED", "device", "show", interface) + require( + managed == "yes", + "Only an observed NetworkManager-managed listener is supported; verify persistent ownership explicitly", + ) + connection = checked("nmcli", "--get-values", "GENERAL.CON-UUID", "device", "show", interface) + require( + re.fullmatch("[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", connection), + "Listener requires one active NetworkManager connection UUID", + ) + saved_zone = checked("nmcli", "--get-values", "connection.zone", "connection", "show", "uuid", connection) + if saved_zone == "": + saved_zone = checked("firewall-cmd", "--get-default-zone") + require(saved_zone == zone, "NetworkManager connection zone differs from selected firewalld zone") + return interface + + interface = interface_zone() + rule = str(port) + "/udp" + missing = [] + for permanent in (True, False): + prefix = ("--permanent",) if permanent else () + code, output = command("firewall-cmd", *prefix, "--zone=" + zone, "--query-port=" + rule) + require((code, output) in {(0, "yes"), (1, "no")}, "Unable to query the exact firewalld UDP port") + if code: + missing.append(permanent) + planned = ["permanent" if value else "runtime" for value in missing] + require(mode != "verify" or not missing, "Required UDP port is missing from runtime or permanent firewalld") + if mode == "prepare": + for permanent in missing: + require(interface_zone() == interface, "Listener interface changed before firewall preparation") + prefix = ("--permanent",) if permanent else () + checked("firewall-cmd", *prefix, "--zone=" + zone, "--add-port=" + rule) + # The verify path checks both exact port entries and the effective NIC zone again. + reconcile(intent, zone, "verify", command) + return { + "state": "planned" if mode == "plan" and missing else "ready", + "changed": mode == "prepare" and bool(missing), + "planned": planned, + "interface": interface, + "zone": zone, + "address": str(address), + "port": port, + "protocol": "udp", + "provider_firewall": "not_observed", + } + + +if __name__ == "__main__": + try: + require(os.geteuid() == 0, "Host firewall prerequisite requires the administrator connection") + request = json.loads(sys.stdin.read(LIMIT + 1), object_pairs_hook=unique) + require(set(request) == {"mode", "zone", "normalized_sha256"}, "Closed host prerequisite request required") + result = reconcile(read_intent(request["normalized_sha256"]), request["zone"], request["mode"]) + print(json.dumps(result, sort_keys=True)) + except PrerequisiteError as error: + sys.exit(str(error)) + except (OSError, ValueError, TypeError, KeyError, subprocess.SubprocessError): + sys.exit("Host UDP prerequisite refused; verify the normalized intent and host firewalld installation") diff --git a/deployments/ansible/roles/lychd_app/files/tether_intent.py b/deployments/ansible/roles/lychd_app/files/tether_intent.py new file mode 100644 index 00000000..430577e2 --- /dev/null +++ b/deployments/ansible/roles/lychd_app/files/tether_intent.py @@ -0,0 +1,170 @@ +"""Stage a public intent once, mapping admitted OCI bytes to the imported digest. + +No Core imports or secret handling. Existing campaign bytes are verified, never +rewritten. The installed native CLI still owns topology and lifecycle admission. +""" + +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +LIMIT = 1024 * 1024 +ROOT = Path("/home/lychd/.local/state/lychd-deploy") +REPOSITORY = "localhost/lychd-tether" + + +class Refusal(Exception): + """A public campaign does not have exact admission evidence.""" + + +def require(condition): + if not condition: + raise Refusal("Tether public intent admission refused") + + +def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result) + result[key] = value + return result + + +def encode(value): + return (json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode() + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def read(path): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + metadata = os.fstat(fd) + require(stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.getuid()) + require(stat.S_IMODE(metadata.st_mode) == 0o600 and metadata.st_nlink == 1) + require(metadata.st_size <= LIMIT) + raw = os.read(fd, LIMIT + 1) + require(len(raw) <= LIMIT) + current = os.stat(path, follow_symlinks=False) + require( + (current.st_dev, current.st_ino, current.st_mode, current.st_size, current.st_mtime_ns) + == (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_size, metadata.st_mtime_ns) + ) + return raw + finally: + os.close(fd) + + +def publish(path, raw): + """Exclusive publication; interrupted writes are refused, never adopted.""" + if os.path.lexists(path): + require(read(path) == raw) + return False + fd, temporary = tempfile.mkstemp(prefix=".tether-intent-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(raw) + handle.flush() + os.fsync(handle.fileno()) + try: + os.link(temporary, path, follow_symlinks=False) + except FileExistsError: + require(read(path) == raw) + return False + parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(parent) + finally: + os.close(parent) + finally: + os.unlink(temporary) + return True + + +def image(reference): + result = subprocess.run( + ["/usr/bin/podman", "image", "inspect", reference], capture_output=True, text=True, timeout=30, check=False + ) + require(result.returncode == 0 and len(result.stdout) <= LIMIT) + rows = json.loads(result.stdout, object_pairs_hook=unique) + require(isinstance(rows, list) and len(rows) == 1 and isinstance(rows[0], dict)) + return rows[0] + + +def normalize(raw, request, inspect=image): + """Only the image string changes; actual repository digest must resolve.""" + require(sha(raw) == request["source_sha256"]) + require(re.fullmatch("[0-9a-f]{64}", request["image_id"]) is not None) + require(re.fullmatch("[0-9a-f]{40}", request["revision"]) is not None) + require(re.fullmatch("sha256:[0-9a-f]{64}", request["archive_manifest_digest"]) is not None) + require(re.fullmatch("[0-9a-f]{64}", request["archive_sha256"]) is not None) + value = json.loads(raw, object_pairs_hook=unique) + expected = REPOSITORY + "@" + request["archive_manifest_digest"] + require(value["rune"]["quadlet"]["image"] == expected) + imported = inspect(request["image_id"]) + require(imported.get("Id") == request["image_id"]) + require(imported.get("Labels", {}).get("org.opencontainers.image.revision") == request["revision"]) + digest = imported.get("Digest", "") + require(re.fullmatch("sha256:[0-9a-f]{64}", digest) is not None) + reference = REPOSITORY + "@" + digest + resolved = inspect(reference) + require(resolved.get("Id") == request["image_id"] and resolved.get("Digest") == digest) + require(resolved.get("Labels", {}).get("org.opencontainers.image.revision") == request["revision"]) + value["rune"]["quadlet"]["image"] = reference + normalized = encode(value) + facts = { + "schema": "lychd.tether.campaign", + "version": 1, + "source_sha256": sha(raw), + "normalized_sha256": sha(normalized), + "archive_sha256": request["archive_sha256"], + "archive_manifest_digest": request["archive_manifest_digest"], + "image_id": request["image_id"], + "revision": request["revision"], + "image": reference, + } + return normalized, facts + + +def operate(command, request, root=ROOT, inspect=image): + require(command in {"stage", "verify"}) + if not os.path.lexists(root): + require(command == "verify") + return {"state": "absent", "changed": False} + metadata = root.lstat() + require(stat.S_ISDIR(metadata.st_mode) and metadata.st_uid == os.getuid()) + require(stat.S_IMODE(metadata.st_mode) == 0o700) + target = root / "tether-intent.json" + receipt = root / "tether-campaign.json" + if command == "verify" and not os.path.lexists(target) and not os.path.lexists(receipt): + return {"state": "absent", "changed": False} + normalized, facts = normalize(read(root / "tether-intent.source.json"), request, inspect) + offered = json.loads(normalized)["rune"].get("offered") is not None + if command == "stage": + changed = publish(target, normalized) + changed = publish(receipt, encode(facts)) or changed + else: + require(read(target) == normalized) + if not os.path.lexists(receipt): + # Publication order is intent then receipt. A retry may finish only + # the missing receipt after re-verifying all immutable input bytes. + return {"state": "pending_receipt", "changed": False, "offered": offered, **facts} + require(read(receipt) == encode(facts)) + changed = False + return {"state": "staged", "changed": changed, "offered": offered, **facts} + + +if __name__ == "__main__": + try: + request = json.loads(sys.stdin.read(LIMIT + 1), object_pairs_hook=unique) + print(json.dumps(operate(sys.argv[1], request), sort_keys=True)) + except (Refusal, OSError, ValueError, TypeError, KeyError, IndexError, subprocess.SubprocessError): + sys.exit("Tether public intent admission refused; campaign bytes were not replaced") diff --git a/deployments/ansible/roles/lychd_app/tasks/apply.yml b/deployments/ansible/roles/lychd_app/tasks/apply.yml index 9b799c3d..4060999b 100644 --- a/deployments/ansible/roles/lychd_app/tasks/apply.yml +++ b/deployments/ansible/roles/lychd_app/tasks/apply.yml @@ -7,7 +7,7 @@ - name: Refuse application effects outside the admitted chain ansible.builtin.assert: that: lychd_app_observed.state in ['absent', 'initialized', 'bound_inactive'] - fail_msg: "Application state {{ lychd_app_observed.state }} is not mutable by automation: {{ lychd_app_observed.reasons }}" + fail_msg: "Application state {{ lychd_app_observed.state }} is not mutable by automation: {{ lychd_app_observed.reasons }}. For prepared_waiting_keys, use native tether import-key for {{ lychd_app_observed.facts.tether_preparation.missing_secrets | default([]) }} and rerun." - name: Report the check-mode boundary for application effects ansible.builtin.debug: @@ -18,18 +18,30 @@ ansible.builtin.meta: end_host when: ansible_check_mode +- name: Stage public Tether intent and verified image before original initialization + ansible.builtin.include_tasks: tether_stage.yml + when: lychd_app_profile == 'tether-container-v1' + +- name: Select the closed native protocol arguments + ansible.builtin.set_fact: + lychd_app_init_profile_args: "{{ ['--profile', 'tether-container-v1', '--intent', lychd_tether_intent_remote] if lychd_app_profile == 'tether-container-v1' else [] }}" + lychd_app_bind_args: "{{ ['tether', 'bind'] if lychd_app_profile == 'tether-container-v1' else ['bind'] }}" + lychd_app_status_args: "{{ ['--deployment-profile', 'tether-container-v1', '--deployment-intent', lychd_tether_intent_remote, '--deployment-tether-image-id', lychd_tether_image_id] if lychd_app_profile == 'tether-container-v1' else [] }}" + lychd_app_protocol: "{{ 2 if lychd_app_profile == 'tether-container-v1' else 1 }}" + # Block conditions are re-evaluated per task, so fix the step plan before re-classifying inside it. - name: Fix the admitted step plan from the entry state ansible.builtin.set_fact: lychd_app_step_init: "{{ lychd_app_observed.state == 'absent' }}" lychd_app_step_bind: "{{ lychd_app_observed.state in ['absent', 'initialized'] }}" + lychd_app_step_prepare: "{{ lychd_app_profile == 'tether-container-v1' and lychd_app_observed.state in ['absent', 'initialized'] }}" - name: Initialize an absent installation when: lychd_app_step_init | bool block: - name: Preview initialization ansible.builtin.command: - argv: ["{{ lychd_app_cli }}", init, --web-image, "{{ lychd_app_image_ref }}", --dry-run] + argv: "{{ [lychd_app_cli, 'init', '--web-image', lychd_app_image_ref, '--dry-run'] + lychd_app_init_profile_args }}" chdir: "{{ lychd_app_source }}" environment: "{{ lychd_app_env }}" register: lychd_app_init_plan @@ -37,7 +49,7 @@ - name: Initialize the Codex and Crypt ansible.builtin.command: - argv: ["{{ lychd_app_cli }}", init, --web-image, "{{ lychd_app_image_ref }}"] + argv: "{{ [lychd_app_cli, 'init', '--web-image', lychd_app_image_ref] + lychd_app_init_profile_args }}" chdir: "{{ lychd_app_source }}" environment: "{{ lychd_app_env }}" changed_when: true @@ -51,6 +63,26 @@ - lychd_app_observed.state == 'initialized' - lychd_app_observed.facts.configuration.state == 'original' +- name: Prepare or resume preparation of an originally initialized Tether installation + when: lychd_app_step_prepare | bool + block: + - name: Prepare native Tether custody from the original public intent + ansible.builtin.command: + argv: ["{{ lychd_app_cli }}", tether, prepare, --intent, "{{ lychd_tether_intent_remote }}"] + chdir: "{{ lychd_app_source }}" + environment: "{{ lychd_app_env }}" + when: lychd_app_profile == 'tether-container-v1' + register: lychd_tether_preparation + changed_when: (lychd_tether_preparation.stdout | from_json).changed + + - name: Re-classify preparation and key admission + ansible.builtin.include_tasks: classify.yml + + - name: Stop for explicit native key import + ansible.builtin.assert: + that: lychd_app_observed.state == 'initialized' + fail_msg: "Tether preparation requires native import-key for missing_secrets: {{ lychd_app_observed.facts.tether_preparation.missing_secrets | default([]) }}. Import keys explicitly, then rerun application deployment." + - name: Bind an initialized installation when: lychd_app_step_bind | bool block: @@ -60,14 +92,14 @@ - name: Preview binding ansible.builtin.command: - argv: ["{{ lychd_app_cli }}", bind, --dry-run] + argv: "{{ [lychd_app_cli] + lychd_app_bind_args + ['--dry-run'] }}" chdir: "{{ lychd_app_source }}" environment: "{{ lychd_app_env }}" changed_when: false - name: Bind the generated units and create only absent core secrets ansible.builtin.command: - argv: ["{{ lychd_app_cli }}", bind] + argv: "{{ [lychd_app_cli] + lychd_app_bind_args }}" chdir: "{{ lychd_app_source }}" environment: "{{ lychd_app_env }}" changed_when: true @@ -99,13 +131,21 @@ when: lychd_app_observed.state == 'bound_inactive' and (lychd_app_start_authorized | bool) changed_when: true +- name: Activate and verify Tether through native lifecycle after the guarded Core bootstrap + ansible.builtin.command: + argv: ["{{ lychd_app_cli }}", tether, activate] + chdir: "{{ lychd_app_source }}" + environment: "{{ lychd_app_env }}" + when: lychd_app_profile == 'tether-container-v1' and (lychd_app_start_authorized | bool) + changed_when: true + - name: Wait read-only for the native deployment assessment to converge ansible.builtin.command: - argv: ["{{ lychd_app_cli }}", status, --json, --deployment-revision, "{{ lychd_source_revision }}", --deployment-image-id, "{{ lychd_image_id }}"] + argv: "{{ [lychd_app_cli, 'status', '--json', '--deployment-revision', lychd_source_revision, '--deployment-image-id', lychd_image_id] + lychd_app_status_args }}" chdir: "{{ lychd_app_source }}" environment: "{{ lychd_app_env }}" register: lychd_app_units - until: lychd_app_units.rc == 0 and (lychd_app_units.stdout | from_json).schema == 'lychd.deployment' and (lychd_app_units.stdout | from_json).version == 1 and (lychd_app_units.stdout | from_json).state == 'running' + until: lychd_app_units.rc == 0 and (lychd_app_units.stdout | from_json).schema == 'lychd.deployment' and (lychd_app_units.stdout | from_json).version == (lychd_app_protocol | int) and (lychd_app_units.stdout | from_json).state == 'running' retries: "{{ lychd_app_start_poll_retries }}" delay: "{{ lychd_app_start_poll_delay }}" changed_when: false @@ -113,7 +153,7 @@ - name: Report non-convergence without intervening ansible.builtin.assert: - that: lychd_app_units.rc == 0 and (lychd_app_units.stdout | from_json).schema == 'lychd.deployment' and (lychd_app_units.stdout | from_json).version == 1 and (lychd_app_units.stdout | from_json).state == 'running' + that: lychd_app_units.rc == 0 and (lychd_app_units.stdout | from_json).schema == 'lychd.deployment' and (lychd_app_units.stdout | from_json).version == (lychd_app_protocol | int) and (lychd_app_units.stdout | from_json).state == 'running' fail_msg: >- Native deployment assessment did not reach running. Automation does not stop, restart or kill anything; follow the approved recovery plan and `lychd logs`. diff --git a/deployments/ansible/roles/lychd_app/tasks/classify.yml b/deployments/ansible/roles/lychd_app/tasks/classify.yml index 02968865..8ee757cc 100644 --- a/deployments/ansible/roles/lychd_app/tasks/classify.yml +++ b/deployments/ansible/roles/lychd_app/tasks/classify.yml @@ -21,6 +21,24 @@ changed_when: false check_mode: false +- name: Require an explicitly supported profile + ansible.builtin.assert: + that: lychd_app_profile in ['empty-extensions-container', 'tether-container-v1'] + +- name: Verify the normalized immutable public Tether campaign + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -I + - -c + - "{{ lookup('ansible.builtin.file', role_path + '/files/tether_intent.py') }}" + - verify + stdin: "{{ {'source_sha256': lychd_tether_intent_sha256, 'archive_sha256': lychd_tether_image_archive_sha256, 'archive_manifest_digest': lychd_tether_image_manifest_digest, 'image_id': lychd_tether_image_id, 'revision': lychd_source_revision} | to_json }}" + register: lychd_tether_campaign + changed_when: false + check_mode: false + when: lychd_app_profile == 'tether-container-v1' + - name: Classify the application without effects ansible.builtin.command: argv: @@ -29,7 +47,7 @@ - -c - "{{ lookup('ansible.builtin.file', role_path + '/files/app_probe.py') }}" - classify - stdin: "{{ {'revision': lychd_source_revision, 'image_id': lychd_image_id | default('')} | to_json }}" + stdin: "{{ {'revision': lychd_source_revision, 'image_id': lychd_image_id | default(''), 'profile': lychd_app_profile, 'intent': lychd_tether_intent_remote, 'tether_image_id': lychd_tether_image_id} | to_json }}" register: lychd_app_classify changed_when: false check_mode: false diff --git a/deployments/ansible/roles/lychd_app/tasks/firewall.yml b/deployments/ansible/roles/lychd_app/tasks/firewall.yml new file mode 100644 index 00000000..7b377e59 --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/firewall.yml @@ -0,0 +1,25 @@ +--- +- name: Require verified normalized campaign facts before host UDP preparation + ansible.builtin.assert: + that: + - lychd_tether_campaign is defined + - (lychd_tether_campaign.stdout | from_json).state in ['staged', 'pending_receipt'] + fail_msg: "Stage and verify the normalized public Tether campaign first; check mode cannot invent an unstaged listener." + +- name: Prepare only the normalized intent's host UDP prerequisite + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -I + - -c + - "{{ lookup('ansible.builtin.file', role_path + '/files/tether_firewall.py') }}" + stdin: "{{ {'mode': ('plan' if ansible_check_mode else 'prepare'), 'zone': lychd_tether_firewall_zone, 'normalized_sha256': (lychd_tether_campaign.stdout | from_json).normalized_sha256} | to_json }}" + register: lychd_tether_firewall_result + check_mode: false + changed_when: (lychd_tether_firewall_result.stdout | from_json).changed + when: (lychd_tether_campaign.stdout | from_json).offered + +- name: Report the bounded host UDP observation + ansible.builtin.debug: + msg: "{{ lychd_tether_firewall_result.stdout | from_json }}" + when: (lychd_tether_campaign.stdout | from_json).offered diff --git a/deployments/ansible/roles/lychd_app/tasks/firewall_verify.yml b/deployments/ansible/roles/lychd_app/tasks/firewall_verify.yml new file mode 100644 index 00000000..5054f8ec --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/firewall_verify.yml @@ -0,0 +1,25 @@ +--- +- name: Require verified normalized campaign facts before host UDP verification + ansible.builtin.assert: + that: + - lychd_tether_campaign is defined + - (lychd_tether_campaign.stdout | from_json).state in ['staged', 'pending_receipt'] + fail_msg: "Stage and verify the normalized public Tether campaign first; check mode cannot invent an unstaged listener." + +- name: Observe only the normalized intent's host UDP prerequisite + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -I + - -c + - "{{ lookup('ansible.builtin.file', role_path + '/files/tether_firewall.py') }}" + stdin: "{{ {'mode': 'verify', 'zone': lychd_tether_firewall_zone, 'normalized_sha256': (lychd_tether_campaign.stdout | from_json).normalized_sha256} | to_json }}" + register: lychd_tether_firewall_result + check_mode: false + changed_when: (lychd_tether_firewall_result.stdout | from_json).changed + when: (lychd_tether_campaign.stdout | from_json).offered + +- name: Report the bounded host UDP observation + ansible.builtin.debug: + msg: "{{ lychd_tether_firewall_result.stdout | from_json }}" + when: (lychd_tether_campaign.stdout | from_json).offered diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_kernel.yml b/deployments/ansible/roles/lychd_app/tasks/tether_kernel.yml new file mode 100644 index 00000000..14156fc7 --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/tether_kernel.yml @@ -0,0 +1,90 @@ +--- +# Host kernel availability is an administrator prerequisite, not native VPN state. +- name: Require the explicit Tether profile + ansible.builtin.assert: + that: + - lychd_app_profile == 'tether-container-v1' + +- name: Observe the effective prerequisite user + ansible.builtin.command: + argv: [/usr/bin/id, -u] + register: lychd_tether_kernel_uid + changed_when: false + check_mode: false + +- name: Require administrator authority for the host prerequisite + ansible.builtin.assert: + that: lychd_tether_kernel_uid.stdout | trim == '0' + fail_msg: "The WireGuard kernel prerequisite belongs to the administrator play, never the runtime account." + +- name: Observe the running kernel release + ansible.builtin.command: + argv: [/usr/bin/uname, -r] + register: lychd_tether_kernel_release + changed_when: false + check_mode: false + +- name: Observe WireGuard availability for this running kernel + ansible.builtin.command: + argv: [/usr/sbin/modinfo, -k, "{{ lychd_tether_kernel_release.stdout | trim }}", -F, filename, wireguard] + register: lychd_tether_kernel_module + failed_when: false + changed_when: false + check_mode: false + +- name: Require matching running-kernel WireGuard support before any effect + ansible.builtin.assert: + that: + - lychd_tether_kernel_module.rc == 0 + - lychd_tether_kernel_module.stdout | trim | length > 0 + fail_msg: >- + WireGuard is unavailable for running kernel {{ lychd_tether_kernel_release.stdout | trim }}. + Restore that distribution kernel's matching modules or separately boot a supported installed + kernel, then rerun. This role does not install or update kernels/modules, bypass policy or reboot. + +- name: Observe the system module-load directory without following symlinks + ansible.builtin.stat: + path: /etc/modules-load.d + follow: false + register: lychd_tether_module_directory + +- name: Require a safe existing administrator-owned module-load directory + ansible.builtin.assert: + that: + - lychd_tether_module_directory.stat.isdir | default(false) + - lychd_tether_module_directory.stat.uid | default(-1) == 0 + - lychd_tether_module_directory.stat.mode | default('') is match('^0[0-7][0145][0145]$') + fail_msg: "The existing /etc/modules-load.d must be a root-owned directory without group/other write access." + +- name: Observe the reserved Tether boot entry without following symlinks + ansible.builtin.stat: + path: /etc/modules-load.d/lychd-tether.conf + follow: false + register: lychd_tether_module_boot + +- name: Refuse unsafe existing Tether boot entries before loading + ansible.builtin.assert: + that: + - >- + not lychd_tether_module_boot.stat.exists or + (lychd_tether_module_boot.stat.isreg | default(false) and + lychd_tether_module_boot.stat.uid | default(-1) == 0 and + lychd_tether_module_boot.stat.gid | default(-1) == 0 and + lychd_tether_module_boot.stat.mode | default('') == '0644' and + lychd_tether_module_boot.stat.nlink | default(0) == 1 and + lychd_tether_module_boot.stat.size | default(0) == 10) + fail_msg: "Existing /etc/modules-load.d/lychd-tether.conf is not an exact root-owned regular 0644 entry; refusing replacement." + +- name: Read only a metadata-validated existing Tether boot entry + ansible.builtin.slurp: + src: /etc/modules-load.d/lychd-tether.conf + register: lychd_tether_module_content + when: lychd_tether_module_boot.stat.exists + +- name: Refuse different reserved boot bytes before loading + ansible.builtin.assert: + that: >- + not lychd_tether_module_boot.stat.exists or + (lychd_tether_module_content.content | default('') | b64decode) == 'wireguard\n' + fail_msg: "Existing /etc/modules-load.d/lychd-tether.conf has different content; no module was loaded or file replaced." + diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_kernel_prepare.yml b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_prepare.yml new file mode 100644 index 00000000..3323df69 --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_prepare.yml @@ -0,0 +1,44 @@ +--- +- name: Check the host kernel and reserved boot entry before preparation + ansible.builtin.import_tasks: tether_kernel.yml + +- name: Load only an absent WireGuard module with administrator policy intact + ansible.builtin.command: + argv: [/usr/sbin/modprobe, --use-blacklist, wireguard] + creates: /sys/module/wireguard + # command's creates check predicts changes in check mode without executing modprobe. + # Do not override changed_when: the first real load must report its effect. + +- name: Observe actual WireGuard presence after preparation or during verification + ansible.builtin.stat: + path: /sys/module/wireguard + follow: false + register: lychd_tether_kernel_loaded + +- name: Require observed WireGuard before boot publication or native lifecycle + ansible.builtin.assert: + that: lychd_tether_kernel_loaded.stat.isdir | default(false) + fail_msg: >- + WireGuard is not loaded in the running host kernel. A successful modprobe exit is not proof; + check administrator blacklist/install policy, module signatures and matching kernel modules. + No boot entry or native lifecycle effect follows this failure. + when: not ansible_check_mode + +- name: Persist only an absent Tether module-load entry after observed loading + ansible.builtin.copy: + dest: /etc/modules-load.d/lychd-tether.conf + content: "wireguard\n" + owner: root + group: root + mode: '0644' + force: false + when: not lychd_tether_module_boot.stat.exists + +- name: Report the check-mode kernel prerequisite prediction + ansible.builtin.debug: + msg: + running_kernel: "{{ lychd_tether_kernel_release.stdout | trim }}" + loaded_now: "{{ lychd_tether_kernel_loaded.stat.isdir | default(false) }}" + boot_entry_present: "{{ lychd_tether_module_boot.stat.exists }}" + boundary: "Load and boot-entry changes were predicted only; native effects remain withheld in check mode." + when: ansible_check_mode diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_kernel_verify.yml b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_verify.yml new file mode 100644 index 00000000..9c55a0db --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/tether_kernel_verify.yml @@ -0,0 +1,19 @@ +--- +- name: Check the host kernel and reserved boot entry without mutation + ansible.builtin.import_tasks: tether_kernel.yml + +- name: Require the persistent Tether entry during read-only verification + ansible.builtin.assert: + that: lychd_tether_module_boot.stat.exists + fail_msg: "WireGuard boot persistence is absent; read-only verification does not create it." + +- name: Observe actual WireGuard presence during read-only verification + ansible.builtin.stat: + path: /sys/module/wireguard + follow: false + register: lychd_tether_kernel_loaded + +- name: Require observed WireGuard during read-only verification + 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." diff --git a/deployments/ansible/roles/lychd_app/tasks/tether_stage.yml b/deployments/ansible/roles/lychd_app/tasks/tether_stage.yml new file mode 100644 index 00000000..7b4e0179 --- /dev/null +++ b/deployments/ansible/roles/lychd_app/tasks/tether_stage.yml @@ -0,0 +1,91 @@ +--- +- name: Require the public intent and independently admitted Tether image + ansible.builtin.assert: + that: + - lychd_tether_intent is match('^/[^\n]+$') + - lychd_tether_intent_sha256 is match('^[0-9a-f]{64}$') + - lychd_tether_image_id is match('^[0-9a-f]{64}$') + - lychd_tether_image_revision == lychd_source_revision + - lychd_tether_intent_remote == '/home/lychd/.local/state/lychd-deploy/tether-intent.json' + +- name: Verify the public controller intent before transfer + ansible.builtin.stat: + path: "{{ lychd_tether_intent }}" + follow: false + checksum_algorithm: sha256 + delegate_to: localhost + vars: + ansible_connection: local + register: lychd_tether_controller_intent + +- name: Refuse a changed or symlinked intent + ansible.builtin.assert: + that: + - lychd_tether_controller_intent.stat.isreg | default(false) + - not (lychd_tether_controller_intent.stat.islnk | default(true)) + - lychd_tether_controller_intent.stat.checksum == lychd_tether_intent_sha256 + +- name: Re-attest the exact archive manifest before image import and intent normalization + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -I + - -c + - "{{ lookup('ansible.builtin.file', role_path + '/../lychd_image/files/admit_oci_archive.py') }}" + - --verify-tether + - "{{ lychd_tether_image_archive }}" + - "{{ lychd_source_revision }}" + - "{{ lychd_tether_image_id }}" + - "{{ lychd_tether_image_manifest_digest }}" + - "{{ lychd_tether_image_archive_sha256 }}" + delegate_to: localhost + vars: + ansible_connection: local + changed_when: false + +- name: Load only the verified Tether OCI archive through the existing image importer + ansible.builtin.include_role: + name: lychd_image + vars: + lychd_image_kind: tether + +- name: Create private deployment staging outside native owned roots + ansible.builtin.file: + path: /home/lychd/.local/state/lychd-deploy + state: directory + mode: '0700' + +- name: Stage the public intent without replacing an existing campaign + ansible.builtin.copy: + src: "{{ lychd_tether_intent }}" + dest: /home/lychd/.local/state/lychd-deploy/tether-intent.source.json + force: false + mode: '0600' + +- name: Verify the staged intent digest + ansible.builtin.stat: + path: /home/lychd/.local/state/lychd-deploy/tether-intent.source.json + follow: false + checksum_algorithm: sha256 + register: lychd_tether_staged_intent + +- name: Require the exact admitted public intent before original initialization + ansible.builtin.assert: + that: + - lychd_tether_staged_intent.stat.isreg | default(false) + - not (lychd_tether_staged_intent.stat.islnk | default(true)) + - lychd_tether_staged_intent.stat.checksum == lychd_tether_intent_sha256 + +- name: Stage the normalized immutable public Tether campaign + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -I + - -c + - "{{ lookup('ansible.builtin.file', role_path + '/files/tether_intent.py') }}" + - stage + stdin: "{{ {'source_sha256': lychd_tether_intent_sha256, 'archive_sha256': lychd_tether_image_archive_sha256, 'archive_manifest_digest': lychd_tether_image_manifest_digest, 'image_id': lychd_tether_image_id, 'revision': lychd_source_revision} | to_json }}" + register: lychd_tether_campaign + changed_when: (lychd_tether_campaign.stdout | from_json).changed + check_mode: false + when: lychd_app_profile == 'tether-container-v1' diff --git a/deployments/ansible/roles/lychd_app/tasks/verify.yml b/deployments/ansible/roles/lychd_app/tasks/verify.yml index e5692848..62c00f96 100644 --- a/deployments/ansible/roles/lychd_app/tasks/verify.yml +++ b/deployments/ansible/roles/lychd_app/tasks/verify.yml @@ -20,6 +20,20 @@ changed_when: false check_mode: false +- name: Verify the normalized immutable public Tether campaign + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -I + - -c + - "{{ lookup('ansible.builtin.file', role_path + '/files/tether_intent.py') }}" + - verify + stdin: "{{ {'source_sha256': lychd_tether_intent_sha256, 'archive_sha256': lychd_tether_image_archive_sha256, 'archive_manifest_digest': lychd_tether_image_manifest_digest, 'image_id': lychd_tether_image_id, 'revision': lychd_source_revision} | to_json }}" + register: lychd_tether_campaign + changed_when: false + check_mode: false + when: lychd_app_profile == 'tether-container-v1' + - name: Observe server-local application acceptance without effects ansible.builtin.command: argv: @@ -30,7 +44,7 @@ - verify # Hosts deployed from the admitted archive must run its exact ID; earlier hand-built hosts are # checked by revision label and running-container/settings agreement instead. - stdin: "{{ {'revision': lychd_source_revision, 'image_id': lychd_app_expected_image_id | default('')} | to_json }}" + stdin: "{{ {'revision': lychd_source_revision, 'image_id': lychd_app_expected_image_id | default(''), 'profile': lychd_app_profile, 'intent': lychd_tether_intent_remote, 'tether_image_id': lychd_tether_image_id} | to_json }}" register: lychd_app_verify changed_when: false check_mode: false diff --git a/deployments/ansible/roles/lychd_image/defaults/main.yml b/deployments/ansible/roles/lychd_image/defaults/main.yml index 33609cd0..0545faa5 100644 --- a/deployments/ansible/roles/lychd_image/defaults/main.yml +++ b/deployments/ansible/roles/lychd_image/defaults/main.yml @@ -7,3 +7,6 @@ lychd_image_ref: "" lychd_image_revision: "" # Private runtime-user staging outside every LychD-owned root; the copy is removed after loading. lychd_image_staging_dir: /home/lychd/.local/state/lychd-deploy + +# Closed image repository selection; optional Tether reuses the same verified import. +lychd_image_kind: core diff --git a/deployments/ansible/roles/lychd_image/files/admit_oci_archive.py b/deployments/ansible/roles/lychd_image/files/admit_oci_archive.py index 8c8e6d6f..9f6dc4f0 100644 --- a/deployments/ansible/roles/lychd_image/files/admit_oci_archive.py +++ b/deployments/ansible/roles/lychd_image/files/admit_oci_archive.py @@ -79,7 +79,9 @@ def read_verified(archive, digest, *, keep=True, size=None): return bytes(kept) -def inspect_archive(path, revision, expected_image_id=None): +def inspect_archive(path, revision, expected_image_id=None, *, kind="core"): + require(kind in {"core", "tether"}, "unsupported image kind") + repository = "lychd-tether" if kind == "tether" else "lychd" require(re.fullmatch(r"[0-9a-f]{40}", revision or ""), "exact source SHA required") if expected_image_id: require(re.fullmatch(r"[0-9a-f]{64}", expected_image_id), "expected image ID must be 64 hex characters") @@ -104,7 +106,9 @@ def inspect_archive(path, revision, expected_image_id=None): require(len(manifests) == 1, f"archive must contain exactly one image, found {len(manifests)}") descriptor = validate_descriptor(manifests[0], MANIFEST_TYPES) reference = (descriptor.get("annotations") or {}).get("org.opencontainers.image.ref.name") - require(reference in (None, f"localhost/lychd:{revision}"), "archive tag must be absent or the campaign tag") + require( + reference in (None, f"localhost/{repository}:{revision}"), "archive tag must be absent or the campaign tag" + ) manifest = json.loads(read_verified(archive, descriptor["digest"], size=descriptor.get("size"))) require(manifest.get("schemaVersion") == 2, "unsupported manifest schema") require(manifest.get("mediaType", next(iter(MANIFEST_TYPES))) in MANIFEST_TYPES, "unsupported manifest type") @@ -138,20 +142,36 @@ def sha256_file(path): def main(argv): - require(len(argv) in (3, 4), "usage: ARCHIVE REVISION OUTPUT_JSON [EXPECTED_IMAGE_ID]") + if argv and argv[0] == "--verify-tether": + require(len(argv) == 6, "usage: --verify-tether ARCHIVE REVISION IMAGE_ID MANIFEST_DIGEST ARCHIVE_SHA256") + _, raw_path, revision, image_id, manifest_digest, archive_sha256 = argv + path = Path(raw_path) + require(path.is_absolute() and path.is_file() and not path.is_symlink(), "ordinary absolute archive required") + require(sha256_file(path) == archive_sha256, "archive checksum differs") + identity = inspect_archive(path, revision, image_id, kind="tether") + require(identity["manifest_digest"] == manifest_digest, "archive manifest differs") + require(sha256_file(path) == archive_sha256, "archive changed during admission") + print(json.dumps(identity, sort_keys=True)) + return + kind = "tether" if argv and argv[0] == "--tether" else "core" + argv = argv[1:] if kind == "tether" else argv + require(len(argv) in (3, 4), "usage: [--tether] ARCHIVE REVISION OUTPUT_JSON [EXPECTED_IMAGE_ID]") archive = Path(argv[0]).resolve(strict=True) revision, output = argv[1], Path(argv[2]) require(archive.is_file() and not Path(argv[0]).is_symlink(), "archive must be a regular file") before = sha256_file(archive) - identity = inspect_archive(archive, revision, argv[3] if len(argv) == 4 else None) + identity = inspect_archive(archive, revision, argv[3] if len(argv) == 4 else None, kind=kind) require(sha256_file(archive) == before, "archive changed during admission") variables = { "lychd_image_archive": str(archive), "lychd_image_archive_sha256": before, "lychd_image_id": identity["image_id"], - "lychd_image_ref": f"localhost/lychd:{revision}", + "lychd_image_manifest_digest": identity["manifest_digest"], + "lychd_image_ref": f"localhost/{'lychd-tether' if kind == 'tether' else 'lychd'}:{revision}", "lychd_image_revision": revision, } + if kind == "tether": + variables = {key.replace("lychd_image_", "lychd_tether_image_"): value for key, value in variables.items()} # Create privately before writing; never follow or replace an existing output path. with os.fdopen(os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as handle: handle.write(json.dumps(variables, indent=2) + "\n") diff --git a/deployments/ansible/roles/lychd_image/tasks/main.yml b/deployments/ansible/roles/lychd_image/tasks/main.yml index f4d9ca56..5cfef87f 100644 --- a/deployments/ansible/roles/lychd_image/tasks/main.yml +++ b/deployments/ansible/roles/lychd_image/tasks/main.yml @@ -1,20 +1,40 @@ --- +- name: Require an explicit supported image namespace + ansible.builtin.assert: + that: lychd_image_kind in ['core', 'tether'] + +# Extra variables outrank include_role vars. Select from distinct receipt namespaces +# instead of aliasing Tether values onto the public Core image variables. +- name: Select the admitted artifact without overriding public receipt variables + ansible.builtin.set_fact: + _lychd_selected_image: >- + {{ {'archive': lychd_tether_image_archive, + 'archive_sha256': lychd_tether_image_archive_sha256, + 'id': lychd_tether_image_id, 'ref': lychd_tether_image_ref, + 'revision': lychd_tether_image_revision} + if lychd_image_kind == 'tether' else + {'archive': lychd_image_archive, + 'archive_sha256': lychd_image_archive_sha256, + 'id': lychd_image_id, 'ref': lychd_image_ref, + 'revision': lychd_image_revision} }} + - name: Validate the admitted image receipt and direct runtime SSH ansible.builtin.assert: that: - ansible_user == 'lychd' - not (ansible_become | default(false) | bool) - - lychd_image_revision is match('^[0-9a-f]{40}$') - - lychd_image_revision == lychd_source_revision - - lychd_image_ref == 'localhost/lychd:' + lychd_image_revision - - lychd_image_id is match('^[0-9a-f]{64}$') - - lychd_image_archive_sha256 is match('^[0-9a-f]{64}$') - - lychd_image_archive is match('^/[^\n]+$') + - _lychd_selected_image.revision is match('^[0-9a-f]{40}$') + - _lychd_selected_image.revision == lychd_source_revision + - lychd_image_kind in ['core', 'tether'] + - _lychd_selected_image.ref == ('localhost/lychd-tether:' if lychd_image_kind == 'tether' else 'localhost/lychd:') + _lychd_selected_image.revision + - _lychd_selected_image.id is match('^[0-9a-f]{64}$') + - _lychd_selected_image.archive_sha256 is match('^[0-9a-f]{64}$') + - _lychd_selected_image.archive is match('^/[^\n]+$') fail_msg: "Admit one OCI archive with admit_oci_archive.py and pass its image-vars.json; connect directly as lychd." - name: Observe whether the full-revision tag already exists ansible.builtin.command: - argv: [/usr/bin/podman, image, inspect, "{{ lychd_image_ref }}", --format, "{{ '{{' }}.Id{{ '}}' }}"] + argv: [/usr/bin/podman, image, inspect, "{{ _lychd_selected_image.ref }}", --format, "{{ '{{' }}.Id{{ '}}' }}"] register: lychd_image_existing changed_when: false failed_when: false @@ -22,17 +42,17 @@ - name: Refuse a different image under the campaign tag ansible.builtin.assert: - that: lychd_image_existing.rc != 0 or lychd_image_existing.stdout | trim == lychd_image_id + that: lychd_image_existing.rc != 0 or lychd_image_existing.stdout | trim == _lychd_selected_image.id fail_msg: >- - {{ lychd_image_ref }} already names image {{ lychd_image_existing.stdout | trim }}, not the admitted - {{ lychd_image_id }}. It is never retagged or removed automatically. + {{ _lychd_selected_image.ref }} already names image {{ lychd_image_existing.stdout | trim }}, not the admitted + {{ _lychd_selected_image.id }}. It is never retagged or removed automatically. - name: Stage, verify and load the admitted archive only when the image is absent when: lychd_image_existing.rc != 0 block: - name: Re-verify the controller archive checksum before transfer ansible.builtin.stat: - path: "{{ lychd_image_archive }}" + path: "{{ _lychd_selected_image.archive }}" follow: false checksum_algorithm: sha256 delegate_to: localhost @@ -45,11 +65,11 @@ that: - lychd_image_controller_archive.stat.isreg | default(false) - not (lychd_image_controller_archive.stat.islnk | default(true)) - - lychd_image_controller_archive.stat.checksum | default('') == lychd_image_archive_sha256 + - lychd_image_controller_archive.stat.checksum | default('') == _lychd_selected_image.archive_sha256 - name: Report the check-mode boundary for image loading ansible.builtin.debug: - msg: "Image {{ lychd_image_ref }} ({{ lychd_image_id }}) would be transferred and loaded." + msg: "Image {{ _lychd_selected_image.ref }} ({{ _lychd_selected_image.id }}) would be transferred and loaded." when: ansible_check_mode - name: Create the private staging directory @@ -65,14 +85,14 @@ - name: Copy the archive under the runtime account ansible.builtin.copy: - src: "{{ lychd_image_archive }}" - dest: "{{ lychd_image_staging_dir }}/lychd-{{ lychd_image_revision }}.oci.tar" + src: "{{ _lychd_selected_image.archive }}" + dest: "{{ lychd_image_staging_dir }}/lychd-{{ _lychd_selected_image.revision }}.oci.tar" mode: '0600' when: not ansible_check_mode - name: Hash the transferred archive on the host ansible.builtin.stat: - path: "{{ lychd_image_staging_dir }}/lychd-{{ lychd_image_revision }}.oci.tar" + path: "{{ lychd_image_staging_dir }}/lychd-{{ _lychd_selected_image.revision }}.oci.tar" follow: false checksum_algorithm: sha256 register: lychd_image_remote_archive @@ -80,25 +100,25 @@ - name: Refuse a transferred archive whose SHA-256 differs ansible.builtin.assert: - that: lychd_image_remote_archive.stat.checksum == lychd_image_archive_sha256 + that: lychd_image_remote_archive.stat.checksum == _lychd_selected_image.archive_sha256 when: not ansible_check_mode - name: Load the verified archive into rootless storage ansible.builtin.command: - argv: [/usr/bin/podman, load, --input, "{{ lychd_image_staging_dir }}/lychd-{{ lychd_image_revision }}.oci.tar"] + argv: [/usr/bin/podman, load, --input, "{{ lychd_image_staging_dir }}/lychd-{{ _lychd_selected_image.revision }}.oci.tar"] when: not ansible_check_mode changed_when: true - name: Observe the loaded image by its exact ID ansible.builtin.command: - argv: [/usr/bin/podman, image, inspect, "{{ lychd_image_id }}", --format, "{{ '{{' }}.Id{{ '}}' }}"] + argv: [/usr/bin/podman, image, inspect, "{{ _lychd_selected_image.id }}", --format, "{{ '{{' }}.Id{{ '}}' }}"] register: lychd_image_loaded changed_when: false when: not ansible_check_mode - name: Observe the full-revision tag after loading ansible.builtin.command: - argv: [/usr/bin/podman, image, inspect, "{{ lychd_image_ref }}", --format, "{{ '{{' }}.Id{{ '}}' }}"] + argv: [/usr/bin/podman, image, inspect, "{{ _lychd_selected_image.ref }}", --format, "{{ '{{' }}.Id{{ '}}' }}"] register: lychd_image_tagged changed_when: false failed_when: false @@ -106,13 +126,13 @@ - name: Name the verified image with the full revision only when the archive carried no tag ansible.builtin.command: - argv: [/usr/bin/podman, tag, "{{ lychd_image_id }}", "{{ lychd_image_ref }}"] + argv: [/usr/bin/podman, tag, "{{ _lychd_selected_image.id }}", "{{ _lychd_selected_image.ref }}"] when: not ansible_check_mode and lychd_image_tagged.rc != 0 changed_when: true - name: Remove the staged archive copy after a verified load ansible.builtin.file: - path: "{{ lychd_image_staging_dir }}/lychd-{{ lychd_image_revision }}.oci.tar" + path: "{{ lychd_image_staging_dir }}/lychd-{{ _lychd_selected_image.revision }}.oci.tar" state: absent when: not ansible_check_mode @@ -122,7 +142,7 @@ - /usr/bin/podman - image - inspect - - "{{ lychd_image_ref }}" + - "{{ _lychd_selected_image.ref }}" - --format - "{{ '{{' }}.Id{{ '}}' }} {{ '{{' }}index .Labels \"org.opencontainers.image.revision\"{{ '}}' }}" register: lychd_image_final @@ -132,6 +152,6 @@ - name: Require the exact admitted image identity ansible.builtin.assert: - that: lychd_image_final.stdout | trim == lychd_image_id + ' ' + lychd_image_revision + that: lychd_image_final.stdout | trim == _lychd_selected_image.id + ' ' + _lychd_selected_image.revision fail_msg: "Loaded image identity differs from the admitted archive." when: not ansible_check_mode or lychd_image_existing.rc == 0 diff --git a/deployments/ansible/tests/test_app_probe.py b/deployments/ansible/tests/test_app_probe.py index 7ec9d967..3e51e779 100644 --- a/deployments/ansible/tests/test_app_probe.py +++ b/deployments/ansible/tests/test_app_probe.py @@ -46,6 +46,27 @@ def test_fixed_cli_and_full_image_id_are_forwarded(self): ], ) + def test_protocol_two_requires_explicit_profile_and_full_tether_identity(self): + request = { + "revision": REVISION, + "image_id": IMAGE, + "profile": "tether-container-v1", + "intent": "/secure/intent.json", + "tether_image_id": "c" * 64, + } + _, argv = probe.native_command("classify", request, "/home/lychd") + self.assertIn("--deployment-profile", argv) + self.assertIn("--deployment-intent", argv) + self.assertIn("--deployment-tether-image-id", argv) + document = dict(response(), version=2, profile="tether-container-v1") + self.assertIs(probe.validate_response(document, "tether-container-v1"), document) + with self.assertRaises(probe.Refusal): + probe.validate_response(document) + with self.assertRaises(probe.Refusal): + probe.validate_response(response(), "tether-container-v1") + with self.assertRaises(probe.Refusal): + probe.native_command("classify", dict(request, tether_image_id="moving"), "/home/lychd") + def test_configure_and_arbitrary_commands_do_not_exist(self): for command in ("configure", "start", "shell"): with self.assertRaises(probe.Refusal): @@ -120,3 +141,10 @@ def test_no_core_or_human_render_parser_is_in_controller(self): if __name__ == "__main__": unittest.main() + + +class TetherPreparationTests(unittest.TestCase): + def test_waiting_keys_is_actionable_protocol_two(self): + document = response("prepared_waiting_keys") + document.update(version=2, profile="tether-container-v1") + self.assertEqual(probe.validate_response(document, "tether-container-v1")["next_action"], "import-key") diff --git a/deployments/ansible/tests/test_deploy_policy.py b/deployments/ansible/tests/test_deploy_policy.py index ab7657c4..f5a7f3cf 100644 --- a/deployments/ansible/tests/test_deploy_policy.py +++ b/deployments/ansible/tests/test_deploy_policy.py @@ -7,6 +7,7 @@ import yaml from jinja2 import Environment, StrictUndefined +from jinja2.nativetypes import NativeEnvironment ROOT = Path(__file__).parents[1] FORBIDDEN = re.compile( @@ -58,12 +59,26 @@ def test_start_is_the_cli_and_requires_explicit_authorization(self): def test_application_effects_follow_classification_and_dry_runs(self): names = [task.get("name", "") for task in walk(load("roles/lychd_app/tasks/apply.yml"))] - commands = [ - task["ansible.builtin.command"]["argv"][1:] - for task in walk(load("roles/lychd_app/tasks/apply.yml")) - if "ansible.builtin.command" in task - and str(task["ansible.builtin.command"]["argv"][0]).endswith("lychd_app_cli }}") - ] + context = { + "lychd_app_cli": "{{ lychd_app_cli }}", + "lychd_app_image_ref": "{{ lychd_app_image_ref }}", + "lychd_source_revision": "{{ lychd_source_revision }}", + "lychd_image_id": "{{ lychd_image_id }}", + "lychd_app_init_profile_args": [], + "lychd_app_bind_args": ["bind"], + "lychd_app_status_args": [], + } + commands = [] + for task in walk(load("roles/lychd_app/tasks/apply.yml")): + if "ansible.builtin.command" not in task or "lychd_app_profile == 'tether-container-v1'" in str( + task.get("when", "") + ): + continue + argv = task["ansible.builtin.command"]["argv"] + if isinstance(argv, str): + argv = NativeEnvironment(undefined=StrictUndefined).from_string(argv).render(**context) + if argv[0] == "{{ lychd_app_cli }}": + commands.append(argv[1:]) self.assertEqual( commands, [ @@ -87,6 +102,52 @@ def test_application_effects_follow_classification_and_dry_runs(self): names.index("Initialize the Codex and Crypt"), ) + def test_tether_stages_public_inputs_before_original_init_and_keeps_core_bootstrap(self): + tasks = list(walk(load("roles/lychd_app/tasks/apply.yml"))) + names = [task.get("name") for task in tasks] + self.assertLess( + names.index("Stage public Tether intent and verified image before original initialization"), + names.index("Preview initialization"), + ) + self.assertLess( + names.index("Start through the CLI's guarded inactive bootstrap"), + names.index("Activate and verify Tether through native lifecycle after the guarded Core bootstrap"), + ) + stage = list(walk(load("roles/lychd_app/tasks/tether_stage.yml"))) + self.assertTrue( + any(task.get("ansible.builtin.include_role", {}).get("name") == "lychd_image" for task in stage) + ) + commands = [task["ansible.builtin.command"]["argv"] for task in stage if "ansible.builtin.command" in task] + self.assertEqual(len(commands), 2) + self.assertIn("admit_oci_archive.py", commands[0][3]) + self.assertEqual(commands[0][4], "--verify-tether") + self.assertIn("tether_intent.py", commands[1][3]) + self.assertEqual(commands[1][4], "stage") + self.assertTrue(all(command[:3] == ["/usr/bin/python3", "-I", "-c"] for command in commands)) + copies = [task["ansible.builtin.copy"] for task in stage if "ansible.builtin.copy" in task] + self.assertEqual(len(copies), 1) + self.assertFalse(copies[0]["force"]) + self.assertEqual(copies[0]["src"], "{{ lychd_tether_intent }}") + self.assertFalse(any("receipt" in str(task).lower() for task in stage)) + + def test_tether_host_udp_prerequisite_precedes_native_apply(self): + plays = load("playbooks/deploy.yml") + names = [play["name"] for play in plays] + stage = names.index("Stage the exact source CLI, image and application") + firewall = names.index("Admit only declared host prerequisites before native initialization") + apply = names.index("Apply native application lifecycle after host prerequisites") + self.assertLess(stage, firewall) + self.assertLess(firewall, apply) + self.assertFalse(plays[stage]["become"]) + self.assertTrue(plays[firewall]["become"]) + self.assertFalse(plays[apply]["become"]) + task = load("roles/lychd_app/tasks/firewall.yml")[1] + self.assertEqual(task["when"], "(lychd_tether_campaign.stdout | from_json).offered") + self.assertFalse(task["check_mode"]) + self.assertIn("'plan' if ansible_check_mode", task["ansible.builtin.command"]["stdin"]) + self.assertIn("normalized_sha256", task["ansible.builtin.command"]["stdin"]) + self.assertNotIn("listen_port", task["ansible.builtin.command"]["stdin"]) + def test_deploy_requires_one_named_target_before_any_effect(self): plays = load("playbooks/deploy.yml") admission = plays[0]["tasks"][0]["ansible.builtin.assert"]["that"] @@ -269,11 +330,43 @@ def test_runtime_plays_never_escalate(self): self.assertFalse(play["become"], path) self.assertFalse(play["vars"]["ansible_become"], path) + def test_image_namespace_renders_independent_receipts_with_extra_var_precedence(self): + tasks = list(walk(load("roles/lychd_image/tasks/main.yml"))) + select = next( + task["ansible.builtin.set_fact"]["_lychd_selected_image"] + for task in tasks + if "ansible.builtin.set_fact" in task + ) + extra_vars = {} + for prefix, label in [("lychd_image_", "core"), ("lychd_tether_image_", "tether")]: + extra_vars.update( + {prefix + name: label + "-" + name for name in ["archive", "archive_sha256", "id", "ref", "revision"]} + ) + environment = NativeEnvironment(undefined=StrictUndefined) + for kind in ["core", "tether", "core"]: + role_vars = {"lychd_image_kind": kind, "lychd_image_ref": "attempted-role-override"} + effective = {**role_vars, **extra_vars} + selected = environment.from_string(select).render(**effective) + self.assertEqual( + selected, {name: kind + "-" + name for name in ["archive", "archive_sha256", "id", "ref", "revision"]} + ) + effective["_lychd_selected_image"] = selected + inspect = next( + task["ansible.builtin.command"]["argv"] + for task in tasks + if task["name"] == "Observe whether the full-revision tag already exists" + ) + self.assertEqual(environment.from_string(inspect[3]).render(**effective), kind + "-ref") + copy = next(task["ansible.builtin.copy"] for task in tasks if "ansible.builtin.copy" in task) + self.assertEqual(environment.from_string(copy["src"]).render(**effective), kind + "-archive") + def test_image_role_verifies_checksum_and_identity(self): tasks = list(walk(load("roles/lychd_image/tasks/main.yml"))) text = yaml.safe_dump(tasks) - self.assertIn("lychd_image_remote_archive.stat.checksum == lychd_image_archive_sha256", text) - self.assertIn("lychd_image_final.stdout | trim == lychd_image_id + ' ' + lychd_image_revision", text) + self.assertIn("lychd_image_remote_archive.stat.checksum == _lychd_selected_image.archive_sha256", text) + self.assertIn( + "lychd_image_final.stdout | trim == _lychd_selected_image.id + ' ' + _lychd_selected_image.revision", text + ) loads = [task for task in tasks if "load" in task.get("ansible.builtin.command", {}).get("argv", [])] checksum = next( i @@ -300,7 +393,11 @@ def test_start_wait_requires_successful_native_running_assessment(self): ) environment = Environment() environment.filters["from_json"] = json.loads - check = environment.compile_expression(wait["until"]) + raw_check = environment.compile_expression(wait["until"]) + + def check(**kwargs): + return raw_check(lychd_app_protocol=1, **kwargs) + self.assertFalse( check( lychd_app_units={ @@ -338,9 +435,11 @@ def test_start_wait_requires_successful_native_running_assessment(self): def test_read_only_classification_checks_source_before_running_cli(self): tasks = load("roles/lychd_app/tasks/classify.yml") commands = [task["ansible.builtin.command"]["argv"] for task in tasks if "ansible.builtin.command" in task] - self.assertEqual(len(commands), 2) + self.assertEqual(len(commands), 3) self.assertIn("source_guard.py", commands[0][3]) - self.assertIn("app_probe.py", commands[1][3]) + self.assertIn("tether_intent.py", commands[1][3]) + self.assertEqual(commands[1][4], "verify") + self.assertIn("app_probe.py", commands[2][3]) def test_fresh_source_install_is_noneditable_and_completed_environments_are_preserved(self): tasks = load("roles/lychd_source/tasks/main.yml") diff --git a/deployments/ansible/tests/test_host_readonly.py b/deployments/ansible/tests/test_host_readonly.py new file mode 100644 index 00000000..26f267d4 --- /dev/null +++ b/deployments/ansible/tests/test_host_readonly.py @@ -0,0 +1,157 @@ +"""Actual Ansible precedence cannot promote host-prerequisite verification.""" + +import json +import unittest +from pathlib import Path + +import yaml +from ansible.parsing.dataloader import DataLoader +from ansible.playbook.play import Play +from ansible.plugins.loader import init_plugin_loader +from ansible.vars.manager import VariableManager +from jinja2 import Environment, StrictUndefined + +ROOT = Path(__file__).parents[1] +ENV = Environment(undefined=StrictUndefined) +ENV.filters["from_json"] = json.loads +ENV.filters["to_json"] = json.dumps + + +def tasks_in(blocks): + for block in blocks: + for task in block.block: + if hasattr(task, "block"): + yield from tasks_in([task]) + else: + yield task + + +def compiled(entries, obsolete_action): + loader = DataLoader() + loader.set_basedir(str(ROOT)) + variables = VariableManager(loader=loader) + # The obsolete action names intentionally remain higher-precedence -e input. + variables._extra_vars = loader.load( + json.dumps( + { + "lychd_app_profile": "tether-container-v1", + "lychd_tether_kernel_action": obsolete_action, + "lychd_tether_firewall_action": obsolete_action, + "lychd_tether_firewall_zone": "public", + "lychd_tether_campaign": { + "stdout": json.dumps({"state": "staged", "offered": True, "normalized_sha256": "a" * 64}) + }, + } + ) + ) + play = Play.load( + { + "hosts": "localhost", + "gather_facts": False, + # Static imports compile the same literal role entry points without executing a task. + "tasks": [{"ansible.builtin.import_role": {"name": "lychd_app", "tasks_from": entry}} for entry in entries], + }, + loader=loader, + variable_manager=variables, + ) + return play, variables, list(tasks_in(play.compile())) + + +def firewall_request(task, context): + return json.loads(ENV.from_string(task.args["stdin"]).render(**context)) + + +class HostReadOnlyTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + init_plugin_loader() + + def test_extra_actions_cannot_add_mutators_to_actual_verification_entrypoints(self): + plays = yaml.safe_load((ROOT / "playbooks/app-verify.yml").read_text()) + entries = [row["ansible.builtin.include_role"]["tasks_from"] for row in plays[1]["tasks"]] + self.assertEqual(entries, ["tether_kernel_verify", "firewall_verify"]) + self.assertTrue(plays[1]["become"]) + for obsolete_action in ("prepare", "verify", "unknown"): + for check in (False, True): + with self.subTest(action=obsolete_action, check=check): + play, variables, tasks = compiled(entries, obsolete_action) + 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(commands[0].args["argv"], ["/usr/bin/id", "-u"]) + self.assertEqual(commands[1].args["argv"], ["/usr/bin/uname", "-r"]) + self.assertEqual( + commands[2].args["argv"], + [ + "/usr/sbin/modinfo", + "-k", + "{{ lychd_tether_kernel_release.stdout | trim }}", + "-F", + "filename", + "wireguard", + ], + ) + for task in commands[:3]: + 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) + firewall = commands[-1] + context = variables.get_vars(play=play, task=firewall) + context["ansible_check_mode"] = check + self.assertEqual( + firewall_request(firewall, context), + {"mode": "verify", "zone": "public", "normalized_sha256": "a" * 64}, + ) + self.assertEqual(firewall.args["argv"][:3], ["/usr/bin/python3", "-I", "-c"]) + self.assertEqual( + firewall.args["argv"][3], + "{{ lookup('ansible.builtin.file', role_path + '/files/tether_firewall.py') }}", + ) + self.assertFalse(firewall.check_mode) + checks = { + task.name: task for task in tasks if task.action.removeprefix("ansible.builtin.") == "assert" + } + for name in ( + "Require the persistent Tether entry during read-only verification", + "Require observed WireGuard during read-only verification", + ): + self.assertEqual(checks[name].when, []) + + 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(load.args["argv"], ["/usr/sbin/modprobe", "--use-blacklist", "wireguard"]) + self.assertEqual(load.args["creates"], "/sys/module/wireguard") + self.assertNotIn("check_mode", load.get_ds()) + self.assertEqual(load.changed_when, []) + self.assertEqual(load.when, []) + copies = [task for task in tasks if task.action.removeprefix("ansible.builtin.") == "copy"] + self.assertEqual(len(copies), 1) + self.assertEqual(copies[0].args["dest"], "/etc/modules-load.d/lychd-tether.conf") + self.assertFalse(copies[0].args["force"]) + firewall = commands[-1] + context = variables.get_vars(play=play, task=firewall) + for check in (False, True): + context["ansible_check_mode"] = check + self.assertEqual(firewall_request(firewall, context)["mode"], "plan" if check else "prepare") + + def test_deploy_uses_literal_entrypoints_and_no_action_aliases(self): + plays = yaml.safe_load((ROOT / "playbooks/deploy.yml").read_text()) + entries = [] + for play in plays: + for task in play.get("tasks", []): + entry = task.get("ansible.builtin.include_role", {}).get("tasks_from") + if entry in {"tether_kernel_prepare", "tether_kernel_verify", "firewall", "firewall_verify"}: + entries.append(entry) + self.assertNotIn("vars", task) + self.assertEqual(entries, ["tether_kernel_prepare", "firewall", "tether_kernel_verify", "firewall_verify"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/deployments/ansible/tests/test_image_namespace.py b/deployments/ansible/tests/test_image_namespace.py new file mode 100644 index 00000000..2f3497bb --- /dev/null +++ b/deployments/ansible/tests/test_image_namespace.py @@ -0,0 +1,111 @@ +"""Actual Ansible precedence regression for independent Core/Tether OCI admission.""" + +import json +import re +import unittest +from pathlib import Path + +from ansible.parsing.dataloader import DataLoader +from ansible.playbook.play import Play +from ansible.plugins.loader import init_plugin_loader +from ansible.vars.manager import VariableManager +from jinja2 import StrictUndefined +from jinja2.nativetypes import NativeEnvironment + +ROOT = Path(__file__).parents[1] +REVISION = "a" * 40 + + +def render(value, context): + environment = NativeEnvironment(undefined=StrictUndefined) + environment.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None + environment.filters["bool"] = bool + if isinstance(value, str): + return environment.from_string(value).render(**context) + if isinstance(value, list): + return [render(item, context) for item in value] + if isinstance(value, dict): + return {key: render(item, context) for key, item in value.items()} + return value + + +def tasks_in(blocks): + for block in blocks: + for task in block.block: + if hasattr(task, "block"): + yield from tasks_in([task]) + else: + yield task + + +class ImageNamespaceTests(unittest.TestCase): + def test_real_extra_vars_cannot_replace_tether_artifact_with_core_artifact(self): + init_plugin_loader() + receipts = {"lychd_source_revision": REVISION, "ansible_user": "lychd", "ansible_become": False} + expected = {} + for kind, prefix, repository, digit in [ + ("core", "lychd_image_", "lychd", "b"), + ("tether", "lychd_tether_image_", "lychd-tether", "c"), + ]: + expected[kind] = { + "archive": "/controller/" + kind + ".oci.tar", + "archive_sha256": digit * 64, + "id": ("d" if kind == "core" else "e") * 64, + "ref": "localhost/" + repository + ":" + REVISION, + "revision": REVISION, + } + receipts.update({prefix + field: value for field, value in expected[kind].items()}) + for kind in ("core", "tether", "core"): + with self.subTest(kind=kind): + loader = DataLoader() + loader.set_basedir(str(ROOT)) + variables = VariableManager(loader=loader) + # Exactly the values provided by -e @core-vars.json -e @tether-vars.json. + variables._extra_vars = loader.load(json.dumps(receipts)) + role_vars = {"lychd_image_kind": kind} + if kind == "tether": + # Reproduce the failing invocation: even attempted role aliases lose to -e. + role_vars.update({"lychd_image_" + field: value for field, value in expected["tether"].items()}) + play = Play.load( + { + "hosts": "localhost", + "gather_facts": False, + "roles": [{"role": "lychd_image", "vars": role_vars}], + }, + loader=loader, + variable_manager=variables, + ) + role = play.get_roles()[0] + tasks = list(tasks_in(role.compile(play=play))) + selection = next(task for task in tasks if task.name.startswith("Select the admitted artifact")) + context = variables.get_vars(play=play, task=selection) + self.assertEqual(context["lychd_image_ref"], expected["core"]["ref"]) + selected = render(selection.args["_lychd_selected_image"], context) + self.assertEqual(selected, expected[kind]) + context["_lychd_selected_image"] = selected + context.update(ansible_check_mode=False, lychd_image_existing={"rc": 1, "stdout": ""}) + by_name = {task.name: task for task in tasks} + first = render(by_name["Observe whether the full-revision tag already exists"].args, context) + self.assertEqual(first["argv"][3], expected[kind]["ref"]) + copied = render(by_name["Copy the archive under the runtime account"].args, context) + self.assertEqual(copied["src"], expected[kind]["archive"]) + exact_id = render(by_name["Observe the loaded image by its exact ID"].args, context) + self.assertEqual(exact_id["argv"][3], expected[kind]["id"]) + final = render(by_name["Read the final image identity and revision label"].args, context) + self.assertEqual(final["argv"][3], expected[kind]["ref"]) + context.update( + lychd_image_remote_archive={"stat": {"checksum": expected[kind]["archive_sha256"]}}, + lychd_image_final={"stdout": expected[kind]["id"] + " " + REVISION}, + ) + for name in ( + "Validate the admitted image receipt and direct runtime SSH", + "Refuse a transferred archive whose SHA-256 differs", + "Require the exact admitted image identity", + ): + checks = by_name[name].args["that"] + for expression in checks if isinstance(checks, list) else [checks]: + self.assertTrue(render("{{ " + expression + " }}", context), name) + if kind == "tether": + context["lychd_image_remote_archive"]["stat"]["checksum"] = expected["core"]["archive_sha256"] + expression = by_name["Refuse a transferred archive whose SHA-256 differs"].args["that"] + self.assertFalse(render("{{ " + expression + " }}", context)) diff --git a/deployments/ansible/tests/test_oci_archive.py b/deployments/ansible/tests/test_oci_archive.py index faab1c8c..cea3f516 100644 --- a/deployments/ansible/tests/test_oci_archive.py +++ b/deployments/ansible/tests/test_oci_archive.py @@ -79,6 +79,35 @@ def test_exact_archive_is_admitted_with_its_image_id(self): identity = admit.inspect_archive(path, REVISION, image_id) self.assertEqual(identity["image_id"], image_id) + def test_tether_archive_has_a_separate_explicit_receipt(self): + path, image_id = self.build(reference=f"localhost/lychd-tether:{REVISION}") + with self.assertRaises(admit.ArchiveRefusal): + admit.inspect_archive(path, REVISION) + self.assertEqual(admit.inspect_archive(path, REVISION, kind="tether")["image_id"], image_id) + output = self.root / "tether-vars.json" + with contextlib.redirect_stdout(io.StringIO()): + admit.main(["--tether", str(path), REVISION, str(output), image_id]) + document = json.loads(output.read_text()) + self.assertEqual(document["lychd_tether_image_id"], image_id) + self.assertNotIn("lychd_image_id", document) + + def test_manifest_receipt_is_reverified_without_writing(self): + path, image_id = self.build(reference=f"localhost/lychd-tether:{REVISION}") + identity = admit.inspect_archive(path, REVISION, kind="tether") + arguments = [ + "--verify-tether", + str(path), + REVISION, + image_id, + identity["manifest_digest"], + admit.sha256_file(path), + ] + with contextlib.redirect_stdout(io.StringIO()): + admit.main(arguments) + arguments[4] = "sha256:" + "f" * 64 + with self.assertRaisesRegex(admit.ArchiveRefusal, "manifest differs"): + admit.main(arguments) + def test_tampered_layer_is_refused(self): path, _ = self.build(corrupt=True) with self.assertRaisesRegex(admit.ArchiveRefusal, "digest mismatch"): diff --git a/deployments/ansible/tests/test_tether_firewall.py b/deployments/ansible/tests/test_tether_firewall.py new file mode 100644 index 00000000..19af9507 --- /dev/null +++ b/deployments/ansible/tests/test_tether_firewall.py @@ -0,0 +1,193 @@ +"""Host-only UDP prerequisite tests; no host commands or Core imports.""" + +import importlib.util +import json +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location( + "tether_firewall", Path(__file__).parents[1] / "roles/lychd_app/files/tether_firewall.py" +) +helper = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(helper) + + +class Firewall: + def __init__(self): + self.calls = [] + self.rows = [{"ifname": "eth0", "addr_info": [{"family": "inet", "local": "192.0.2.7"}]}] + self.zone = "public" + self.permanent_zone = "public" + self.managed = "yes" + self.saved_zone = "public" + self.default = "public" + self.ports = {True: set(), False: set()} + + def __call__(self, tool, *args): + self.calls.append((tool, *args)) + if tool == "ip": + assert args == ("-j", "-4", "address", "show") + return 0, json.dumps(self.rows) + if tool == "nmcli": + if args == ("--get-values", "GENERAL.NM-MANAGED", "device", "show", "eth0"): + return 0, self.managed + if args == ("--get-values", "GENERAL.CON-UUID", "device", "show", "eth0"): + return 0, "11111111-1111-1111-1111-111111111111" + assert args == ( + "--get-values", + "connection.zone", + "connection", + "show", + "uuid", + "11111111-1111-1111-1111-111111111111", + ) + return 0, self.saved_zone + assert tool == "firewall-cmd" + permanent = "--permanent" in args + if args[-1] == "--get-zones": + return 0, "public internal" + if args == ("--get-zone-of-interface=eth0",): + return 0, self.zone + if args == ("--permanent", "--get-zone-of-interface=eth0"): + return 0, self.permanent_zone + if args == ("--get-default-zone",): + return 0, self.default + if args[-1].startswith("--query-port="): + assert "--zone=public" in args + present = args[-1].split("=", 1)[1] in self.ports[permanent] + return (0, "yes") if present else (1, "no") + if args[-1].startswith("--add-port="): + assert "--zone=public" in args + self.ports[permanent].add(args[-1].split("=", 1)[1]) + return 0, "success" + raise AssertionError(args) + + def mutations(self): + return [row for row in self.calls if any("--add-port=" in word for word in row)] + + +class HostUDPTests(unittest.TestCase): + def setUp(self): + self.firewall = Firewall() + self.intent = {"rune": {"offered": {"listen_address": "192.0.2.7", "listen_port": 51820}}} + + def test_first_apply_adds_only_exact_missing_entries_second_is_noop(self): + self.firewall.ports[True].add("22/tcp") + self.firewall.ports[False].add("22/tcp") + first = helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertTrue(first["changed"]) + self.assertEqual( + self.firewall.mutations(), + [ + ("firewall-cmd", "--permanent", "--zone=public", "--add-port=51820/udp"), + ("firewall-cmd", "--zone=public", "--add-port=51820/udp"), + ], + ) + second = helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertFalse(second["changed"]) + self.assertEqual(len(self.firewall.mutations()), 2) + self.assertEqual(self.firewall.ports, {True: {"22/tcp", "51820/udp"}, False: {"22/tcp", "51820/udp"}}) + self.assertEqual(second["provider_firewall"], "not_observed") + + def test_check_mode_plans_both_without_effects(self): + report = helper.reconcile(self.intent, "public", "plan", self.firewall) + self.assertEqual(report["planned"], ["permanent", "runtime"]) + self.assertFalse(report["changed"]) + self.assertEqual(self.firewall.mutations(), []) + + def test_verification_refuses_missing_rule_without_repair(self): + with self.assertRaisesRegex(helper.PrerequisiteError, "missing"): + helper.reconcile(self.intent, "public", "verify", self.firewall) + self.assertEqual(self.firewall.mutations(), []) + + def test_unbound_interface_uses_explicit_default_zone_observation(self): + for unbound in ("", "no zone"): + with self.subTest(unbound=unbound): + self.firewall.zone = unbound + helper.reconcile(self.intent, "public", "plan", self.firewall) + self.assertIn(("firewall-cmd", "--get-default-zone"), self.firewall.calls) + + def test_zone_mismatch_never_mutates(self): + self.firewall.zone = "internal" + with self.assertRaisesRegex(helper.PrerequisiteError, "differs"): + helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertEqual(self.firewall.mutations(), []) + + def test_missing_or_duplicate_address_is_refused(self): + for rows in ( + [], + self.firewall.rows * 2, + self.firewall.rows + [{"ifname": "eth1", "addr_info": self.firewall.rows[0]["addr_info"]}], + ): + with self.subTest(rows=rows), self.assertRaisesRegex(helper.PrerequisiteError, "exactly once"): + self.firewall.rows = rows + helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertEqual(self.firewall.mutations(), []) + + def test_wildcard_or_missing_explicit_zone_is_refused_before_commands(self): + for address, zone in [("0.0.0.0", "public"), ("127.0.0.1", "public"), ("192.0.2.7", "")]: + self.intent["rune"]["offered"]["listen_address"] = address + with self.assertRaises(helper.PrerequisiteError): + helper.reconcile(self.intent, zone, "prepare", self.firewall) + self.assertEqual(self.firewall.calls, []) + + def test_no_offered_network_does_not_touch_firewall(self): + result = helper.reconcile({"rune": {"offered": None}}, "", "prepare", self.firewall) + self.assertEqual(result["state"], "not_required") + self.assertEqual(self.firewall.calls, []) + + def test_unknown_query_exit_is_not_treated_as_missing(self): + def command(tool, *args): + if args[-1].startswith("--query-port="): + return 252, "error" + return self.firewall(tool, *args) + + with self.assertRaisesRegex(helper.PrerequisiteError, "query"): + helper.reconcile(self.intent, "public", "prepare", command) + self.assertEqual(self.firewall.mutations(), []) + + def test_already_permanent_adds_runtime_only(self): + self.firewall.ports[True].add("51820/udp") + helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertEqual(self.firewall.mutations(), [("firewall-cmd", "--zone=public", "--add-port=51820/udp")]) + + def test_zone_drift_between_observation_and_addition_refuses_effect(self): + observations = 0 + + def command(tool, *args): + nonlocal observations + if args == ("--get-zone-of-interface=eth0",): + observations += 1 + if observations > 1: + return 0, "internal" + return self.firewall(tool, *args) + + with self.assertRaisesRegex(helper.PrerequisiteError, "differs"): + helper.reconcile(self.intent, "public", "prepare", command) + self.assertEqual(self.firewall.mutations(), []) + + def test_permanent_interface_zone_mismatch_refuses_all_additions(self): + self.firewall.permanent_zone = "drop" + with self.assertRaisesRegex(helper.PrerequisiteError, "permanent zone differs"): + helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertEqual(self.firewall.mutations(), []) + + def test_networkmanager_saved_zone_mismatch_refuses_all_additions(self): + self.firewall.saved_zone = "internal" + with self.assertRaisesRegex(helper.PrerequisiteError, "NetworkManager connection zone differs"): + helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertEqual(self.firewall.mutations(), []) + + def test_networkmanager_default_and_permanent_default_are_observed(self): + self.firewall.permanent_zone = "no zone" + self.firewall.saved_zone = "" + helper.reconcile(self.intent, "public", "plan", self.firewall) + self.assertIn(("firewall-cmd", "--permanent", "--get-zone-of-interface=eth0"), self.firewall.calls) + self.assertEqual(self.firewall.calls.count(("firewall-cmd", "--get-default-zone")), 2) + self.assertEqual(self.firewall.mutations(), []) + + def test_unmanaged_interface_refuses_unknown_persistent_ownership(self): + self.firewall.managed = "no" + with self.assertRaisesRegex(helper.PrerequisiteError, "NetworkManager-managed"): + helper.reconcile(self.intent, "public", "prepare", self.firewall) + self.assertEqual(self.firewall.mutations(), []) diff --git a/deployments/ansible/tests/test_tether_intent.py b/deployments/ansible/tests/test_tether_intent.py new file mode 100644 index 00000000..b8bd8a00 --- /dev/null +++ b/deployments/ansible/tests/test_tether_intent.py @@ -0,0 +1,119 @@ +"""Controller-only imported digest and immutable campaign tests.""" + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location( + "tether_intent", Path(__file__).parents[1] / "roles/lychd_app/files/tether_intent.py" +) +helper = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(helper) + + +class IntentTests(unittest.TestCase): + def setUp(self): + self.document = { + "rune": {"quadlet": {"image": helper.REPOSITORY + "@sha256:" + "a" * 64}, "other": {"unchanged": [1, 2]}}, + "version": 1, + } + self.raw = json.dumps(self.document).encode() + self.request = { + "source_sha256": helper.sha(self.raw), + "archive_sha256": "b" * 64, + "archive_manifest_digest": "sha256:" + "a" * 64, + "image_id": "c" * 64, + "revision": "d" * 40, + } + self.row = { + "Id": "c" * 64, + "Digest": "sha256:" + "e" * 64, + "Labels": {"org.opencontainers.image.revision": "d" * 40}, + } + self.calls = [] + + def inspect(self, reference): + self.calls.append(reference) + return self.row + + def test_import_conversion_changes_only_image_and_retains_both_hashes(self): + raw, facts = helper.normalize(self.raw, self.request, self.inspect) + normalized = json.loads(raw) + image = normalized["rune"]["quadlet"].pop("image") + original = json.loads(self.raw) + original["rune"]["quadlet"].pop("image") + self.assertEqual(normalized, original) + self.assertEqual(image, helper.REPOSITORY + "@" + self.row["Digest"]) + self.assertEqual(self.calls, ["c" * 64, image]) + self.assertEqual(facts["source_sha256"], helper.sha(self.raw)) + self.assertEqual(facts["normalized_sha256"], helper.sha(raw)) + + def test_wrong_artifact_is_refused_before_image_lookup(self): + self.document["rune"]["quadlet"]["image"] = helper.REPOSITORY + "@sha256:" + "f" * 64 + raw = json.dumps(self.document).encode() + self.request["source_sha256"] = helper.sha(raw) + with self.assertRaises(helper.Refusal): + helper.normalize(raw, self.request, self.inspect) + self.assertEqual(self.calls, []) + + def test_resolved_digest_must_name_same_image(self): + count = 0 + + def inspect(_reference): + nonlocal count + count += 1 + return self.row if count == 1 else {**self.row, "Id": "f" * 64} + + with self.assertRaises(helper.Refusal): + helper.normalize(self.raw, self.request, inspect) + + def test_changed_revision_or_id_refused(self): + for field, value in [("Id", "f" * 64), ("Labels", {})]: + with self.subTest(field=field), self.assertRaises(helper.Refusal): + helper.normalize(self.raw, self.request, lambda _ref: {**self.row, field: value}) + + def test_campaign_is_idempotent_and_drift_never_rewritten(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "tether-intent.source.json" + source.write_bytes(self.raw) + source.chmod(0o600) + first = helper.operate("stage", self.request, root, self.inspect) + self.assertTrue(first["changed"]) + self.assertFalse(helper.operate("stage", self.request, root, self.inspect)["changed"]) + self.assertFalse(helper.operate("verify", self.request, root, self.inspect)["changed"]) + target = root / "tether-intent.json" + original = target.read_bytes() + self.row["Digest"] = "sha256:" + "f" * 64 + with self.assertRaises(helper.Refusal): + helper.operate("stage", self.request, root, self.inspect) + self.assertEqual(target.read_bytes(), original) + + def test_truncated_existing_campaign_is_not_replaced(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "tether-intent.source.json" + source.write_bytes(self.raw) + source.chmod(0o600) + target = root / "tether-intent.json" + target.write_bytes(b"{") + target.chmod(0o600) + with self.assertRaises(helper.Refusal): + helper.operate("stage", self.request, root, self.inspect) + self.assertEqual(target.read_bytes(), b"{") + + def test_retry_finishes_only_missing_campaign_receipt(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "tether-intent.source.json" + source.write_bytes(self.raw) + source.chmod(0o600) + normalized, _facts = helper.normalize(self.raw, self.request, self.inspect) + helper.publish(root / "tether-intent.json", normalized) + observed = helper.operate("verify", self.request, root, self.inspect) + self.assertEqual(observed["state"], "pending_receipt") + self.assertTrue(helper.operate("stage", self.request, root, self.inspect)["changed"]) + self.assertEqual((root / "tether-intent.json").read_bytes(), normalized) + self.assertEqual(helper.operate("verify", self.request, root, self.inspect)["state"], "staged") diff --git a/deployments/ansible/tests/test_tether_kernel.py b/deployments/ansible/tests/test_tether_kernel.py new file mode 100644 index 00000000..34c7a24a --- /dev/null +++ b/deployments/ansible/tests/test_tether_kernel.py @@ -0,0 +1,257 @@ +"""Evaluate the actual kernel-task admission and check/verify execution boundaries.""" + +import base64 +import copy +import re +import unittest +from pathlib import Path + +import yaml +from jinja2 import Environment, StrictUndefined + +ROOT = Path(__file__).parents[1] +DIRECTORY = ROOT / "roles/lychd_app/tasks" +COMMON = yaml.safe_load((DIRECTORY / "tether_kernel.yml").read_text()) +PREPARE = yaml.safe_load((DIRECTORY / "tether_kernel_prepare.yml").read_text()) +VERIFY = yaml.safe_load((DIRECTORY / "tether_kernel_verify.yml").read_text()) +TASKS = COMMON + PREPARE[1:] +ENV = Environment(undefined=StrictUndefined) +ENV.filters["bool"] = bool +ENV.filters["b64decode"] = lambda value: base64.b64decode(value).decode() +ENV.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None + + +def task(name, rows=TASKS): + return next(row for row in rows if row["name"] == name) + + +def evaluate(expression, context): + return expression if isinstance(expression, bool) else bool(ENV.compile_expression(expression)(**context)) + + +def admitted(row, context): + clauses = row["ansible.builtin.assert"]["that"] + return all(evaluate(clause, context) for clause in (clauses if isinstance(clauses, list) else [clauses])) + + +def context(**updates): + value = { + "lychd_app_profile": "tether-container-v1", + "ansible_check_mode": False, + "lychd_tether_kernel_uid": {"stdout": "0\n"}, + "lychd_tether_kernel_module": {"rc": 0, "stdout": "/usr/lib/modules/running/kernel/wireguard.ko.zst"}, + "lychd_tether_module_directory": {"stat": {"isdir": True, "uid": 0, "mode": "0755"}}, + "lychd_tether_module_boot": {"stat": {"exists": False}}, + "lychd_tether_module_content": {"content": base64.b64encode(b"wireguard\n").decode()}, + "lychd_tether_kernel_loaded": {"stat": {"isdir": False}}, + } + value.update(updates) + return value + + +def existing_boot(): + return {"stat": {"exists": True, "isreg": True, "uid": 0, "gid": 0, "mode": "0644", "nlink": 1, "size": 10}} + + +class KernelPrerequisiteTests(unittest.TestCase): + def test_effect_inventory_contains_only_one_fixed_load_and_boot_file(self): + commands = [row for row in TASKS if "ansible.builtin.command" in row] + self.assertEqual(len(commands), 4) + self.assertEqual(commands[0]["ansible.builtin.command"]["argv"], ["/usr/bin/id", "-u"]) + self.assertEqual(commands[1]["ansible.builtin.command"]["argv"], ["/usr/bin/uname", "-r"]) + for row in commands[:3]: + self.assertFalse(row["changed_when"]) + self.assertFalse(row["check_mode"]) + self.assertEqual(len([row for row in TASKS if "ansible.builtin.copy" in row]), 1) + modules = {key for row in TASKS for key in row if key.startswith("ansible.builtin.")} + self.assertEqual( + modules, + { + "ansible.builtin.assert", + "ansible.builtin.command", + "ansible.builtin.stat", + "ansible.builtin.slurp", + "ansible.builtin.copy", + "ansible.builtin.debug", + }, + ) + + def test_profile_and_effective_root_are_required(self): + gate = task("Require the explicit Tether profile") + self.assertTrue(admitted(gate, context())) + self.assertFalse(admitted(gate, context(lychd_app_profile="empty-extensions-container"))) + root = task("Require administrator authority for the host prerequisite") + self.assertFalse(admitted(root, context(lychd_tether_kernel_uid={"stdout": "1000"}))) + for wrapper in (PREPARE, VERIFY): + self.assertEqual(wrapper[0]["ansible.builtin.import_tasks"], "tether_kernel.yml") + + def test_matching_running_kernel_and_reserved_file_checks_precede_all_effects(self): + load = task("Load only an absent WireGuard module with administrator policy intact") + publish = task("Persist only an absent Tether module-load entry after observed loading") + for name in ( + "Require matching running-kernel WireGuard support before any effect", + "Require a safe existing administrator-owned module-load directory", + "Refuse unsafe existing Tether boot entries before loading", + "Refuse different reserved boot bytes before loading", + ): + self.assertLess(TASKS.index(task(name)), TASKS.index(load)) + self.assertLess(TASKS.index(task(name)), TASKS.index(publish)) + guard = task("Require matching running-kernel WireGuard support before any effect") + for result in ({"rc": 1, "stdout": ""}, {"rc": 0, "stdout": ""}, {"rc": 2, "stdout": "unknown"}): + self.assertFalse(admitted(guard, context(lychd_tether_kernel_module=result))) + query = task("Observe WireGuard availability for this running kernel") + self.assertEqual( + query["ansible.builtin.command"]["argv"], + [ + "/usr/sbin/modinfo", + "-k", + "{{ lychd_tether_kernel_release.stdout | trim }}", + "-F", + "filename", + "wireguard", + ], + ) + self.assertFalse(query["check_mode"]) + self.assertFalse(query["changed_when"]) + + def test_foreign_boot_metadata_or_bytes_cannot_reach_load(self): + metadata = task("Refuse unsafe existing Tether boot entries before loading") + content = task("Refuse different reserved boot bytes before loading") + good = context(lychd_tether_module_boot=existing_boot()) + self.assertTrue(admitted(metadata, good)) + self.assertTrue(admitted(content, good)) + for key, value in [ + ("isreg", False), + ("uid", 1000), + ("gid", 1000), + ("mode", "0664"), + ("nlink", 2), + ("size", 11), + ]: + bad = copy.deepcopy(good) + bad["lychd_tether_module_boot"]["stat"][key] = value + self.assertFalse(admitted(metadata, bad), key) + unknown = {"stat": {"exists": True}} + self.assertFalse(admitted(metadata, context(lychd_tether_module_boot=unknown))) + good["lychd_tether_module_content"]["content"] = base64.b64encode(b"foreign!!\n").decode() + self.assertFalse(admitted(content, good)) + self.assertTrue(admitted(metadata, context())) + self.assertTrue(admitted(content, context())) + + def test_unsafe_module_directory_is_not_repaired(self): + guard = task("Require a safe existing administrator-owned module-load directory") + for record in ( + {}, + {"isdir": False}, + {"isdir": True, "uid": 1000, "mode": "0755"}, + {"isdir": True, "uid": 0, "mode": "0775"}, + {"isdir": True, "uid": 0, "mode": "0757"}, + ): + self.assertFalse(admitted(guard, context(lychd_tether_module_directory={"stat": record}))) + self.assertTrue(admitted(guard, context())) + self.assertFalse(any("ansible.builtin.file" in row for row in TASKS)) + + def test_load_uses_builtin_absence_and_check_mode_semantics_without_hiding_changes(self): + load = task("Load only an absent WireGuard module with administrator policy intact") + self.assertEqual( + load["ansible.builtin.command"], + {"argv": ["/usr/sbin/modprobe", "--use-blacklist", "wireguard"], "creates": "/sys/module/wireguard"}, + ) + # command.creates skips an already loaded module and predicts an absent load in check mode. + self.assertNotIn("changed_when", load) + self.assertNotIn("check_mode", load) + self.assertNotIn("when", load) + self.assertNotIn(load, COMMON + VERIFY) + + def test_exit_zero_without_loaded_module_stops_before_boot_publication(self): + observed = task("Observe actual WireGuard presence after preparation or during verification") + self.assertEqual(observed["ansible.builtin.stat"], {"path": "/sys/module/wireguard", "follow": False}) + guard = task("Require observed WireGuard before boot publication or native lifecycle") + publish = task("Persist only an absent Tether module-load entry after observed loading") + self.assertLess(TASKS.index(observed), TASKS.index(guard)) + self.assertLess(TASKS.index(guard), TASKS.index(publish)) + for check in (False, True): + current = context(ansible_check_mode=check) + self.assertEqual(evaluate(guard["when"], current), not check) + self.assertFalse(admitted(guard, current)) + current["lychd_tether_kernel_loaded"] = {"stat": {"isdir": True}} + self.assertTrue(admitted(guard, current)) + verify = task("Require observed WireGuard during read-only verification", VERIFY) + self.assertNotIn("when", verify) + self.assertFalse(admitted(verify, context(ansible_check_mode=True))) + self.assertTrue(admitted(verify, context(lychd_tether_kernel_loaded={"stat": {"isdir": True}}))) + + def test_boot_entry_is_absent_only_and_verification_never_repairs_it(self): + publish = task("Persist only an absent Tether module-load entry after observed loading") + self.assertEqual( + publish["ansible.builtin.copy"], + { + "dest": "/etc/modules-load.d/lychd-tether.conf", + "content": "wireguard\n", + "owner": "root", + "group": "root", + "mode": "0644", + "force": False, + }, + ) + self.assertNotIn("check_mode", publish) + self.assertNotIn("changed_when", publish) + self.assertNotIn(publish, COMMON + VERIFY) + for check in (False, True): + for exists in (False, True): + current = context(ansible_check_mode=check, lychd_tether_module_boot={"stat": {"exists": exists}}) + self.assertEqual(evaluate(publish["when"], current), not exists) + verify = task("Require the persistent Tether entry during read-only verification", VERIFY) + self.assertNotIn("when", verify) + self.assertFalse(admitted(verify, context(ansible_check_mode=True))) + + def test_only_root_plays_select_kernel_prepare_or_verify_for_tether(self): + calls = [] + for filename in ("deploy.yml", "app-verify.yml"): + plays = yaml.safe_load((ROOT / "playbooks" / filename).read_text()) + for index, play in enumerate(plays): + for row in play.get("tasks", []): + entry = row.get("ansible.builtin.include_role", {}).get("tasks_from", "") + if entry not in ("tether_kernel_prepare", "tether_kernel_verify"): + continue + self.assertTrue(play["become"]) + self.assertNotIn("vars", row) + action = entry.removeprefix("tether_kernel_") + calls.append((filename, action)) + for profile in ("empty-extensions-container", "tether-container-v1"): + for mode in ("mutate", "verify", "refuse"): + current = context( + lychd_app_profile=profile, + lychd_deploy_mode=mode, + lychd_app_observed={"state": "initialized"}, + ) + expected = profile == "tether-container-v1" and ( + filename == "app-verify.yml" or mode == ("mutate" if action == "prepare" else "verify") + ) + self.assertEqual(evaluate(row["when"], current), expected) + self.assertNotIn("offered", row["when"]) + if action == "prepare": + native = next( + i + for i, item in enumerate(plays) + if item["name"] == "Apply native application lifecycle after host prerequisites" + ) + self.assertLess(index, native) + self.assertFalse(plays[native]["become"]) + self.assertEqual(play["tasks"][0], row) + self.assertEqual(play["tasks"][1]["ansible.builtin.include_role"]["tasks_from"], "firewall") + self.assertFalse( + evaluate( + row["when"], + context( + ansible_check_mode=True, + lychd_deploy_mode="mutate", + lychd_app_observed={"state": "no_runtime_account"}, + ), + ) + ) + self.assertEqual(calls, [("deploy.yml", "prepare"), ("deploy.yml", "verify"), ("app-verify.yml", "verify")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/adr/19-cli.md b/docs/adr/19-cli.md index 550dd239..43754fad 100644 --- a/docs/adr/19-cli.md +++ b/docs/adr/19-cli.md @@ -13,7 +13,8 @@ icon: material/console-line ## Requirements -- The root grammar is closed: internal frameworks and extensions add no public root command. +- The root grammar is a fixed host-owned inventory reviewed in source. Internal frameworks and + extensions cannot register their own public root commands. - Commands name operator intent rather than Litestar, systemd, Podman, database, queue, or worker topology. Click validates and renders; typed services retain authority. - One source-owned target vocabulary supplies observation, with a safely actuable subset for @@ -39,8 +40,14 @@ not public compatibility. The managed image uses the fixed process entrypoint defined by [Backend](11-backend.md#process-and-listener-boundary). There is no host Vessel `serve` entrypoint. Database commands retain their lazy framework delegation. The root verbs are `init`, `bind`, `start`, `stop`, `status`/`st`, -`logs`, `access`, and `del`; no run-operation, snapshot, restore, selector shorthand, recovery, promotion, or -consent spelling is reserved by the grammar. +`logs`, `access`, `tether`, and `del`; no run-operation, snapshot, restore, selector shorthand, +recovery, promotion, or consent spelling is reserved by the grammar. + +`lychd tether` is a fixed host-owned group for the rootless native lifecycle defined by +[VPN](39-vpn.md). Its closed operations delegate to typed native services, revalidate exact +ownership, and serialize mutations under the lifecycle lock. Registration belongs to Pulse source, +never an extension-supplied Click callback. This group grants neither generic extension execution +nor arbitrary host commands; the future run-operation namespace remains separate. ### Inscribe, then bind diff --git a/docs/adr/39-vpn.md b/docs/adr/39-vpn.md index 5ea46851..ec3483c1 100644 --- a/docs/adr/39-vpn.md +++ b/docs/adr/39-vpn.md @@ -26,14 +26,15 @@ Remote model/CLI requests still disclose their transmitted content to the receiv irrespective of the road carrying it; [Security](09-security.md#portal-privatization-and-egress) owns that disclosure decision. -The Tether may manifest a rootless service with only the network capability it needs and explicit +The Tether manifests rootless services with only the network capability each needs and explicit UDP publication. Interface name, listen port, address space, DNS behaviour, routes, and peer limits -are Rune-owned configuration, not hard-coded topology. Typed operations under a future host-owned -run-operation verb may generate a keypair, admit, inspect, revoke, rotate, or export a short-lived -client configuration or QR projection; it adds no root CLI verb. +are typed configuration, not hard-coded topology. The fixed host-owned +[`lychd tether` group](19-cli.md#decision-outcome) owns preparation, key import, attachment +registration and admission, peer enrollment, binding, activation, inspection and generation-fenced +revocation. Client configuration and QR export remain separate work. -If that service is projected through Quadlet, its future Rune may embed the code-level -`QuadletConfig` value under `quadlet`. That does not rename Tether to a kind of Stone, make it an +The Rune embeds the code-level `QuadletConfig` value under `quadlet`. That does not rename Tether +to a kind of Stone, make it an Animator, or grant generic unit-text authority; Tether keeps ownership of its exact network fields and compilation policy. @@ -172,14 +173,118 @@ traversal require separate trust decisions; recovery cannot introduce them invis ## Delivery and consequences -This Covenant is **Designed**. No VPN provider, interface, enrollment, peer registry, key -rotation, route policy, health, or revocation path ships; generated deployment remains -IPv4-loopback-only and rejects UDP. The optional infrastructure-owned +[State of Work](../state-of-the-work.md#vpn-tether) owns maturity, executable evidence and remaining +qualification boundaries. The native `rootless-wireguard-stream-v1` contract below is selected by +the `tether-container-v1` initialization profile. It has typed Domain/Rune values, isolated Quadlet +projection with exact UDP publication, native key and peer custody, managed imported attachments, +and host-owned lifecycle operations. These source contracts do not establish unrestricted +tunnelling, remote application authority or qualification on arbitrary hosts and user devices. + +Use the native profile for LychD-managed roads. The infrastructure-owned [single-operator forwarding candidate](09-security.md#single-operator-forwarding-candidate) -is separate from Tether and has no installed qualification. General tunnelling and shared-user -access remain unsupported. [State of -Work](../state-of-the-work.md#vpn-tether) owns that boundary. +is a separate legacy access path with its own custody and evidence; it does not qualify Tether. Independent revocation is Tether's practical return. Operating that private road requires security-sensitive peer and route management, backup, rotation, recovery, and an account of uncertain direct reachability. + +## Native rootless stream profile + +The `rootless-wireguard-stream-v1` profile compiles one private Pod for the offered network and +one private Pod for each admitted imported attachment. These Pods are separate from the Core +Pod. The WireGuard role alone receives namespace-local `NET_ADMIN`; byte relays drop all +capabilities. The compiler owns the closed networking policy and complete deployment contribution. +Ordinary extension contributors cannot request this privilege, arbitrary Podman arguments, or +changes to Core's network namespace. + +The first profile uses numeric IPv4 outer endpoints and numeric inner service destinations with +an admitted application hostname. It performs no ambient DNS resolution. Each imported service +has a generation-specific Unix socket beneath its attachment's runtime directory. A Core consumer +selects the remote Lich, attachment, generation and service before connecting; a bounded broker +handshake also binds installation epoch, policy digest and caller. The broker connects only the +compiled destination. TLS hostname verification and application identity remain with the consumer. +A failed selection never becomes direct transport or another attachment. + +The initial Rune is immutable installation configuration. After preparation, typed native Tether +operations may register additional imported attachment definitions in Tether-owned persistent +runtime data. This managed topology can change only imported attachments: the initial profile, +image, offered network and other Rune fields remain unchanged. Registration accepts the closed +public attachment DTO, never a WireGuard configuration file, hook or executable directive. It +stages a definition without importing credentials, admitting a generation or starting a service. +The operator separately imports its local keys, admits the exact generation, binds and activates. + +Changing an existing attachment requires completed physical revocation of its previous generation +and no retained live launch, role or Pod. Its identity and admission tombstone remain; the next +admission advances the generation even when the remote Lich or endpoint changes. Historical key +references keep their attachment and credential-purpose ownership and cannot be reassigned to +another road. Original Settings, Rune files and installation receipts are never rewritten or +resealed; repeated preparation with original intent preserves managed roads. Binding substitutes +only the validated effective Tether Rune into the in-memory Rune registry and preserves every +other extension's declarations. A Tether-selected Core always mounts the private runtime parent +read-only, including an initially offered-only installation, so adding an attachment requires no +Core mount change. Application enrollment and authorization remain separate under [A2A](26-a2a.md). + +Initial Rune configuration, managed attachment definitions and current host-owned admission are +separate inputs. The compiler does +not discover files or generate keys. Binding reconciles the complete desired fileset through +Scribe with a freshly observed transaction generation. Persisted route identity instead uses the +installation epoch, explicit admission generation and canonical policy digest. Offered peer-only +changes advance that peer's generation without advancing the network topology generation; it never persists +Scribe's inode-sensitive transaction observation as route authority. + +The host atomically materializes public role configuration and revocation fences in dedicated +per-role directories. Tunnel and SSH private material arrive only through purpose-specific secret +references mounted into the role that needs them. Read-only consumer mounts expose the socket +parent, while each producer can write only its own attachment directory. Socket directories under +`/run/user//lychd-tether` are boot-local; their inode identities are not durable authority. +Missing, mismatched or disabled current fences close the affected transport. Revocation is +complete only after observing live removal and stream closure, or verified termination of the +affected role; writing denial intent alone is not a live closure receipt. + +Failed-start launch records retire only after the corresponding role is proved closed. If a failed +start created the Pod before its receipt was saved, revocation may recover its exact owned Pod +identity from the journaled launch's boot, network, policy and changed Pod invocation, then persist +that identity before stopping it. Missing or stale proof leaves revocation pending; an existing +unproved Pod never counts as physical closure. + +### Optional same-owner SSH access + +`same-owner-ssh-loopback-v1` is a separate owner-access binding on an offered peer. It provides +one SSH local-forward destination to the existing host-published owner web loopback port, derived +from current Settings. It does not expose an Altar IP listener through WireGuard, enroll a remote +application principal, or deliver general remote Ward/IAM. + +Each owner peer has an independently contained SSH adapter in the offered network's private Pod, +outside Core. Its SSH public-key admission and purpose-specific host key are independent of the +WireGuard keys and application credentials. The adapter has no `NET_ADMIN`; its capability set is +limited to the tested SSH requirements `SETUID`, `SETGID` and `SYS_CHROOT`. The namespace's pasta +policy admits only the selected container-loopback-to-host-loopback TCP port and disables broad +host-loopback aliases and automatic port forwarding. This profile requires live qualification of +the installed Podman/pasta combination and namespace filtering. + +Declared owner bindings reserve this one-port namespace capacity, including disabled peer +tombstones. Revoking the last owner removes its WireGuard admission, ingress and SSH adapter but +keeps the offered Pod policy unchanged. Retained bridge capacity grants no peer access. A network +with no declared owner binding has no owner bridge; introducing its first binding is a cold change. + +SSH admits no shell, session, subsystem, reverse forward, arbitrary destination, Unix-socket +forward, agent, X11 or tunnel channel. Each peer's private SSH listener is separately source-filtered +and admits only its own SSH key. Per-peer container termination closes all descendants and active +channels without terminating another peer's adapter. The browser uses the admitted loopback +origin through its local SSH forward. Existing Basic authentication, Host and Origin validation, +and same-origin behavior remain unchanged; neither tunnel nor SSH admission substitutes for them. + +A generic offered TCP service is a distinct explicit service contract with independent application +authentication. Admission supplies a fresh observed host-address exclusion set; without that +observation the compiler refuses external backends. Host, loopback, link-local, offered-network +and reserved Core listener destinations are rejected. The host revalidates this observation +before activation and reconciliation; unrelated host addresses are not part of route identity. It cannot select arbitrary Core administration, database or Reactor listeners, and +cannot use the generic route to bypass the owner SSH profile. + +A new offered peer may join an already active network without stopping other peers only when +its compiled Pod policy is byte-identical, all required imported credentials are already attested, +and any new peer-specific SSH host key is prepared before publication. Binding reconciles the +complete fileset and starts only new peer roles; existing peer identities and imported attachments +remain unchanged. Changing the Pod policy, including introducing the first owner loopback bridge, +or rotating an existing peer requires the explicit cold-change path. A missing preshared key +cannot be published into a live admission. diff --git a/docs/deployments/tumbleweed/index.md b/docs/deployments/tumbleweed/index.md index 2f566d79..5c8c849c 100644 --- a/docs/deployments/tumbleweed/index.md +++ b/docs/deployments/tumbleweed/index.md @@ -617,6 +617,113 @@ An active body remains observation-only, even when all acceptance checks pass. T and [focused refusal tests](https://github.com/hexanomicon/lychd/blob/main/tests/unit/system/services/test_deployment.py) are source evidence; installed host qualification remains a separate receipt. +### Native Tether profile (protocol 2) + +The controller records the admitted archive manifest digest alongside its config image ID. +Rootless import may change the manifest digest while preserving that config ID. Before original +initialization, staging verifies the archive again, checks the source intent names that archive's +`localhost/lychd-tether@sha256:…` reference, and resolves the actual imported repository digest to +the same admitted ID and revision. It changes only `rune.quadlet.image` in a normalized public +intent. `tether-campaign.json` retains the source and normalized intent hashes, archive hash, +manifest digest, imported reference, config ID and revision. Existing campaign bytes are never +replaced; original initialization consumes the normalized intent from its first journaled write. + +An interruption after original initialization can resume native preparation on the next deployment. +`prepared_waiting_keys` reports `next_action: import-key` and the missing references; import those +keys explicitly with the native CLI, then rerun deployment. Automation never transfers keys. +Replaced keys and uncertain custody remain refusals, not missing-key recovery. + +Select `lychd_app_profile: tether-container-v1` only for an original installation with the +`tether` builtin. Keep its public closed intent JSON outside the checkout and provide +`lychd_tether_intent` plus its SHA-256 as `lychd_tether_intent_sha256`. The intent selects the +Rune, offered peers, attachment admission and application port; it contains secret references, +never private keys. Admit the separate runtime OCI archive with +`deployments/ansible/roles/lychd_image/files/admit_oci_archive.py --tether ARCHIVE REVISION OUTPUT_JSON [IMAGE_ID]` +and pass the resulting `lychd_tether_image_*` variables alongside the ordinary Core image receipt. + +Automation verifies and imports that image and stages the public intent before running +`lychd init --profile tether-container-v1 --intent PATH --web-image REF`. The selected settings +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 +`/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. + +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 +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 +does not establish this kernel prerequisite. + +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 +to the existing zone's permanent and runtime port lists. It preserves other rules, performs no +reload, and reports no change when both entries already exist. There is no separate firewall +port variable. Import-only profiles do not invoke the firewall helper. + +The listener address must occur exactly once on one host interface; `0.0.0.0`, loopback, +link-local, ambiguous addresses and mismatched zones are refused. An interface with no explicit +zone uses firewalld's [documented default zone](https://firewalld.org/documentation/zone/default-zone.html), +which must still equal the selected zone. Both runtime and permanent interface assignments must +agree. The supported host profile requires positive NetworkManager ownership and checks the active +connection UUID's configured `connection.zone`; an unset zone uses the same default according to +[NetworkManager's zone contract](https://networkmanager.dev/docs/api/latest/settings-connection.html). +Unknown or unmanaged ownership is refused without altering network bindings. +A port entry applies to the selected zone; this helper +does not narrow or change that zone's interface membership. Check mode only reports missing +entries for an already verified normalized campaign; it cannot invent an unstaged listener. +The protocol-2 verification path observes both port entries and the effective interface zone. +Provider firewall or security-group rules remain operator-owned and are not claimed as verified. + +These OS prerequisites belong to deployment automation. Native Tether continues to own all VPN +keys, routes, configuration, units, activation and revocation; the host helper performs none of them. +The firewall helper uses only the documented [firewall-cmd zone and port operations](https://firewalld.org/documentation/man-pages/firewall-cmd.html). + +The native chain is `tether prepare --intent PATH`, `tether bind`, ordinary guarded `start`, +then `tether activate`. Keys must already be admitted through the separate operator-owned +`tether import-key --secret REF --file PATH` boundary before binding; automation neither transports +keys nor generates VPN units. Inspect with `tether inspect`, enroll a strict public declaration +with `tether enroll --peer-file PATH` (new peers may join a live road when its Pod policy stays unchanged), +readmit one imported attachment with `tether admit --attachment ID --generation GENERATION`, +and revoke an exact observed generation with +`tether revoke --network ID --generation GENERATION [--peer ID]`. + +To add a received VPN attachment after installation, keep the original campaign and Rune unchanged. +Write a closed public `ImportedAttachment` JSON document in a private, owner-only `0600` file, +containing numeric endpoint/service coordinates, public peer identity and secret references. +Raw WireGuard configuration, private keys and hooks are not attachment declarations. Stage it with +`lychd tether register --attachment-file PATH` (or inspect the proposed operation with `--dry-run`). +Then use native `tether import-key`, `tether admit --attachment ID --generation 1`, `tether bind`, +and `tether activate` in that order. Registration records managed Tether data; it grants neither +admission nor activation. The binding projection uses that managed topology without rewriting the +original initialization receipt, normalized intent or immutable Rune. Ordinary preparation and +Ansible reruns preserve the managed attachments. + +Repeating the identical declaration is a no-op. To change an existing attachment, revoke the whole +attachment's current generation and complete runtime closure first, then register its replacement, +import any new referenced key, admit the next generation, bind and activate. The prior generation +remains a tombstone; it is never reset to generation 1. These attachment operations do not restart +Core; check its existing PID and unrelated active roads across the native lifecycle acceptance run. +Native inspection reports currently required secrets separately from all strictly receipted owned +secrets. Retained keys from revoked roads remain owned; unreceipted or replaced keys remain refusals. + +Protocol 2 observes native Tether custody, exact unit provenance and the separately admitted +image identity. Its status invocation adds `--deployment-profile tether-container-v1`, +`--deployment-intent PATH` and `--deployment-tether-image-id ID` to the protocol-1 revision/image +arguments. Default deployments keep protocol 1 and empty optional extensions. A partially active +or unknown body remains a refusal requiring native inspection; automation does not infer recovery. + ## Repeat on another host After the pilot passes its declared host and recovery observations, add each fresh Tumbleweed diff --git a/docs/sepulcher/extensions/tether.md b/docs/sepulcher/extensions/tether.md index 1cb20de3..607fae46 100644 --- a/docs/sepulcher/extensions/tether.md +++ b/docs/sepulcher/extensions/tether.md @@ -8,20 +8,19 @@ icon: material/shield-link-variant-outline > _A thread drawn taut between two distant thresholds._ **Tether** is the private-reachability Extension Domain, with WireGuard as its accepted transport. -It may eventually appear as a LychD-managed WireGuard service or as an attachment to an externally -managed private network. +Its native `rootless-wireguard-stream-v1` profile offers one network to admitted peers and receives +independent imported attachments in isolated rootless Pods outside Core. -Tether is **Designed**; no managed VPN or peer-operation path ships. [State of -Work](../../state-of-the-work.md#vpn-tether) records that delivery boundary. The public peer intent, -secret references, endpoint and route checks, revision fences, and retained revocation tombstones -below are accepted design. +[State of Work](../../state-of-the-work.md#vpn-tether) records maturity, source and test evidence, +and the live qualification still required. Select `tether-container-v1` during original +initialization for LychD-managed roads. Native operations preserve that initialization intent +while registering imported attachments and admitting peers separately. -Generated deployment remains IPv4-loopback-only: the Extension port grammar accepts only -`127.0.0.1::` and rejects UDP. Remote, proxied, tunneled, and untrusted-browser -use is unsupported. The separate infrastructure-owned -[single-owner access candidate](../../deployments/tumbleweed/access.md) permits an explicit -qualification experiment while keeping loopback publication. It is not a native Tether -implementation or a shared remote Altar. +Tether's closed compiler publishes the offered WireGuard UDP endpoint; generic Extension port +declarations remain loopback-only. Owner web access uses the optional restricted SSH binding below +and preserves Core's loopback browser policy. The infrastructure-owned +[single-owner access candidate](../../deployments/tumbleweed/access.md) is a separate legacy path, +not the preferred path for native Tether or evidence of shared remote Altar support. ## Managed path and peer custody @@ -44,15 +43,16 @@ and routes, endpoint and keepalive policy, enabled or revoked state, and creatio metadata. Keep private and preshared keys with the secret owner, outside ordinary documentation, logs, QR history, and public peer records. -Future typed operations under a host-owned run-operation verb may generate, admit, inspect, -revoke, rotate, or export short-lived client configuration or QR projections. They would use -that governed operation path without adding a root CLI verb. +The `lychd tether` command group delegates preparation, key import, registration, admission, +enrollment, binding, activation, inspection and revocation to the native lifecycle service. +Client configuration and QR export are not part of that operation set. ## One offered network, many received accesses [ADR 39](../../adr/39-vpn.md#offered-and-imported-roads) separates the network a Lich offers from -the independent VPN attachments it receives. This is a design example, not an available command, -Rune schema, or expansion of the first single-peer deployment profile. +the independent VPN attachments it receives. The example below describes the native compiler's +isolated attachment selection; application enrollment and live deployment qualification remain +separate boundaries. Suppose Lich A offers `A-home` to an admitted Lich D. A also receives `B-private` from Lich B and `C-private` from Lich C, whose application endpoints are private. B and C independently choose @@ -138,3 +138,68 @@ Direct WireGuard avoids a mandatory hosted control plane, but CGNAT, blocked UDP endpoints, roaming, endpoint metadata, and traffic analysis remain. Reachability may require fixed addressing, DNS, or a separately accepted rendezvous or relay. ADR 39 owns transport law; [Security](../../adr/09-security.md) and the Ward own the trust boundary. + +## Native profile configuration contract + +The native `rootless-wireguard-stream-v1` contract separates static topology from current +admission. Its [Rune and public values](../../../src/lychd/domain/tether/models.py) describe at +most one offered network and many independent imported attachments. The +[pure compiler](../../../src/lychd/domain/tether/compiler.py) receives a current host-owned +admission snapshot explicitly; loading an imported document does not enable it. + +Each imported attachment names its remote Lich, local tunnel-key reference, remote public key, +numeric UDP endpoint, numeric inner services, TLS hostnames and allowed callers. The first +profile uses admitted static resolution only. The same private service address in two attachments +is valid because each has a separate private Pod, socket directory and route generation. Core +selects the complete route and verifies the remote application independently. + +Private keys never belong in Rune documents or public role JSON. Preparation must establish +purpose-specific immutable secrets before binding. Runtime projections carry secret references, +not secret values. Binding still reconciles the complete installation; it does not replace Scribe +with a per-route file writer. Repeating unchanged intent preserves keys and route generations. + +The optional `same-owner-ssh-loopback-v1` binding gives a specific offered peer a separate SSH +public-key admission and restricted SSH adapter. It permits only a local forward to the current +owner web loopback port. Open the browser at the same admitted loopback address and port used by +local owner access; the tunnel preserves the browser's Host, Origin and Basic authentication. +There is no WireGuard-exposed Altar HTTP listener. General application peers do not acquire this +owner binding implicitly. + +Live acceptance must cover two overlapping imported networks, two offered peers, authenticated +Core-selected service access, real owner browser access where selected, failed routes without +fallback, and revocation during active streams. Repeat initialization, preparation, binding and +activation; then verify restart and reinstall without editing the original creation receipt. +Current delivery and qualified platform combinations remain owned by +[State of Work](../../state-of-the-work.md#vpn-tether). + +### Registering roads after initialization + +The original Tether initialization intent remains immutable. Native attachment registration stores +one validated public `ImportedAttachment` in Tether-owned runtime data; it does not edit the Rune +file or installation receipt. Registration stages the road only. Import its own local key material, +admit generation 1, bind the complete fileset and activate separately. Repeating the same +registration is a no-op. The installation may begin with only an offered network and acquire +independent received attachments later without reinstalling Core. + +Before changing an existing attachment, revoke its exact current generation and finish physical +closure and binding reconciliation. Registration preserves the disabled generation tombstone; +subsequent admission requires the next generation. Retired key references retain their original +road and purpose ownership. Registering a road cannot change the transport image, offered network, +Core settings or application authentication. Repeating preparation with the original intent does +not remove roads registered later. The Core socket-parent mount stays fixed across these changes. + +New offered peers can be enrolled while the network remains active when the namespace policy is +unchanged and every required key is ready. A second owner-access peer can therefore join without +interrupting the existing owner's SSH adapter. Introducing the first owner-access bridge, changing +Pod policy, or rotating an existing peer requires an explicit cold change. Missing preshared keys +must be staged through the inactive path before admission. + +Revoking the last owner retains the declared one-port bridge capacity in the offered Pod while +removing that peer's WireGuard admission, ingress and SSH adapter. Disabled owner tombstones keep +this Pod policy stable; they grant no live access. An installation that has never declared an +owner binding still needs the cold path to introduce its first bridge. + +After a failed start, revocation also closes a Pod created before its receipt was saved when the +retained launch proves its exact owned invocation. If that proof is unavailable, revocation stays +pending and attachment replacement remains refused; a successful denial write is not physical +closure. Retry the same revocation after resolving the reported custody or observation failure. diff --git a/docs/state-of-the-work.md b/docs/state-of-the-work.md index 783308d1..76355952 100644 --- a/docs/state-of-the-work.md +++ b/docs/state-of-the-work.md @@ -3902,34 +3902,46 @@ artifact transfer, durable spool, cancellation, or settlement. ### VPN Tether {#vpn-tether} -**State:** Designed +**State:** Partial -**Proved now:** VPN law defines Tether as private reachability over WireGuard without application -authority, with exact peer and route intent and separate lifecycle ownership. It distinguishes -one offered network from independent received VPN attachments, including isolated overlapping -addresses and attachment-scoped service selection without implicit transit or fallback. It also -distinguishes that road from anonymous browsing and provider-visible request content. - -**Do not expect yet:** There is no Tether Domain contract, Rune or provider, generated service, UDP -publication, interface or enrollment effect, peer/key custody, live route policy, health, -reconciliation, revocation effect, or identity proof. Multi-attachment selection and routing -isolation have no implementation or capacity qualification. - -The separate [host access candidate](./deployments/tumbleweed/access.md) can prepare an -infrastructure-owned WireGuard road and restricted SSH forwarding service. It is not a Tether -extension, Rune, UDP compiler contribution or IAM implementation. Two-endpoint operation, -browser refusal, loss, revocation and reboot remain unqualified by this campaign. -Its reviewed source now includes separate read-only verification and durable transport revocation, -boot fences for both owned services, semantic firewalld policy checks and private client-profile -rendering with a pinned SSH host key and explicit WireGuard route preflight. These are candidate -contracts, not an installed security receipt or a shared remote Altar. +**Proved now:** The native `rootless-wireguard-stream-v1` implementation has typed Domain/Rune +contracts and a closed `tether-container-v1` initialization profile. Its compiler projects one +offered multi-peer network and independent imported attachments into separate rootless Pods, +with exact UDP publication, isolated overlapping addresses and generation-specific consumer +sockets. Route selection binds the remote Lich, attachment, generation, service and caller; +transport failure cannot select another attachment or direct application transport. + +Native lifecycle source and focused tests cover immutable original intent, named attachment +registration, purpose-specific key custody, separate generation admission, complete-fileset +binding, activation and observation. Offered peers have independent revocation tombstones; +additive enrollment can preserve a live Pod's policy and existing owner SSH adapters. Revocation +journals retain uncertain closure, recover exactly owned failed launches, and preserve unrelated +roads. Boot helpers recreate ephemeral directories and resume only prior activation authority. +The CLI and Ansible profile delegate these effects to the native service. The optional same-owner +SSH binding permits only the existing owner web loopback destination; it grants no application +identity or remote IAM role. + +**Boundary — Not yet:** Repository tests use bounded effect ports and policy fixtures; they do not +qualify an arbitrary installed Podman/pasta/kernel combination, wider fleet operation, measured +capacity, or user-device/browser acceptance. Whole-filesystem rollback and reconciliation against +independently current revocation authority remain unqualified. Client-profile/QR export, general +remote IAM, automatic NAT traversal, exit-node traffic and anonymous browsing are not delivered by +this profile. Each live deployment still needs named evidence for loss, active-stream revocation, +restart and reboot under its actual transport and browser configuration. + +The separate legacy [host access candidate](./deployments/tumbleweed/access.md) retains its own +infrastructure custody, verification and revocation path. Prefer the native profile for +LychD-managed roads; neither path supplies shared remote Altar authority. **Evidence** - **Topic:** [Tether](./sepulcher/extensions/tether.md) -- **Source:** [Host access candidate](https://github.com/hexanomicon/lychd/tree/main/deployments/ansible/roles/host_access) -- **Verification:** [Access refusal checks](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_access_host.py) -- **Verification:** [Policy checks](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_access_policy.py), [revocation checks](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_access_lifecycle.py), and [client checks](https://github.com/hexanomicon/lychd/blob/main/deployments/access/test_client.py) +- **Source:** [Domain values](https://github.com/hexanomicon/lychd/blob/main/src/lychd/domain/tether/models.py), [compiler](https://github.com/hexanomicon/lychd/blob/main/src/lychd/domain/tether/compiler.py), and [native lifecycle](https://github.com/hexanomicon/lychd/blob/main/src/lychd/system/services/tether/service.py) +- **Source:** [CLI operations](https://github.com/hexanomicon/lychd/blob/main/src/lychd/cli/tether.py), [initialization profile](https://github.com/hexanomicon/lychd/blob/main/src/lychd/cli/commands.py), and [Ansible application flow](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/roles/lychd_app/tasks/apply.yml) +- **Verification:** [Compiler isolation and stable Pod policy](https://github.com/hexanomicon/lychd/blob/main/tests/unit/domain/tether/test_compiler.py), [admission, recovery and revocation](https://github.com/hexanomicon/lychd/blob/main/tests/unit/system/services/test_tether_service.py), and [CLI input and dispatch](https://github.com/hexanomicon/lychd/blob/main/tests/unit/cli/test_tether.py) +- **Verification:** [Key custody](https://github.com/hexanomicon/lychd/blob/main/tests/unit/system/services/test_tether_keys.py), [host incarnation ownership](https://github.com/hexanomicon/lychd/blob/main/tests/unit/system/services/test_tether_host.py), [runtime policy](https://github.com/hexanomicon/lychd/blob/main/tests/unit/system/services/test_tether_runtime.py), and [consumer selection](https://github.com/hexanomicon/lychd/blob/main/tests/unit/system/services/test_tether_consumer.py) +- **Verification:** [Ansible intent handling](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_tether_intent.py) and [firewall policy](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_tether_firewall.py) +- **Source:** [Separate legacy host access](https://github.com/hexanomicon/lychd/tree/main/deployments/ansible/roles/host_access), with [refusal](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_access_host.py), [policy](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_access_policy.py), [revocation](https://github.com/hexanomicon/lychd/blob/main/deployments/ansible/tests/test_access_lifecycle.py), and [client checks](https://github.com/hexanomicon/lychd/blob/main/deployments/access/test_client.py) - **Law:** [VPN](./adr/39-vpn.md) ### Proxy Veil {#proxy-veil} diff --git a/scripts/container_checks.sh b/scripts/container_checks.sh index 92e4e63f..0d8824ba 100644 --- a/scripts/container_checks.sh +++ b/scripts/container_checks.sh @@ -29,6 +29,8 @@ if [[ "$inside" == 1 ]]; then export LYCHD_TEST_CONTAINER=1 XDG_RUNTIME_DIR=$(mktemp -d /tmp/lychd-check-runtime.XXXXXX) export XDG_RUNTIME_DIR + UV_CACHE_DIR=$(mktemp -d /tmp/lychd-check-cache.XXXXXX) + export UV_CACHE_DIR exec make --no-print-directory "$@" fi engine=${LYCHD_TEST_ENGINE:-podman} @@ -101,8 +103,9 @@ tar -C "$root" --exclude=node_modules --exclude=.venv --exclude=.cache \ --exclude=.svelte-kit --exclude=.pytest_cache --exclude=.ruff_cache \ -cf - "${inputs[@]}" | tar -C "$context" -xf - # Both engines see only this already-bounded context. Its production ignore -# file is retained as test data; the build uses a local empty ignore file. -: > "$context/.dockerignore" +# file is retained as test data. Explicit inclusion avoids Podman/Buildah +# falling back to the production allowlist when an override has no patterns. +printf '!**\n' > "$context/.dockerignore" cp -- "$context/.dockerignore" "$context/.containerignore.build" image="localhost/lychd-checks:python-${python_version}" build=("$engine" build --file "$context/Containerfile.tests" @@ -134,9 +137,13 @@ needs_daemon=0 for target in "$@"; do [[ "$target" != test-containers ]] || needs_daemon=1 done +check_tmpfs=/tmp:rw,nosuid,nodev,size=1g +if [[ "$engine" == podman ]]; then + check_tmpfs+=,notmpcopyup +fi common_run=("$engine" run --cidfile "$cidfile" --label io.lychd.repository-check=1 --memory 4g --cpus 2 --pids-limit 512 --cap-drop=ALL - --security-opt=no-new-privileges --tmpfs /tmp:rw,nosuid,nodev,size=1g) + --security-opt=no-new-privileges --tmpfs "$check_tmpfs") run=("${common_run[@]}" --network=none) if [[ " $* " == *' developer-fixture '* ]]; then [[ $# == 1 ]] || fail 'developer-fixture must run as its own explicit invocation.' diff --git a/src/lychd/cli/binding.py b/src/lychd/cli/binding.py index 5292cf4e..88e29e15 100644 --- a/src/lychd/cli/binding.py +++ b/src/lychd/cli/binding.py @@ -77,17 +77,26 @@ def prepare(self) -> PreparedBinding: """Refine host authority and compile the complete immutable bind request.""" settings = self.settings_snapshot.materialize() foundation = self.preflight.require_ready() - request = compile_bind_request( - settings=settings, - extensions=self.extensions, - runes=self.runes, - soulstones=self.soulstones, - portals=self.portals, - ) + attestor = None + if "tether" in settings.extensions.builtins: + from lychd.system.services.tether.service import NativeTetherService + + tether = NativeTetherService() + request = tether.bind_request() + attestor = tether.attest_bind_request + else: + request = compile_bind_request( + settings=settings, + extensions=self.extensions, + runes=self.runes, + soulstones=self.soulstones, + portals=self.portals, + ) use_case = assemble_bind_use_case( foundation=foundation, preflight_service=self.preflight_service, settings_snapshot=self.settings_snapshot, + request_attestor=attestor, ) return PreparedBinding( request=request, diff --git a/src/lychd/cli/commands.py b/src/lychd/cli/commands.py index ed6a572b..1cc61479 100644 --- a/src/lychd/cli/commands.py +++ b/src/lychd/cli/commands.py @@ -4,17 +4,23 @@ import os from collections.abc import Sequence +from pathlib import Path from typing import TYPE_CHECKING import click from lychd.cli.base import get_console, ritual_command +from lychd.cli.tether import tether_group if TYPE_CHECKING: from rich.console import Console + from lychd.config.runes import RuneConfig from lychd.config.settings import Settings + from lychd.extensions.host import AssembledExtensions from lychd.system.services.binding_preflight import BindingPreflightReport + from lychd.system.services.codex import CodexService + from lychd.system.services.lifecycle.receipt import LifecycleReceiptStore from lychd.system.services.scribe import BindingReconcilePlan @@ -37,6 +43,56 @@ def _with_web_image(settings: Settings, image: str) -> Settings: return prepared +def _initial_profile( + settings: Settings, *, profile: str, intent: Path | None +) -> tuple[Settings, tuple[RuneConfig, ...], AssembledExtensions]: + from lychd.extensions.host import assemble_extensions, get_extensions + + if profile == "default": + if intent is not None: + message = "--intent requires the Tether initialization profile." + raise click.UsageError(message) + return settings, (), get_extensions() + if profile != "tether-container-v1": + message = "Unsupported initialization profile." + raise click.UsageError(message) + if intent is None: + message = "Tether initialization requires --intent." + raise click.UsageError(message) + from lychd.system.services.tether.models import load_intent + + try: + admitted = load_intent(intent) + except (OSError, ValueError, RuntimeError): + message = "Public Tether initialization intent refused." + raise click.UsageError(message) from None + prepared = settings.model_copy(deep=True) + prepared.extensions.builtins = ("tether",) + prepared.extensions.crypt = () + prepared.server.port = admitted.web_port + from lychd.config.settings import SettingsSnapshot + + baseline = SettingsSnapshot(payload="{}").materialize() + baseline.server.web.image = prepared.server.web.image + baseline.server.port = admitted.web_port + baseline.extensions.builtins = ("tether",) + if prepared.model_dump(mode="json", exclude_none=True) != baseline.model_dump(mode="json", exclude_none=True): + message = "Settings are outside the closed Tether initialization profile." + raise click.UsageError(message) + return prepared, (admitted.rune,), assemble_extensions(prepared) + + +def _require_original_profile(codex: CodexService, receipt_store: LifecycleReceiptStore) -> None: + from lychd.system.constants import PATH_LYCHD_TOML + + for path, expected in {PATH_LYCHD_TOML: codex.settings_content(), **codex.initial_files()}.items(): + if os.path.lexists(path): + original = receipt_store.read_original_file(path, expected_mode=0o600, max_bytes=1024 * 1024) + if original != expected.encode(): + message = "Existing original configuration differs from the selected profile." + raise click.UsageError(message) + + @ritual_command( name="init", help_text="Initialize the Codex config files and system layout.", @@ -53,6 +109,8 @@ def _with_web_image(settings: Settings, image: str) -> Settings: default=None, help="Select the Vessel image for newly created settings; existing settings are preserved.", ) +@click.option("--profile", type=click.Choice(["default", "tether-container-v1"]), default="default") +@click.option("--intent", type=click.Path(path_type=Path, dir_okay=False), default=None) @click.option( "-v", "--verbose", @@ -64,6 +122,8 @@ def init_codex( dry_run: bool, # noqa: FBT001 - Click owns these boolean option contracts verbose: bool, # noqa: FBT001 web_image: str | None, + profile: str = "default", + intent: Path | None = None, ) -> None: """Plan, attest, and converge the complete host inscription. @@ -84,7 +144,6 @@ def init_codex( ) from lychd.config.runes.writer import ConfigWriter from lychd.config.settings.root import get_settings - from lychd.extensions.host import get_extensions from lychd.system.constants import PATH_CODEX_ROOT, PATH_RUNES_DIR from lychd.system.readiness import HostReadinessService from lychd.system.services.codex import CodexService @@ -96,19 +155,28 @@ def init_codex( lifecycle_lock = nullcontext() if dry_run else lock.LifecycleLock() with lifecycle_lock: settings = get_settings() - if web_image is not None: - settings = _with_web_image(settings, web_image) - extensions = get_extensions() + settings = _with_web_image(settings, web_image) if web_image is not None else settings + settings, initial_runes, extensions = _initial_profile(settings, profile=profile, intent=intent) schemas = list(extensions.rune_schemas) writer = ConfigWriter(runes_dir=PATH_RUNES_DIR) receipt_store = receipt.LifecycleReceiptStore() + codex = ( + CodexService(rune_schemas=schemas, settings=settings, initial_runes=initial_runes) + if initial_runes + else None + ) + if codex is not None: + _require_original_profile(codex, receipt_store) planner = initialization.InitializationPlanner( reactor_directories=( settings.orchestration.switching.host_reactor_dir, settings.orchestration.switching.host_reactor_journal_dir, ), anchor_paths=tuple(schema.anchor_dir(PATH_RUNES_DIR) for schema in schemas), - sample_paths=writer.planned_sample_paths(schemas), + sample_paths=( + *writer.planned_sample_paths(codex.sample_schemas if codex else schemas), + *(codex.initial_files() if codex else ()), + ), receipt_store=receipt_store, ) readiness_service = HostReadinessService() @@ -143,8 +211,7 @@ def establish_reactor(record: initialization.InitializationRecorder) -> models.C ) def inscribe_codex(record: initialization.InitializationRecorder) -> models.CreatedResources: - console.print("[dim] Inscribing the Prime Directive (lychd.toml)...[/]") - return CodexService(rune_schemas=schemas, settings=settings).inscribe(on_created=record) + return (codex or CodexService(rune_schemas=schemas, settings=settings)).inscribe(on_created=record) initialization.InitializationExecutor( planner=planner, @@ -395,4 +462,5 @@ async def _consume_reactor_intents() -> None: init_codex, bind_quadlets, reactor_group, + tether_group, ) diff --git a/src/lychd/cli/operator.py b/src/lychd/cli/operator.py index 946cbdeb..edef2789 100644 --- a/src/lychd/cli/operator.py +++ b/src/lychd/cli/operator.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path import click @@ -48,8 +49,15 @@ def _render_status(report: InventoryReport) -> None: @click.command(name="status", help="Show installation, runtime, storage, and readiness truth.") @click.argument("target", required=False, type=_TARGET_CHOICE, default=OperatorTarget.SYSTEM.value) @click.option("--json", "as_json", is_flag=True, help="Emit stable machine-readable JSON.") -@click.option("--deployment-revision", default=None, help="Assess the pinned empty-extension deployment profile.") +@click.option("--deployment-revision", default=None, help="Assess the explicitly selected pinned deployment profile.") @click.option("--deployment-image-id", default=None, help="Require the admitted exact OCI image ID.") +@click.option( + "--deployment-profile", + type=click.Choice(["empty-extensions-container", "tether-container-v1"]), + default="empty-extensions-container", +) +@click.option("--deployment-intent", type=click.Path(path_type=Path, dir_okay=False), default=None) +@click.option("--deployment-tether-image-id", default=None) @click.option("--deployment-verify", is_flag=True, help="Observe bounded server-local deployment acceptance.") def status( target: str, @@ -58,9 +66,19 @@ def status( deployment_revision: str | None, deployment_image_id: str | None, deployment_verify: bool, + deployment_profile: str = "empty-extensions-container", + deployment_intent: Path | None = None, + deployment_tether_image_id: str | None = None, ) -> None: """Render one read-only status projection.""" - if deployment_revision is not None or deployment_image_id is not None or deployment_verify: + if ( + deployment_revision is not None + or deployment_image_id is not None + or deployment_verify + or deployment_profile != "empty-extensions-container" + or deployment_intent is not None + or deployment_tether_image_id is not None + ): if not as_json or target != OperatorTarget.SYSTEM.value or deployment_revision is None: message = "Deployment assessment requires --json, the system target, and --deployment-revision." raise click.UsageError(message) @@ -73,7 +91,14 @@ def status( ) try: - request = DeploymentRequest(deployment_revision, deployment_image_id, deployment_verify) + request = DeploymentRequest( + deployment_revision, + deployment_image_id, + deployment_verify, + deployment_profile, + deployment_intent, + deployment_tether_image_id, + ) except ValueError as error: raise click.UsageError(str(error)) from None try: @@ -91,7 +116,8 @@ def status( json.dumps( { "schema": SCHEMA, - "version": VERSION, + "version": 2 if request.profile == "tether-container-v1" else VERSION, + "profile": request.profile, "state": "unknown", "next_action": "refuse", "reasons": [reason], diff --git a/src/lychd/cli/tether.py b/src/lychd/cli/tether.py new file mode 100644 index 00000000..887ade5f --- /dev/null +++ b/src/lychd/cli/tether.py @@ -0,0 +1,193 @@ +"""Closed native Tether operations; command output contains only public receipts.""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from collections.abc import Callable + + from lychd.system.services.tether.service import NativeTetherService + +_MAX_PEER_BYTES = 64 * 1024 +_PRIVATE_DECLARATION_MODE = 0o600 + + +def _invoke(operation: Callable[[NativeTetherService], dict[str, object]]) -> None: + from lychd.system.services.tether.service import NativeTetherService + + try: + result = json.dumps(operation(NativeTetherService()), sort_keys=True) + except Exception: # noqa: BLE001 - native diagnostics must never expose credential input + message = "Tether operation refused; inspect current native state and admitted inputs." + raise click.ClickException(message) from None + click.echo(result) + + +@click.group(name="tether") +def tether_group() -> None: + """Prepare, inspect and reconcile explicitly admitted Tether roads.""" + + +@tether_group.command("prepare") +@click.option("--intent", required=True, type=click.Path(path_type=Path, dir_okay=False)) +@click.option("--dry-run", is_flag=True) +def prepare(intent: Path, *, dry_run: bool) -> None: + """Admit public intent through native custody without exposing key material.""" + _invoke(lambda service: service.prepare(intent_path=intent, dry_run=dry_run)) + + +@tether_group.command("bind") +@click.option("--dry-run", is_flag=True) +def bind(*, dry_run: bool) -> None: + """Bind the exact admitted deployment through the native service.""" + _invoke(lambda service: service.bind(dry_run=dry_run)) + + +@tether_group.command("activate") +@click.option("--dry-run", is_flag=True) +def activate(*, dry_run: bool) -> None: + """Activate and verify admitted networks before publishing consumer routes.""" + _invoke(lambda service: service.activate(dry_run=dry_run)) + + +@tether_group.command("inspect") +def inspect_tether() -> None: + """Observe current native custody and runtime without mutation.""" + _invoke(lambda service: service.inspect()) + + +@tether_group.command("revoke") +@click.option("--network", required=True) +@click.option("--peer", default=None) +@click.option("--generation", required=True) +@click.option("--dry-run", is_flag=True) +def revoke(network: str, peer: str | None, generation: str, *, dry_run: bool) -> None: + """Fence one exact current generation; stale requests remain refused.""" + _invoke( + lambda service: service.revoke( + network_id=network, peer_id=peer, expected_generation=generation, dry_run=dry_run + ) + ) + + +@tether_group.command("import-key") +@click.option("--secret", required=True) +@click.option("--file", "key_file", required=True, type=click.Path(path_type=Path, dir_okay=False)) +@click.option("--dry-run", is_flag=True) +def import_key(secret: str, key_file: Path, *, dry_run: bool) -> None: + """Import an owner-controlled secret through the native custody boundary.""" + _invoke(lambda service: service.import_key(secret_ref=secret, path=key_file, dry_run=dry_run)) + + +@tether_group.command("enroll") +@click.option("--peer-file", required=True, type=click.Path(path_type=Path, dir_okay=False)) +@click.option("--dry-run", is_flag=True) +def enroll(peer_file: Path, *, dry_run: bool) -> None: + """Enroll a public peer; additive admission preserves an unchanged live offered Pod.""" + from lychd.domain.tether.models import OfferedPeer + + try: + descriptor = os.open(peer_file, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as source: + metadata = os.fstat(source.fileno()) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_nlink != 1 + or metadata.st_mode & 0o022 + or metadata.st_size > _MAX_PEER_BYTES + ): + raise ValueError # noqa: TRY301 - normalize the public file boundary + data = source.read(_MAX_PEER_BYTES + 1) + if len(data) > _MAX_PEER_BYTES: + raise ValueError # noqa: TRY301 - normalize the public file boundary + document = json.loads(data, object_pairs_hook=_closed_object) + peer = OfferedPeer.model_validate_json(json.dumps(document), strict=True) + except (OSError, ValueError, RecursionError): + message = "Public peer declaration refused." + raise click.ClickException(message) from None + _invoke(lambda service: service.enroll(peer=peer, dry_run=dry_run)) + + +def _closed_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + document: dict[str, object] = {} + for key, value in pairs: + if key in document: + raise ValueError + document[key] = value + return document + + +@tether_group.command("runtime-dir", hidden=True) +def runtime_dir() -> None: + """Recreate only the attested ephemeral runtime directory after reboot.""" + _invoke(lambda service: service.runtime_dir()) + + +@tether_group.command("resume", hidden=True) +def resume() -> None: + """Resume only generations with prior explicit activation authority.""" + _invoke(lambda service: service.resume()) + + +@tether_group.command("admit") +@click.option("--attachment", required=True) +@click.option("--generation", required=True, type=click.IntRange(min=1)) +@click.option("--dry-run", is_flag=True) +def admit(attachment: str, generation: int, *, dry_run: bool) -> None: + """Independently admit one current imported attachment generation.""" + _invoke(lambda service: service.admit(attachment_id=attachment, generation=generation, dry_run=dry_run)) + + +@tether_group.command("register") +@click.option("--attachment-file", required=True, type=click.Path(path_type=Path, dir_okay=False)) +@click.option("--dry-run", is_flag=True) +def register(attachment_file: Path, *, dry_run: bool) -> None: + """Stage one public imported attachment; import its key and admit it separately.""" + from lychd.domain.tether.models import ImportedAttachment + + try: + data = _read_private_declaration(attachment_file) + document = json.loads(data, object_pairs_hook=_closed_object) + attachment = ImportedAttachment.model_validate_json(json.dumps(document), strict=True) + except (OSError, ValueError, RecursionError): + message = "Public attachment declaration refused." + raise click.ClickException(message) from None + _invoke(lambda service: service.register_attachment(attachment, dry_run=dry_run)) + + +def _read_private_declaration(path: Path) -> bytes: + """Read bounded owner-only public input without following any path symlink.""" + path = path.absolute() + if ".." in path.parts: + raise ValueError + parent = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + for component in path.parent.parts[1:]: + child = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent) + os.close(parent) + parent = child + descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, dir_fd=parent) + with os.fdopen(descriptor, "rb") as source: + metadata = os.fstat(source.fileno()) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_nlink != 1 + or stat.S_IMODE(metadata.st_mode) != _PRIVATE_DECLARATION_MODE + or metadata.st_size > _MAX_PEER_BYTES + ): + raise ValueError + data = source.read(_MAX_PEER_BYTES + 1) + finally: + os.close(parent) + if len(data) > _MAX_PEER_BYTES: + raise ValueError + return data diff --git a/src/lychd/domain/animation/transmute.py b/src/lychd/domain/animation/transmute.py index 60d60da3..23c4bd90 100644 --- a/src/lychd/domain/animation/transmute.py +++ b/src/lychd/domain/animation/transmute.py @@ -384,6 +384,9 @@ def _normalize_contribution( contribution: QuadletContribution, ) -> QuadletContribution: """Revalidate returned container fields and detach contributor-owned references.""" + if any(container.tether_role is not None for container in contribution.containers): + msg = "Tether privilege is reserved for the host admission compiler" + raise ValueError(msg) return QuadletContribution( containers=tuple( QuadletContainer.model_validate(container.model_dump()) for container in contribution.containers diff --git a/src/lychd/domain/intercom/transport.py b/src/lychd/domain/intercom/transport.py index c7c5f705..c173bf8a 100644 --- a/src/lychd/domain/intercom/transport.py +++ b/src/lychd/domain/intercom/transport.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import Literal, Protocol +from lychd.domain.tether.models import TetherSelection + MAX_REQUEST_BYTES = 64 * 1024 MAX_REQUEST_BODY_BYTES = 48 * 1024 MAX_RESPONSE_BODY_BYTES = 64 * 1024 @@ -63,6 +65,7 @@ class IntercomEndpoint: port: int path: str = field(repr=False) ca_file: Path = field(repr=False) + tether: TetherSelection | None = field(default=None, repr=False) @dataclass(frozen=True, slots=True) diff --git a/src/lychd/domain/tether/__init__.py b/src/lychd/domain/tether/__init__.py new file mode 100644 index 00000000..a3f2bb22 --- /dev/null +++ b/src/lychd/domain/tether/__init__.py @@ -0,0 +1 @@ +"""Private reachability with explicit per-service route selection.""" diff --git a/src/lychd/domain/tether/compiler.py b/src/lychd/domain/tether/compiler.py new file mode 100644 index 00000000..bf2c1902 --- /dev/null +++ b/src/lychd/domain/tether/compiler.py @@ -0,0 +1,629 @@ +"""Pure complete Tether projection: role JSON, isolated Quadlets and Core routes. + +Compilation receives current admission explicitly. It neither loads private state +nor generates keys, creates files, starts processes, or grants application roles. +""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from lychd.domain.tether.models import ( + ImportedAttachment, + OfferedNetwork, + TetherRoute, + TetherRune, + TetherSelection, + TetherSnapshot, +) +from lychd.system.constants import CONTAINER_LYCHD_PORT, CONTAINER_POSTGRES_PORT +from lychd.system.schemas import MountData, QuadletBase, QuadletContainer, QuadletPod, TetherPodPolicy + +CONTAINER_RUNTIME = Path("/run/lychd-tether") +CONTAINER_CONFIG = Path("/etc/lychd-tether") +_UNIX_PATH_BYTES = 107 +_MAX_PORT = 65535 +_RUNTIME_COMMAND = "python3 -m lychd_tether_runtime /etc/lychd-tether/config.json" + + +@dataclass(frozen=True, slots=True) +class RuntimeConfig: + """Public per-role projection materialized by the host, never by the Domain.""" + + name: str + network_id: str + role: str + payload: dict[str, object] + fences: dict[str, object] + + def json_bytes(self) -> bytes: + """Encode canonical public configuration without machine-specific identity.""" + return _json(self.payload) + + +@dataclass(frozen=True, slots=True) +class TetherNetwork: + """Stable per-network reconciliation subset, independent of global Scribe CAS.""" + + id: str + generation: int + policy_digest: str + pod_unit: str + unit_names: tuple[str, ...] + runtime_directory: Path + + +@dataclass(frozen=True, slots=True) +class TetherDeployment: + """The complete Tether contribution to one host-owned bind generation.""" + + manifests: tuple[QuadletBase, ...] + required_secrets: tuple[str, ...] + runtime_configs: tuple[RuntimeConfig, ...] + routes: tuple[TetherRoute, ...] + networks: tuple[TetherNetwork, ...] + consumer_mount: MountData | None + + +def _json(value: object) -> bytes: + return (json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode() + + +def _digest(value: object) -> str: + return hashlib.sha256(_json(value)).hexdigest() + + +def _key_path(reference: str) -> str: + return "/run/secrets/psk-" + hashlib.sha256(reference.encode()).hexdigest()[:16] + + +def _secret(reference: str, target: str) -> str: + return f"{reference},target={target},mode=0400" + + +def _require_root(path: Path) -> None: + text = str(path) + if not path.is_absolute() or ".." in path.parts or any(char in text for char in "\n\r\\%$:'\""): + msg = "Tether projection roots must be safe absolute literal paths" + raise ValueError(msg) + + +def _fences(snapshot: TetherSnapshot, network_id: str, generation: int, *, offered: bool) -> dict[str, object]: + return { + "schema": "lychd.tether.fences", + "version": 1, + "installation_epoch": snapshot.installation_epoch, + "network_id": network_id, + "generation": generation, + "enabled": True, + "peers": [ + {"id": peer.id, "generation": peer.generation, "enabled": peer.enabled} + for peer in sorted(snapshot.offered_peers, key=lambda peer: peer.id) + ] + if offered + else [], + } + + +class _NetworkBuilder: + """Accumulate one isolated network without sharing mutable cross-network policy.""" + + def __init__( + self, + *, + rune: TetherRune, + snapshot: TetherSnapshot, + network_id: str, + generation: int, + policy: object, + runtime_root: Path, + config_root: Path, + pod_policy: TetherPodPolicy, + ) -> None: + self.network_id = network_id + self.generation = generation + self.digest = _digest({"epoch": snapshot.installation_epoch, "image": rune.quadlet.image, "policy": policy}) + self.directory_name = hashlib.sha256(network_id.encode()).hexdigest()[:16] + self.stem = "lychd-tether-" + self.directory_name + self.runtime_directory = runtime_root / self.directory_name + self.config_root = config_root + self.image = rune.quadlet.image + self.base: dict[str, object] = { + "schema": "lychd.tether.runtime", + "version": 1, + "network_id": network_id, + "installation_epoch": snapshot.installation_epoch, + "generation": generation, + "policy_digest": self.digest, + "fence_path": str(CONTAINER_CONFIG / "fences.json"), + } + self.fences = _fences(snapshot, network_id, generation, offered=pod_policy.kind == "offered") + self.manifests: list[QuadletBase] = [ + QuadletPod( + description=f"Tether isolated network {network_id}", + pod_name=self.stem, + tether_policy=pod_policy, + wanted_by=[], + ) + ] + self.configs: list[RuntimeConfig] = [] + self.secrets: set[str] = set() + self.units: list[str] = [] + + def role( + self, + role: Literal["wireguard", "imported-relay", "offered-relay", "owner-ssh"], + payload: dict[str, object], + *, + suffix: str, + secrets: tuple[tuple[str, str], ...] = (), + sockets: bool = False, + ) -> str: + name = f"{self.stem}-{suffix}" + base = dict(self.base) + if role == "owner-ssh": + # Peer-only changes never perturb another owner's live SSH adapter. + base["policy_digest"] = _digest( + { + "epoch": base["installation_epoch"], + "network": self.network_id, + "generation": self.generation, + "image": self.image, + "role": role, + "payload": payload, + } + ) + config = RuntimeConfig(name, self.network_id, role, {**base, "role": role, **payload}, dict(self.fences)) + self.configs.append(config) + mounts = [ + MountData( + host_path=self.config_root / name, container_path=CONTAINER_CONFIG, mirror=False, options=["ro", "z"] + ) + ] + if sockets: + mounts.append( + MountData( + host_path=self.runtime_directory, + container_path=CONTAINER_RUNTIME / self.directory_name, + mirror=False, + options=["rw", "z"], + ) + ) + specs = [_secret(reference, target) for reference, target in secrets] + self.secrets.update(reference for reference, _target in secrets) + wg_unit = f"{self.stem}-wg.service" + dependencies = [] if role == "wireguard" else [wg_unit] + self.manifests.append( + QuadletContainer( + description=f"Tether {self.network_id} {role}", + image=self.image, + container_name=name, + pod=f"{self.stem}.pod", + tether_role=role, + user="0" if role in {"wireguard", "owner-ssh"} else "%U", + read_only=True, + run_init=False, + secret_mount_tmpfs=bool(specs), + volumes=mounts, + secrets=specs, + requires=dependencies, + after=["network-online.target", *dependencies], + binds_to=dependencies, + exec=_RUNTIME_COMMAND, + wanted_by=[], + ) + ) + unit = f"{name}.service" + self.units.append(unit) + return unit + + def network(self) -> TetherNetwork: + return TetherNetwork( + self.network_id, + self.generation, + self.digest, + f"{self.stem}-pod.service", + tuple(self.units), + self.runtime_directory, + ) + + +def _validate_offered( + *, + offered: OfferedNetwork, + snapshot: TetherSnapshot, + core_web_port: int, + forbidden_backend_addresses: tuple[str, ...] | None, +) -> None: + """Reject routing and listener ambiguity before projecting any role.""" + peers = tuple(sorted(snapshot.offered_peers, key=lambda peer: peer.id)) + own_interface = ipaddress.IPv4Interface(offered.address) + own_address = str(own_interface.ip) + known_services = {service.id for service in offered.services} + occupied_ports = {offered.listen_port, *(service.listen_port for service in offered.services)} + for peer in peers: + if peer.address == own_address or ipaddress.IPv4Address(peer.address) not in own_interface.network: + msg = "Offered peer address must be distinct and within its offered network" + raise ValueError(msg) + if not set(peer.service_ids).issubset(known_services): + msg = "Offered peer refers to an unknown service" + raise ValueError(msg) + if peer.owner_access is not None: + if peer.owner_access.listen_port in occupied_ports: + msg = "Owner SSH listeners require distinct private ports" + raise ValueError(msg) + occupied_ports.add(peer.owner_access.listen_port) + _validate_backends(offered, core_web_port, forbidden_backend_addresses) + + +def _validate_backends( + offered: OfferedNetwork, + core_web_port: int, + forbidden_backend_addresses: tuple[str, ...] | None, +) -> None: + """Require fresh host exclusion evidence without making it persistent route identity.""" + own_interface = ipaddress.IPv4Interface(offered.address) + if offered.services and forbidden_backend_addresses is None: + msg = "Offered external backends require current host-address admission context" + raise ValueError(msg) + forbidden = {str(ipaddress.IPv4Address(address)) for address in (forbidden_backend_addresses or ())} + for service in offered.services: + backend = ipaddress.IPv4Address(service.backend_address) + if service.backend_address in forbidden or backend.is_link_local: + msg = "Offered external backends cannot target host or link-local addresses" + raise ValueError(msg) + if ipaddress.IPv4Address(service.backend_address) in own_interface.network: + msg = "An offered backend cannot target its offered network" + raise ValueError(msg) + if service.backend_port in {core_web_port, CONTAINER_LYCHD_PORT, CONTAINER_POSTGRES_PORT}: + msg = "Owner web access requires the SSH profile, not a generic backend" + raise ValueError(msg) + + +def _offered( + *, + rune: TetherRune, + snapshot: TetherSnapshot, + offered: OfferedNetwork, + runtime_root: Path, + config_root: Path, + core_web_port: int, + forbidden_backend_addresses: tuple[str, ...] | None, +) -> _NetworkBuilder: + _validate_offered( + offered=offered, + snapshot=snapshot, + core_web_port=core_web_port, + forbidden_backend_addresses=forbidden_backend_addresses, + ) + peers = tuple(sorted(snapshot.offered_peers, key=lambda peer: peer.id)) + active = tuple(peer for peer in peers if peer.enabled) + own_address = str(ipaddress.IPv4Interface(offered.address).ip) + # Declared owner capacity survives revocation; only active peers receive admission. + owner_port = core_web_port if any(peer.owner_access is not None for peer in peers) else None + builder = _NetworkBuilder( + rune=rune, + snapshot=snapshot, + network_id=offered.id, + generation=snapshot.offered_generation, + policy={ + "definition": offered.model_dump(mode="json"), + "peers": [peer.model_dump(mode="json") for peer in peers], + "generation": snapshot.offered_generation, + "owner_port": owner_port, + }, + runtime_root=runtime_root, + config_root=config_root, + pod_policy=TetherPodPolicy( + kind="offered", + udp_address=offered.listen_address, + udp_port=offered.listen_port, + owner_loopback_port=owner_port, + ), + ) + wg_peers: list[dict[str, object]] = [] + ingress: list[dict[str, object]] = [] + key_mounts = [(offered.private_key_ref, "/run/secrets/wg-private")] + services = {service.id: service for service in offered.services} + for peer in active: + psk = _key_path(peer.preshared_key_ref) if peer.preshared_key_ref else None + if peer.preshared_key_ref and psk: + key_mounts.append((peer.preshared_key_ref, psk)) + wg_peers.append( + { + "id": peer.id, + "generation": peer.generation, + "public_key": peer.public_key, + "allowed_ips": [f"{peer.address}/32"], + "endpoint_address": None, + "endpoint_port": None, + "keepalive": 0, + "preshared_key_path": psk, + } + ) + ingress.extend({"peer_address": peer.address, "port": services[name].listen_port} for name in peer.service_ids) + if peer.owner_access is not None: + ingress.append({"peer_address": peer.address, "port": peer.owner_access.listen_port}) + builder.role( + "wireguard", + { + "interface": offered.interface, + "address": offered.address, + "listen_port": offered.listen_port, + "private_key_path": "/run/secrets/wg-private", + "peers": wg_peers, + "ingress": ingress, + "egress": [ + {"address": service.backend_address, "port": service.backend_port, "protocol": "tcp"} + for service in offered.services + ], + "owner_loopback_port": owner_port, + }, + suffix="wg", + secrets=tuple(key_mounts), + ) + if offered.services: + builder.role( + "offered-relay", + { + "services": [ + { + "service_id": service.id, + "listen_address": own_address, + "listen_port": service.listen_port, + "backend_address": service.backend_address, + "backend_port": service.backend_port, + } + for service in offered.services + ], + "peers": [ + { + "id": peer.id, + "generation": peer.generation, + "address": peer.address, + "service_ids": list(peer.service_ids), + } + for peer in active + ], + }, + suffix="ingress", + ) + for peer in active: + owner = peer.owner_access + if owner is None: + continue + suffix = "ssh-" + hashlib.sha256(peer.id.encode()).hexdigest()[:12] + builder.role( + "owner-ssh", + { + "peer_id": peer.id, + "peer_generation": peer.generation, + "peer_address": peer.address, + "listen_address": own_address, + "listen_port": owner.listen_port, + "ssh_public_key": owner.ssh_public_key, + "ssh_host_key_path": "/run/secrets/ssh-host", + "target_address": "127.0.0.1", + "target_port": core_web_port, + }, + suffix=suffix, + secrets=((owner.ssh_host_key_ref, "/run/secrets/ssh-host"),), + ) + return builder + + +def _imported( + *, + rune: TetherRune, + snapshot: TetherSnapshot, + attachment: ImportedAttachment, + generation: int, + runtime_root: Path, + config_root: Path, + consumer_uid: int, +) -> tuple[_NetworkBuilder, tuple[TetherRoute, ...]]: + builder = _NetworkBuilder( + rune=rune, + snapshot=snapshot, + network_id=attachment.id, + generation=generation, + policy={ + "definition": attachment.model_dump(mode="json"), + "generation": generation, + "consumer_uid": consumer_uid, + }, + runtime_root=runtime_root, + config_root=config_root, + pod_policy=TetherPodPolicy(kind="imported"), + ) + psk = _key_path(attachment.preshared_key_ref) if attachment.preshared_key_ref else None + key_mounts = [(attachment.private_key_ref, "/run/secrets/wg-private")] + if attachment.preshared_key_ref and psk: + key_mounts.append((attachment.preshared_key_ref, psk)) + builder.role( + "wireguard", + { + "interface": attachment.interface, + "address": attachment.address, + "listen_port": 0, + "private_key_path": "/run/secrets/wg-private", + "peers": [ + { + "id": attachment.remote_lich_id, + "generation": generation, + "public_key": attachment.remote_public_key, + "allowed_ips": [ + f"{address}/32" for address in sorted({service.address for service in attachment.services}) + ], + "endpoint_address": attachment.endpoint_address, + "endpoint_port": attachment.endpoint_port, + "keepalive": attachment.keepalive, + "preshared_key_path": psk, + } + ], + "ingress": [], + "owner_loopback_port": None, + "egress": [ + {"address": attachment.endpoint_address, "port": attachment.endpoint_port, "protocol": "udp"}, + *( + {"address": service.address, "port": service.port, "protocol": "tcp"} + for service in attachment.services + ), + ], + }, + suffix="wg", + secrets=tuple(key_mounts), + ) + relay_unit = f"{builder.stem}-outbound.service" + rows: list[TetherRoute] = [] + for service in sorted(attachment.services, key=lambda service: service.id): + socket_name = ( + _digest( + { + "epoch": snapshot.installation_epoch, + "network": attachment.id, + "generation": generation, + "service": service.id, + "policy": builder.digest, + } + )[:32] + + ".sock" + ) + socket_path = str(CONTAINER_RUNTIME / builder.directory_name / socket_name) + if len(str(builder.runtime_directory / socket_name).encode()) > _UNIX_PATH_BYTES: + msg = "Host runtime root exceeds the UNIX socket path limit" + raise ValueError(msg) + rows.append( + TetherRoute( + selection=TetherSelection( + remote_lich_id=attachment.remote_lich_id, + attachment_id=attachment.id, + generation=generation, + service_id=service.id, + ), + installation_epoch=snapshot.installation_epoch, + policy_digest=builder.digest, + socket_path=socket_path, + hostname=service.hostname, + application_identity_ref=service.application_identity_ref, + address=service.address, + port=service.port, + allowed_callers=attachment.allowed_callers, + unit_name=relay_unit, + ) + ) + builder.role( + "imported-relay", + { + "caller_uid": consumer_uid, + "remote_lich_id": attachment.remote_lich_id, + "allowed_callers": list(attachment.allowed_callers), + "routes": [ + { + "service_id": row.selection.service_id, + "generation": generation, + "socket_path": row.socket_path, + "address": row.address, + "port": row.port, + "application_identity_ref": row.application_identity_ref, + } + for row in rows + ], + }, + suffix="outbound", + sockets=True, + ) + return builder, tuple(rows) + + +def _validate_secret_custody(rune: TetherRune, snapshot: TetherSnapshot) -> None: + """Forbid one secret reference crossing a network, peer or credential purpose.""" + references: list[str] = [] + for attachment in rune.attachments: + references.append(attachment.private_key_ref) + if attachment.preshared_key_ref: + references.append(attachment.preshared_key_ref) + if rune.offered is not None: + references.append(rune.offered.private_key_ref) + for peer in snapshot.offered_peers: + if peer.preshared_key_ref: + references.append(peer.preshared_key_ref) + if peer.owner_access: + references.append(peer.owner_access.ssh_host_key_ref) + if len(references) != len(set(references)): + msg = "Tether secret references cannot cross network, peer or credential-purpose boundaries" + raise ValueError(msg) + + +def compile_tether( + *, + rune: TetherRune, + snapshot: TetherSnapshot, + runtime_root: Path, + config_root: Path, + core_web_port: int, + consumer_uid: int, + forbidden_backend_addresses: tuple[str, ...] | None = None, +) -> TetherDeployment: + """Compile current admitted topology without discovering or changing host state.""" + rune = TetherRune.model_validate(rune.model_dump(mode="json")) + snapshot = TetherSnapshot.model_validate(snapshot.model_dump(mode="json")) + _validate_secret_custody(rune, snapshot) + _require_root(runtime_root) + _require_root(config_root) + if isinstance(core_web_port, bool) or not 1 <= core_web_port <= _MAX_PORT: + msg = "Invalid Core owner loopback port" + raise ValueError(msg) + if isinstance(consumer_uid, bool) or not 0 <= consumer_uid <= 2**32 - 2: + msg = "Invalid admitted consumer UID" + raise ValueError(msg) + declared = {attachment.id: attachment for attachment in rune.attachments} + if any(admission.attachment_id not in declared for admission in snapshot.attachments): + msg = "Snapshot refers to an undeclared attachment" + raise ValueError(msg) + if rune.offered is None and snapshot.offered_peers: + msg = "Peer admission requires an offered network" + raise ValueError(msg) + builders: list[_NetworkBuilder] = [] + rows: list[TetherRoute] = [] + if rune.offered is not None: + builders.append( + _offered( + rune=rune, + snapshot=snapshot, + offered=rune.offered, + runtime_root=runtime_root, + config_root=config_root, + core_web_port=core_web_port, + forbidden_backend_addresses=forbidden_backend_addresses, + ) + ) + for admission in sorted(snapshot.attachments, key=lambda admission: admission.attachment_id): + if not admission.enabled: + continue + builder, routes = _imported( + rune=rune, + snapshot=snapshot, + attachment=declared[admission.attachment_id], + generation=admission.generation, + runtime_root=runtime_root, + config_root=config_root, + consumer_uid=consumer_uid, + ) + builders.append(builder) + rows.extend(routes) + return TetherDeployment( + manifests=tuple(manifest for builder in builders for manifest in builder.manifests), + required_secrets=tuple(sorted({secret for builder in builders for secret in builder.secrets})), + runtime_configs=tuple(config for builder in builders for config in builder.configs), + routes=tuple(rows), + networks=tuple(builder.network() for builder in builders), + consumer_mount=MountData( + host_path=runtime_root, container_path=CONTAINER_RUNTIME, mirror=False, options=["ro", "z"] + ), + ) diff --git a/src/lychd/domain/tether/models.py b/src/lychd/domain/tether/models.py new file mode 100644 index 00000000..a3ff1010 --- /dev/null +++ b/src/lychd/domain/tether/models.py @@ -0,0 +1,339 @@ +"""Closed public Tether intent, admission snapshots, and selected service identities. + +These values perform no I/O and contain secret references, never secret values. +The host supplies the current admission snapshot; a Rune cannot authorize itself. +""" + +from __future__ import annotations + +import base64 +import binascii +import ipaddress +import re +from pathlib import Path +from typing import Annotated, ClassVar, Literal + +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator + +from lychd.config.quadlet import QuadletConfig +from lychd.config.runes.base import RuneConfig +from lychd.system.secret_names import validate_podman_secret_name + +_WG_KEY_BYTES = 32 +_DNS_LABEL_BYTES = 63 +_SSH_KEY_PARTS = 2 +_SSH_ED25519_WIRE_BYTES = 51 +_UNIX_PATH_BYTES = 107 + +_ID = re.compile(r"[a-z0-9][a-z0-9_-]{0,47}\Z") +_HOST = re.compile(r"(?=.{1,253}\Z)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\Z") + + +def _identifier(value: str) -> str: + if _ID.fullmatch(value) is None: + msg = "Tether identity must be a bounded lowercase identifier" + raise ValueError(msg) + return value + + +def _address(value: str) -> str: + address = ipaddress.IPv4Address(value) + if str(address) != value or address.is_unspecified or address.is_multicast or address.is_loopback: + msg = "Tether requires a canonical non-loopback unicast IPv4 address" + raise ValueError(msg) + if value == "255.255.255.255": + msg = "Tether refuses the broadcast address" + raise ValueError(msg) + return value + + +def _interface_name(value: str) -> str: + if re.fullmatch(r"[a-z][a-z0-9]{0,14}", value) is None or value in {"lo", "eth0", "tap0"}: + message = "Tether interface must not replace a reserved namespace device" + raise ValueError(message) + return value + + +def _interface_address(value: str) -> str: + address = ipaddress.IPv4Interface(value) + _address(str(address.ip)) + if str(address) != value or address.network.prefixlen == 0: + msg = "Tether interface requires a canonical explicit IPv4 prefix" + raise ValueError(msg) + return value + + +def _key(value: str) -> str: + try: + decoded = base64.b64decode(value, validate=True) + except (ValueError, binascii.Error) as error: + msg = "Invalid WireGuard public key" + raise ValueError(msg) from error + if len(decoded) != _WG_KEY_BYTES or not any(decoded) or base64.b64encode(decoded).decode() != value: + msg = "Invalid WireGuard public key" + raise ValueError(msg) + return value + + +def _secret(value: str) -> str: + validate_podman_secret_name(value, field_name="Tether secret reference") + if not value.startswith("lychd-tether-"): + msg = "Tether secrets require their purpose-specific namespace" + raise ValueError(msg) + return value + + +def _hostname(value: str) -> str: + if _HOST.fullmatch(value) is None: + msg = "Tether requires a canonical TLS hostname" + raise ValueError(msg) + for label in value.split("."): + if not label or len(label) > _DNS_LABEL_BYTES or label.startswith("-") or label.endswith("-"): + msg = "Invalid TLS hostname label" + raise ValueError(msg) + return value + + +def _ssh_key(value: str) -> str: + parts = value.split(" ") + if len(parts) != _SSH_KEY_PARTS or parts[0] != "ssh-ed25519": + msg = "Owner access accepts only one uncommented Ed25519 public key" + raise ValueError(msg) + try: + raw = base64.b64decode(parts[1], validate=True) + except (ValueError, binascii.Error) as error: + msg = "Invalid SSH public key" + raise ValueError(msg) from error + if len(raw) != _SSH_ED25519_WIRE_BYTES or raw[:19] != b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20": + msg = "Invalid Ed25519 SSH wire key" + raise ValueError(msg) + if not any(raw[19:]) or base64.b64encode(raw).decode() != parts[1]: + msg = "Invalid SSH public key" + raise ValueError(msg) + return value + + +Identifier = Annotated[str, AfterValidator(_identifier)] +Address = Annotated[str, AfterValidator(_address)] +InterfaceAddress = Annotated[str, AfterValidator(_interface_address)] +PublicKey = Annotated[str, AfterValidator(_key)] +SecretRef = Annotated[str, AfterValidator(_secret)] +Hostname = Annotated[str, AfterValidator(_hostname)] +SSHKey = Annotated[str, AfterValidator(_ssh_key)] +Port = Annotated[int, Field(strict=True, ge=1, le=65535)] +Generation = Annotated[int, Field(strict=True, ge=1, le=2**63 - 1)] +Epoch = Annotated[str, Field(pattern=r"^[0-9a-f]{32}$")] +Digest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + + +class TetherValue(BaseModel): + """Immutable, closed public data accepted across the Tether boundary.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class TetherSelection(TetherValue): + """A complete service selection; addresses alone never select a road.""" + + remote_lich_id: Identifier + attachment_id: Identifier + generation: Generation + service_id: Identifier + + +class ImportedService(TetherValue): + """Exact TCP coordinates with independently verified application hostname.""" + + id: Identifier + address: Address + port: Port + hostname: Hostname + application_identity_ref: Identifier + protocol: Literal["tcp"] = "tcp" + + +class OfferedService(TetherValue): + """An independently authenticated external backend, never a Core listener.""" + + id: Identifier + listen_port: Port + backend_address: Address + backend_port: Port + + +class OwnerAccess(TetherValue): + """Optional same-owner SSH forwarding; the compiler owns its fixed target.""" + + ssh_public_key: SSHKey + ssh_host_key_ref: SecretRef + listen_port: Port + + +class OfferedPeer(TetherValue): + """Current public peer admission or retained disabled tombstone.""" + + id: Identifier + generation: Generation + public_key: PublicKey + address: Address + service_ids: tuple[Identifier, ...] = Field(default=(), max_length=64) + owner_access: OwnerAccess | None = None + enabled: bool = Field(default=True, strict=True) + preshared_key_ref: SecretRef | None = None + + @model_validator(mode="after") + def unique_services(self) -> OfferedPeer: + """Refuse ambiguous duplicate service admission.""" + if len(self.service_ids) != len(set(self.service_ids)): + msg = "Peer service admissions must be unique" + raise ValueError(msg) + return self + + +class OfferedNetwork(TetherValue): + """One offered routing domain; peer admission is supplied separately.""" + + id: Identifier + interface: Annotated[str, AfterValidator(_interface_name)] + address: InterfaceAddress + listen_address: Annotated[str, AfterValidator(lambda value: str(ipaddress.IPv4Address(value)))] + listen_port: Port + private_key_ref: SecretRef + services: tuple[OfferedService, ...] = Field(default=(), max_length=64) + + @model_validator(mode="after") + def validate_services(self) -> OfferedNetwork: + """Reserve each service listener and name once inside the network.""" + if len({service.id for service in self.services}) != len(self.services): + msg = "Offered service identities must be unique" + raise ValueError(msg) + ports = [self.listen_port, *(service.listen_port for service in self.services)] + if len(ports) != len(set(ports)): + msg = "Offered service ports must be distinct" + raise ValueError(msg) + return self + + +class ImportedAttachment(TetherValue): + """One isolated routing domain with static endpoint and service resolution.""" + + id: Identifier + remote_lich_id: Identifier + interface: Annotated[str, AfterValidator(_interface_name)] + address: InterfaceAddress + private_key_ref: SecretRef + remote_public_key: PublicKey + preshared_key_ref: SecretRef | None = None + endpoint_address: Address + endpoint_port: Port + keepalive: Annotated[int, Field(strict=True, ge=0, le=65535)] = 25 + services: tuple[ImportedService, ...] = Field(min_length=1, max_length=64) + allowed_callers: tuple[Identifier, ...] = Field(min_length=1, max_length=64) + dns_policy: Literal["admitted-static"] = "admitted-static" + + @model_validator(mode="after") + def validate_services(self) -> ImportedAttachment: + """Keep inner destinations distinct from the outer contact and local address.""" + if not self.services or not self.allowed_callers: + msg = "Imported attachments require services and admitted callers" + raise ValueError(msg) + if len({service.id for service in self.services}) != len(self.services): + msg = "Imported service identities must be unique" + raise ValueError(msg) + if len(self.allowed_callers) != len(set(self.allowed_callers)): + msg = "Imported callers must be unique" + raise ValueError(msg) + local_interface = ipaddress.IPv4Interface(self.address) + if ipaddress.IPv4Address(self.endpoint_address) in local_interface.network: + message = "Outer WireGuard endpoint cannot route through the local tunnel prefix" + raise ValueError(message) + local = str(local_interface.ip) + if any(service.address in {local, self.endpoint_address} for service in self.services): + msg = "Inner service addresses cannot alias local or outer endpoint addresses" + raise ValueError(msg) + return self + + +class AttachmentAdmission(TetherValue): + """Host-owned admission generation independent of other attachments.""" + + attachment_id: Identifier + generation: Generation + enabled: bool = Field(default=True, strict=True) + + +class TetherSnapshot(TetherValue): + """Current host-supplied public authority; restore never makes it fresh itself.""" + + installation_epoch: Epoch + offered_generation: Generation = Field( + default=1, description="Network topology generation; peer-only mutations change only peer generations." + ) + offered_peers: tuple[OfferedPeer, ...] = Field(default=(), max_length=128) + attachments: tuple[AttachmentAdmission, ...] = Field(default=(), max_length=32) + + @model_validator(mode="after") + def unique_admissions(self) -> TetherSnapshot: + """Include tombstones in collision checks to prevent unsafe address reuse.""" + for values in ( + [peer.id for peer in self.offered_peers], + [peer.address for peer in self.offered_peers], + [peer.public_key for peer in self.offered_peers], + [entry.attachment_id for entry in self.attachments], + ): + if len(values) != len(set(values)): + msg = "Tether admission identities, keys and addresses must be unique" + raise ValueError(msg) + return self + + +class TetherRune(RuneConfig): + """Static Tether topology; the separate admission snapshot authorizes activation.""" + + path_fragment: ClassVar[Path] = Path("tether") + profile: Literal["rootless-wireguard-stream-v1"] = "rootless-wireguard-stream-v1" + quadlet: QuadletConfig + offered: OfferedNetwork | None = None + attachments: tuple[ImportedAttachment, ...] = Field(default=(), max_length=32) + + @model_validator(mode="after") + def unique_networks(self) -> TetherRune: + """Keep credential custody separate across independently managed networks.""" + names = [attachment.id for attachment in self.attachments] + keys = [attachment.private_key_ref for attachment in self.attachments] + if self.offered is not None: + names.append(self.offered.id) + keys.append(self.offered.private_key_ref) + if len(names) != len(set(names)) or len(keys) != len(set(keys)): + msg = "Tether networks require unique identities and private-key references" + raise ValueError(msg) + return self + + +class TetherRoute(TetherValue): + """Public Core catalog row with an exact immutable socket selection.""" + + selection: TetherSelection + installation_epoch: Epoch + policy_digest: Digest + socket_path: str + hostname: Hostname + application_identity_ref: Identifier + address: Address + port: Port + allowed_callers: tuple[Identifier, ...] = Field(min_length=1, max_length=64) + unit_name: str + + @model_validator(mode="after") + def exact_socket(self) -> TetherRoute: + """Reject path traversal, overlong UNIX addresses and arbitrary socket targets.""" + if re.fullmatch(r"/run/lychd-tether/[0-9a-f]{16}/[0-9a-f]{32}\.sock", self.socket_path) is None: + msg = "Tether route socket must be a compiler-owned generation address" + raise ValueError(msg) + if len(self.socket_path.encode()) > _UNIX_PATH_BYTES or not self.allowed_callers: + msg = "Invalid Tether route socket or caller admission" + raise ValueError(msg) + if re.fullmatch(r"lychd-tether-[a-z0-9-]+\.service", self.unit_name) is None: + msg = "Invalid Tether route unit" + raise ValueError(msg) + return self diff --git a/src/lychd/extensions/builtin/catalog.py b/src/lychd/extensions/builtin/catalog.py index 9afb166a..baf8ba16 100644 --- a/src/lychd/extensions/builtin/catalog.py +++ b/src/lychd/extensions/builtin/catalog.py @@ -17,6 +17,7 @@ class BuiltinExtension: BUILTIN_EXTENSIONS: Final = MappingProxyType( { + "tether": BuiltinExtension(register_module="lychd.extensions.builtin.tether.register"), "animator": BuiltinExtension( register_module="lychd.extensions.builtin.animator.register", ), diff --git a/src/lychd/extensions/builtin/tether/__init__.py b/src/lychd/extensions/builtin/tether/__init__.py new file mode 100644 index 00000000..c54c0ec5 --- /dev/null +++ b/src/lychd/extensions/builtin/tether/__init__.py @@ -0,0 +1 @@ +"""Native Tether schema registration; physical effects remain host-owned.""" diff --git a/src/lychd/extensions/builtin/tether/register.py b/src/lychd/extensions/builtin/tether/register.py new file mode 100644 index 00000000..cdb4eaae --- /dev/null +++ b/src/lychd/extensions/builtin/tether/register.py @@ -0,0 +1,12 @@ +"""Register Tether's typed intent without a filesystem-owning contributor.""" + +from __future__ import annotations + +from lychd.extensions.context import ExtensionRegistrationContext + + +def register(context: ExtensionRegistrationContext) -> None: + """Expose only the Rune; the complete host bind compiler supplies admission.""" + from lychd.domain.tether.models import TetherRune + + context.runes.add_schema(TetherRune) diff --git a/src/lychd/system/constants.py b/src/lychd/system/constants.py index f753cddb..02db78a5 100644 --- a/src/lychd/system/constants.py +++ b/src/lychd/system/constants.py @@ -81,6 +81,18 @@ PATH_CRYPT_ROOT: Final[Path] = PATH_XDG_DATA_HOME / DEFAULT_MODULE_NAME # ~/.local/share/lychd """Persistent LychD data and workspaces.""" +PATH_TETHER_STATE_DIR: Final[Path] = PATH_CRYPT_ROOT / "tether" +"""Host-owned Tether admission, tombstones and public runtime projections.""" + +PATH_TETHER_CONFIG_DIR: Final[Path] = PATH_TETHER_STATE_DIR / "runtime" +"""Per-role public configuration directories; never private key material.""" + +PATH_TETHER_RUNTIME_DIR: Final[Path] = Path("/run/user") / str(os.getuid()) / "lychd-tether" +"""Boot-local sockets: recreate safely; never persist inode identity as authority.""" + +CONTAINER_TETHER_RUNTIME_DIR: Final[Path] = Path("/run/lychd-tether") +"""The only Core-facing Tether socket mount.""" + PATH_RUNTIME_GUARD_DIR: Final[Path] = PATH_CRYPT_ROOT / "runtime-guard" """Durable Vessel-owned soft activation fence, separate from the Host Reactor journal.""" diff --git a/src/lychd/system/operator/control.py b/src/lychd/system/operator/control.py index 98939b53..cc0f75d1 100644 --- a/src/lychd/system/operator/control.py +++ b/src/lychd/system/operator/control.py @@ -22,6 +22,9 @@ from lychd.system.operator.targets import OperatorTargetResolver _SYSTEMCTL_TIMEOUT_SECONDS = 30.0 +# Cold starts include the 60s database readiness gate and prerequisite units. +# Match the controller convergence window without changing manager completion semantics. +_SYSTEMCTL_START_TIMEOUT_SECONDS = 360.0 _COUNTERPART_TIMEOUT_SECONDS = 3.0 logger = structlog.get_logger(__name__) @@ -153,10 +156,15 @@ def _execute_locked( authority=authority.value, units=units, ) + timeout_s = _SYSTEMCTL_START_TIMEOUT_SECONDS if action is OperatorAction.START else _SYSTEMCTL_TIMEOUT_SECONDS try: - result = self._runner.run(argv, timeout_s=_SYSTEMCTL_TIMEOUT_SECONDS) + result = self._runner.run(argv, timeout_s=timeout_s) except ProcessInvocationError as exc: - message = f"systemctl {action.value} failed: {exc}" + message = ( + f"systemctl start outcome is unconfirmed; the manager job may continue: {exc}" + if action is OperatorAction.START + else f"systemctl {action.value} failed: {exc}" + ) logger.exception( "operator_actuation_failed", action=action.value, diff --git a/src/lychd/system/schemas.py b/src/lychd/system/schemas.py index a04a2b7e..ea80caa0 100644 --- a/src/lychd/system/schemas.py +++ b/src/lychd/system/schemas.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ipaddress import re from pathlib import Path, PurePosixPath from typing import Any, Final, Literal @@ -237,6 +238,48 @@ def from_str(cls, val: str) -> MountData: return cls.model_validate(val) +class TetherPodPolicy(BaseModel): + """Closed network policy emitted only by the host Tether compiler.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + kind: Literal["offered", "imported"] + udp_address: str | None = None + udp_port: int | None = Field(default=None, strict=True, ge=1, le=65535) + owner_loopback_port: int | None = Field(default=None, strict=True, ge=1, le=65535) + + network: str = "" + + def _network(self) -> str: + """Render only the bounded pasta flags belonging to this profile.""" + value = "pasta:--ipv4-only,--no-map-gw,--map-host-loopback,none,--map-guest-addr,none,-t,none,-U,none" + value += f",-T,{self.owner_loopback_port}" if self.owner_loopback_port else ",-T,none" + if self.kind == "imported": + value += ",-u,none" + return value + + @model_validator(mode="after") + def validate_topology(self) -> TetherPodPolicy: + """No wildcard TCP exposure, host namespace or ambient loopback alias.""" + expected_network = self._network() + if self.network and self.network != expected_network: + msg = "Tether networking cannot override the closed pasta profile" + raise ValueError(msg) + object.__setattr__(self, "network", expected_network) + if self.kind == "imported": + if any(value is not None for value in (self.udp_address, self.udp_port, self.owner_loopback_port)): + msg = "Imported Tether pods cannot publish or bridge host ports" + raise ValueError(msg) + elif self.udp_address is None or self.udp_port is None: + msg = "Offered Tether pods require exact UDP publication" + raise ValueError(msg) + if self.udp_address is not None: + address = ipaddress.IPv4Address(self.udp_address) + if str(address) != self.udp_address or address.is_multicast: + msg = "Tether UDP publication requires canonical IPv4" + raise ValueError(msg) + return self + + class QuadletContainer(QuadletBase): """Data model for 'container.jinja'. Represents a single [Container] Quadlet file. @@ -244,6 +287,8 @@ class QuadletContainer(QuadletBase): Raw Podman arguments admit only explicitly supported complete values. """ + tether_role: Literal["wireguard", "imported-relay", "offered-relay", "owner-ssh"] | None = None + # [Unit] section description: str wants: list[str] = Field(default_factory=lambda: ["network-online.target"]) @@ -385,6 +430,30 @@ def validate_secret_mount_tmpfs(self) -> QuadletContainer: raise ValueError(msg) return self + @model_validator(mode="after") + def validate_tether_role(self) -> QuadletContainer: + """Confine special capabilities to standalone compiled Tether bodies.""" + if self.tether_role is not None: + if ( + not self.container_name.startswith("lychd-tether-") + or self.pod is None + or not self.pod.startswith("lychd-tether-") + or not self.read_only + or self.run_init + or self.devices + or self.podman_args != ["--replace"] + or self.security_label_disable + or self.user_ns is not None + or self.wanted_by + ): + msg = "Tether roles require a closed standalone rootless deployment" + raise ValueError(msg) + expected_user = "0" if self.tether_role in {"wireguard", "owner-ssh"} else "%U" + if self.user != expected_user: + msg = "Tether role user mapping differs from its compiled policy" + raise ValueError(msg) + return self + @model_validator(mode="after") def derive_pod_service(self) -> QuadletContainer: """Resolve a Quadlet source name to the systemd service it generates.""" @@ -400,6 +469,7 @@ class QuadletPod(QuadletBase): The Sepulcher—the physical boundary of the Daemon's presence. """ + tether_policy: TetherPodPolicy | None = None description: str = "The Sepulcher (LychD Pod)" pod_name: str = "lychd" publish_ports: list[str] = Field(default_factory=list) @@ -435,6 +505,19 @@ def validate_publish_ports(cls, values: list[str]) -> list[str]: seen_host_ports.add(host_port) return values + @model_validator(mode="after") + def validate_tether_pod(self) -> QuadletPod: + """Keep special networking outside the Core pod and generic port grammar.""" + if self.tether_policy is not None and ( + not self.pod_name.startswith("lychd-tether-") + or self.publish_ports + or self.user_ns != "keep-id" + or self.wanted_by + ): + msg = "Tether networking requires its own explicitly activated pod" + raise ValueError(msg) + return self + @field_validator("wanted_by") @classmethod def validate_wanted_by(cls, values: list[str]) -> list[str]: diff --git a/src/lychd/system/services/bind.py b/src/lychd/system/services/bind.py index 60b3d01b..b930968c 100644 --- a/src/lychd/system/services/bind.py +++ b/src/lychd/system/services/bind.py @@ -6,7 +6,7 @@ from contextlib import AbstractContextManager from dataclasses import dataclass from enum import StrEnum -from typing import Never, Protocol +from typing import TYPE_CHECKING, Never, Protocol import structlog @@ -20,6 +20,9 @@ ) from lychd.system.services.scribe.models import BindingReconcilePlan +if TYPE_CHECKING: + from lychd.domain.tether.compiler import TetherDeployment + logger = structlog.get_logger() @@ -127,6 +130,7 @@ class BindRequest: plain_units: tuple[tuple[str, str], ...] core_secrets: tuple[CoreSecret, ...] required_secret_names: tuple[str, ...] + tether: TetherDeployment | None = None @classmethod def compile( @@ -136,6 +140,7 @@ def compile( plain_units: Mapping[str, str], core_secret_factories: Mapping[str, Callable[[], str]], required_secret_names: Sequence[str], + tether: TetherDeployment | None = None, ) -> BindRequest: """Canonicalize caller-owned collections into deterministic intent.""" core_names = tuple(sorted(core_secret_factories)) @@ -149,6 +154,7 @@ def compile( plain_units=tuple(sorted(plain_units.items())), core_secrets=tuple(CoreSecret(name=name, factory=core_secret_factories[name]) for name in core_names), required_secret_names=required, + tether=tether, ) @property @@ -199,6 +205,7 @@ def __init__( foundation: BindingFoundation, foundation_probe: Callable[[], BindingFoundation], lock_factory: Callable[[], AbstractContextManager[object]], + request_attestor: Callable[[BindRequest], None] | None = None, ) -> None: """Bind the use case to narrow mutation and revalidation ports.""" self._scribe = scribe @@ -207,6 +214,7 @@ def __init__( self._foundation = foundation self._foundation_probe = foundation_probe self._lock_factory = lock_factory + self._request_attestor = request_attestor def plan( self, @@ -240,6 +248,7 @@ def apply(self, request: BindRequest, approved: BindPlan) -> BindResult: self._require_external_secrets(approved) with self._lock_factory(): + self._attest_request(request) self._require_approved_state(request, approved) created = self._reconcile_core_secrets(request, approved) self._require_secrets_at_commit(request, created=created) @@ -257,6 +266,22 @@ def apply(self, request: BindRequest, approved: BindPlan) -> BindResult: binding_generation=committed_generation, ) + def _attest_request(self, request: BindRequest, *, created: tuple[str, ...] = ()) -> None: + """Revalidate owner-specific intent and identities without losing prior effects.""" + if self._request_attestor is None: + return + try: + self._request_attestor(request) + except BaseException as exc: # cancellation retains confirmed secret creation + if not created: + raise + self._raise_partial( + "Binding intent attestation failed before commit", + phase="intent-attestation", + progress=BindProgress(created_secrets=created), + error=exc, + ) + @staticmethod def _require_external_secrets(approved: BindPlan) -> None: """Reject a plan whose operator-supplied secrets are absent.""" @@ -335,6 +360,7 @@ def _require_secrets_at_commit( created: tuple[str, ...], ) -> None: """Revalidate every secret without losing confirmed creation on probe failure.""" + self._attest_request(request, created=created) try: observed = self._observe_secrets(request.secret_names) except BaseException as exc: # cancellation must retain confirmed secret creation diff --git a/src/lychd/system/services/bind_assembly.py b/src/lychd/system/services/bind_assembly.py index bcb20a7a..5bfca57c 100644 --- a/src/lychd/system/services/bind_assembly.py +++ b/src/lychd/system/services/bind_assembly.py @@ -7,8 +7,11 @@ from lychd.system.services.bind import BindUseCase if TYPE_CHECKING: + from collections.abc import Callable + from lychd.config.settings import SettingsSnapshot from lychd.system.readiness import BindingFoundation + from lychd.system.services.bind import BindRequest from lychd.system.services.binding_preflight import BindingPreflightService @@ -17,6 +20,7 @@ def assemble_bind_use_case( foundation: BindingFoundation, preflight_service: BindingPreflightService, settings_snapshot: SettingsSnapshot, + request_attestor: Callable[[BindRequest], None] | None = None, ) -> BindUseCase: """Wire concrete host adapters behind the reusable bind use case.""" from lychd.system.services.lifecycle.lock import LifecycleLock @@ -45,6 +49,7 @@ def revalidate_foundation() -> BindingFoundation: foundation=foundation, foundation_probe=revalidate_foundation, lock_factory=LifecycleLock, + request_attestor=request_attestor, ) diff --git a/src/lychd/system/services/bind_compilation.py b/src/lychd/system/services/bind_compilation.py index 0c844859..ef97bd9e 100644 --- a/src/lychd/system/services/bind_compilation.py +++ b/src/lychd/system/services/bind_compilation.py @@ -16,6 +16,8 @@ from lychd.config.settings.root import Settings from lychd.domain.animation.schemas import PortalConfig, SoulstoneConfig from lychd.domain.animation.services.adapters.contracts import RuntimePlan + from lychd.domain.tether.compiler import TetherDeployment + from lychd.domain.tether.models import TetherSnapshot from lychd.extensions.host import AssembledExtensions @@ -43,6 +45,11 @@ def compile_bind_request( runes: RuneRegistry, soulstones: Sequence[SoulstoneConfig], portals: Sequence[PortalConfig], + tether_snapshot: TetherSnapshot | None = None, + tether_runtime_root: Path | None = None, + tether_config_root: Path | None = None, + tether_consumer_uid: int | None = None, + tether_forbidden_backend_addresses: tuple[str, ...] | None = None, ) -> BindRequest: """Compile the complete container binding generation from declared intent.""" from lychd.domain.animation.capabilities import CapabilitySpec @@ -80,6 +87,34 @@ def compile_bind_request( runes=runes, runtime_plans=runtime_plans, ) + tether = _compile_tether_contribution( + settings=settings, + extensions=extensions, + runes=runes, + snapshot=tether_snapshot, + runtime_root=tether_runtime_root, + config_root=tether_config_root, + consumer_uid=tether_consumer_uid, + forbidden_backend_addresses=tether_forbidden_backend_addresses, + ) + if tether is not None: + existing_secrets = { + podman_secret_source(spec) + for manifest in manifests + if isinstance(manifest, QuadletContainer) + for spec in manifest.secrets + } + if existing_secrets.intersection(tether.required_secrets): + msg = "Tether credentials cannot alias Core or other application credentials" + raise ValueError(msg) + manifests.extend(tether.manifests) + if tether.consumer_mount is not None: + manifests = [ + manifest.model_copy(update={"volumes": [*manifest.volumes, tether.consumer_mount]}) + if isinstance(manifest, QuadletContainer) and manifest.container_name == "lychd-vessel" + else manifest + for manifest in manifests + ] core_secret_factories = { settings.server.web.secret_key_secret: (lambda: secrets.token_hex(32)), settings.server.database.password_secret: (lambda: secrets.token_urlsafe(16)), @@ -87,6 +122,9 @@ def compile_bind_request( settings.server.database.phoenix_password_secret: (lambda: secrets.token_urlsafe(32)), settings.server.web.access_password_secret: (lambda: secrets.token_urlsafe(32)), } + if tether is not None and set(core_secret_factories).intersection(tether.required_secrets): + msg = "Tether credentials cannot use a Core-generated secret name" + raise ValueError(msg) manifest_secrets = { podman_secret_source(spec) for manifest in manifests @@ -100,6 +138,7 @@ def compile_bind_request( manifests=manifests, plain_units=_desired_plain_units(settings=settings), core_secret_factories=core_secret_factories, + tether=tether, required_secret_names=sorted( { *required_soulstone_secrets, @@ -153,3 +192,41 @@ def _desired_plain_units( "compile_bind_request", "required_secret_names_from_soulstones", ] + + +def _compile_tether_contribution( + *, + settings: Settings, + extensions: AssembledExtensions, + runes: RuneRegistry, + snapshot: TetherSnapshot | None, + runtime_root: Path | None, + config_root: Path | None, + consumer_uid: int | None, + forbidden_backend_addresses: tuple[str, ...] | None, +) -> TetherDeployment | None: + """Require explicit current host authority for the one trusted Tether compiler.""" + from lychd.domain.tether.compiler import compile_tether + from lychd.domain.tether.models import TetherRune + + rune = runes.one_or_none(TetherRune) + if rune is None: + if snapshot is not None: + msg = "Tether admission requires its selected Rune" + raise ValueError(msg) + return None + if "tether" not in settings.extensions.builtins or TetherRune not in extensions.rune_schemas: + msg = "Tether Rune was not registered by its selected native extension" + raise ValueError(msg) + if snapshot is None or runtime_root is None or config_root is None or consumer_uid is None: + msg = "Tether bind requires explicit current admission and owned projection roots" + raise ValueError(msg) + return compile_tether( + rune=rune, + snapshot=snapshot, + runtime_root=runtime_root, + config_root=config_root, + core_web_port=settings.server.port, + consumer_uid=consumer_uid, + forbidden_backend_addresses=forbidden_backend_addresses, + ) diff --git a/src/lychd/system/services/codex.py b/src/lychd/system/services/codex.py index eaa874d7..d95a756b 100644 --- a/src/lychd/system/services/codex.py +++ b/src/lychd/system/services/codex.py @@ -60,6 +60,7 @@ def __init__( templates_dir: Path | None = None, postgres_root_path: Path | None = None, settings: Settings | None = None, + initial_runes: Sequence[RuneConfig] = (), ) -> None: """Bind inscription paths and optional prepared Settings for new files only.""" self.toml_path = toml_path or PATH_LYCHD_TOML @@ -68,6 +69,11 @@ def __init__( self.postgres_root_path = postgres_root_path or PATH_POSTGRES_ROOT_DIR self.rune_schemas = list(rune_schemas) self._settings = settings + self.initial_runes = tuple(initial_runes) + types = [type(rune) for rune in self.initial_runes] + if len(types) != len(set(types)) or any(schema not in self.rune_schemas for schema in types): + message = "Initial runes must have unique admitted schemas." + raise ValueError(message) self._env = Environment( loader=FileSystemLoader(self.templates_dir), @@ -76,6 +82,26 @@ def __init__( lstrip_blocks=True, ) + def settings_content(self) -> str: + """Render exactly the settings bytes the journaled inscription will create.""" + settings = self._settings if self._settings is not None else get_settings() + return _render_default_settings_toml(settings) + + def initial_files(self) -> dict[Path, str]: + """Return exact active Rune bytes included in the original creation plan.""" + return { + type(rune).anchor_dir(self.runes_path) / "default.toml": _toml_dumps( + rune.model_dump(mode="json", exclude_none=True) + ) + for rune in self.initial_runes + } + + @property + def sample_schemas(self) -> list[type[RuneConfig]]: + """Exclude explicitly initialized active schemas from inactive examples.""" + active = {type(rune) for rune in self.initial_runes} + return [schema for schema in self.rune_schemas if schema not in active] + def inscribe( self, *, @@ -97,8 +123,7 @@ def inscribe( def _inscribe_lychd_toml(self, creation: JournaledCreation) -> None: """Publish newly prepared Settings without overwriting existing configuration.""" - settings = self._settings if self._settings is not None else get_settings() - content = _render_default_settings_toml(settings) + content = self.settings_content() resources = creation.create_text_file( self.toml_path, content, @@ -133,6 +158,8 @@ def _inscribe_configurables( creation=creation, ) writer.initialize_anchors(self.rune_schemas) - writer.inscribe_samples(self.rune_schemas) + for path, content in self.initial_files().items(): + creation.create_text_file(path, content, mode=0o600) + writer.inscribe_samples(self.sample_schemas) logger.info("configurable_anchors_inscribed", count=len(self.rune_schemas), runes_root=str(self.runes_path)) diff --git a/src/lychd/system/services/deployment.py b/src/lychd/system/services/deployment.py index 999b2602..980f3da9 100644 --- a/src/lychd/system/services/deployment.py +++ b/src/lychd/system/services/deployment.py @@ -14,7 +14,7 @@ import stat import sys import tomllib -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -42,6 +42,12 @@ VERSION = 1 _REVISION = re.compile(r"[0-9a-f]{40}") _IMAGE_ID = re.compile(r"(?:sha256:)?([0-9a-f]{64})") +_RUNTIME_ID = re.compile(r"[0-9a-f]{64}") +_INVOCATION_ID = re.compile(r"[0-9a-f]{32}") +_POD_PROJECTION = "[{{json .ID}},{{json .Name}},{{json .CreateInfra}},{{json .InfraContainerID}}]" +_INFRA_PROJECTION = ( + "[{{json .ID}},{{json .Name}},{{json .Pod}},{{json .IsInfra}},{{json .State.Running}},{{json .State.ConmonPid}}]" +) _COMMON_PROPERTIES = ( "Id", "LoadState", @@ -67,9 +73,27 @@ class DeploymentRequest: revision: str image_id: str | None = None verify: bool = False + profile: str = "empty-extensions-container" + intent_path: Path | None = None + tether_image_id: str | None = None def __post_init__(self) -> None: """Reject unpinned campaign identities before host inspection.""" + if self.profile not in {"empty-extensions-container", "tether-container-v1"}: + msg = "Unsupported deployment profile." + raise ValueError(msg) + if self.profile == "tether-container-v1": + if self.intent_path is None or not self.intent_path.is_absolute() or self.tether_image_id is None: + msg = "Tether deployment requires absolute intent path and admitted image ID." + raise ValueError(msg) + match = _IMAGE_ID.fullmatch(self.tether_image_id) + if match is None: + msg = "Invalid Tether image identity." + raise ValueError(msg) + object.__setattr__(self, "tether_image_id", match.group(1)) + elif self.intent_path is not None or self.tether_image_id is not None: + msg = "Tether inputs require the Tether profile." + raise ValueError(msg) if _REVISION.fullmatch(self.revision) is None: msg = "deployment revision must be exactly 40 lowercase hexadecimal characters" raise ValueError(msg) @@ -96,11 +120,17 @@ class DeploymentReport: checks: list[dict[str, Any]] = field(default_factory=list) def as_dict(self) -> dict[str, Any]: - actions = {"absent": "init", "initialized": "bind", "bound_inactive": "start", "running": "observe"} + actions = { + "absent": "init", + "initialized": "bind", + "bound_inactive": "start", + "running": "observe", + "prepared_waiting_keys": "import-key", + } return { "schema": SCHEMA, - "version": VERSION, - "profile": "empty-extensions-container", + "version": 2 if self.request.profile == "tether-container-v1" else VERSION, + "profile": self.request.profile, "revision": self.request.revision, "image_ref": self.request.image_ref, "state": self.state, @@ -126,13 +156,15 @@ def __init__(self, *, runner: ProcessRunner | None = None) -> None: self.receipt = LifecycleReceiptStore() self.scribe = ScribeService() self.operator = build_operator_services(runner=self.runner) + self._tether: dict[str, Any] = {} def run(self, tool: str, *arguments: str) -> str: executable = trusted_host_tool(tool) if executable is None: msg = f"tool_unavailable:{tool}" raise DeploymentObservationError(msg) - result = self.runner.run((executable, *arguments), timeout_s=20.0) + options = ("--remote=false",) if tool == "podman" else () + result = self.runner.run((executable, *options, *arguments), timeout_s=20.0) if result.returncode != 0: msg = f"observation_failed:{tool}" raise DeploymentObservationError(msg) @@ -143,6 +175,7 @@ def run(self, tool: str, *arguments: str) -> str: def inspect(self, request: DeploymentRequest) -> DeploymentReport: # noqa: C901, PLR0911, PLR0912, PLR0915 - each conservative admission refusal remains explicit """Return typed refusals; operational failures are handled by the JSON adapter.""" + self._tether = {} report = DeploymentReport(request) if os.geteuid() == 0: report.reasons.append("ordinary_runtime_user_required") @@ -163,6 +196,27 @@ def inspect(self, request: DeploymentRequest) -> DeploymentReport: # noqa: C901 return report settings = self._configuration(request) report.facts["configuration"] = {"state": "original", "image": settings.server.web.image} + if request.profile == "tether-container-v1": + from lychd.system.services.tether.service import NativeTetherService + + native = NativeTetherService() + preparation = native.preparation_status() + report.facts["tether_preparation"] = preparation + if preparation["state"] in {"absent", "waiting_keys"}: + return self._preparation_report(request, report, preparation, bindings, observed, footprint) + if preparation["state"] != "prepared": + message = "tether_preparation_protocol" + raise DeploymentObservationError(message) + self._tether = native.inspect() + if ( + self._tether.get("schema") != "lychd.tether.inspection" + or type(self._tether.get("version")) is not int + or self._tether.get("version") != 1 + ): + msg = "tether_inspection_protocol" + raise DeploymentObservationError(msg) + report.facts["tether"] = self._tether + self._require_tether_image(request) inventory = self.operator.inventory.inspect(OperatorTarget.SYSTEM) if inventory.warnings: report.reasons.append("inventory_warnings") @@ -191,15 +245,21 @@ def inspect(self, request: DeploymentRequest) -> DeploymentReport: # noqa: C901 manifest.container_name for manifest in manifests if isinstance(manifest, QuadletContainer) } expected_pods = {manifest.pod_name for manifest in manifests if isinstance(manifest, QuadletPod)} + extra_containers = set(footprint["containers"]) - expected_containers if ( - set(footprint["secrets"]) - set(settings.server.privileged_secret_names) + set(footprint["secrets"]) + - {*settings.server.privileged_secret_names, *self._tether.get("owned_secrets", ())} or set(footprint["pods"]) - expected_pods - or set(footprint["containers"]) - expected_containers + or (extra_containers and not bindings.receipt_present) ): report.reasons.append("foreign_runtime_material") return report if not bindings.receipt_present: - if footprint["containers"] or footprint["pods"] or footprint["secrets"]: + if ( + footprint["containers"] + or footprint["pods"] + or set(footprint["secrets"]) - set(self._tether.get("owned_secrets", ())) + ): report.reasons.append("runtime_material_without_binding_authority") elif any(not self.unit_quiescent(value, absent=True) for value in observed.values()): report.reasons.append("runtime_units_without_binding_authority") @@ -221,6 +281,14 @@ def inspect(self, request: DeploymentRequest) -> DeploymentReport: # noqa: C901 if plan.mutates: report.reasons.append("bindings_differ_from_declared_generation") return report + if extra_containers: + try: + infrastructure = self._owned_infra_names(manifests, bindings, observed, footprint) + except DeploymentObservationError: + infrastructure = frozenset[str]() + if extra_containers - infrastructure: + report.reasons.append("foreign_runtime_material") + return report report.facts["binding_generation"] = bindings.generation active_units = self._active_names(manifests, plain_units) owned_states = {name: observed[name] for name in expected_names} @@ -236,6 +304,8 @@ def inspect(self, request: DeploymentRequest) -> DeploymentReport: # noqa: C901 ) if running_image != image["id"]: report.reasons.append("running_image_identity_mismatch") + elif request.profile == "tether-container-v1" and self._tether.get("state") != "active": + report.reasons.append("tether_not_verified_active") else: report.state = "running" else: @@ -255,6 +325,54 @@ def inspect(self, request: DeploymentRequest) -> DeploymentReport: # noqa: C901 def _roots() -> tuple[Path, ...]: return constants.PATH_CODEX_ROOT, constants.PATH_CRYPT_ROOT, constants.PATH_CACHE_ROOT + def _preparation_report( + self, + request: DeploymentRequest, + report: DeploymentReport, + preparation: dict[str, Any], + bindings: OwnedBindings, + observed: dict[str, dict[str, str]], + footprint: dict[str, list[str]], + ) -> DeploymentReport: + """Admit only original, unbound, quiescent preparation recovery.""" + from lychd.system.services.tether.models import load_intent + + if request.intent_path is None: + message = "tether_intent_missing" + raise DeploymentObservationError(message) + intent = load_intent(request.intent_path) + self._tether = { + "known_paths": preparation["known_paths"], + "required_secrets": preparation["required_secrets"], + "owned_secrets": preparation["owned_secrets"], + "image": intent.rune.quadlet.image, + } + self._require_tether_image(request) + if ( + bindings.receipt_present + or footprint["foreign_sources"] + or footprint["containers"] + or footprint["pods"] + or set(footprint["secrets"]) - set(preparation["owned_secrets"]) + or any(not self.unit_quiescent(value, absent=True) for value in observed.values()) + or not self._data_empty() + ): + report.reasons.append("runtime_material_before_tether_preparation") + return report + self._require_original_files() + self._require_known_tree(bound=False) + image = self._image(request) + report.facts["image"] = image + if image["revision"] != request.revision or (request.image_id is not None and image["id"] != request.image_id): + report.reasons.append("admitted_image_identity_mismatch") + return report + if preparation["state"] == "waiting_keys": + report.state = "prepared_waiting_keys" + report.reasons.append("native_tether_import_key_required") + else: + report.state = "initialized" + return report + def _configuration(self, request: DeploymentRequest) -> Settings: self._require_original_files() try: @@ -270,6 +388,22 @@ def _configuration(self, request: DeploymentRequest) -> Settings: declared = SettingsSnapshot(payload=json.dumps(document)).materialize() baseline = SettingsSnapshot(payload="{}").materialize() baseline.server.web.image = request.image_ref + if request.profile == "tether-container-v1": + from lychd.domain.tether.models import TetherRune + from lychd.system.services.tether.models import load_intent + + if request.intent_path is None: + msg = "tether_intent_missing" + raise DeploymentObservationError(msg) + intent = load_intent(request.intent_path) + baseline.extensions.builtins = ("tether",) + baseline.server.port = intent.web_port + path = TetherRune.anchor_dir(constants.PATH_RUNES_DIR) / "default.toml" + original = self.receipt.read_original_file(path, expected_mode=_CONFIG_MODE, max_bytes=_MAX_CONFIG_BYTES) + rune = TetherRune.model_validate(tomllib.loads(original.decode())) + if rune.model_dump(mode="json") != intent.rune.model_dump(mode="json"): + msg = "tether_original_intent_mismatch" + raise DeploymentObservationError(msg) if declared.model_dump(mode="json", exclude_none=True) != baseline.model_dump(mode="json", exclude_none=True): msg = "configuration_outside_deployment_profile" raise DeploymentObservationError(msg) @@ -323,6 +457,7 @@ def _require_known_tree(self, *, bound: bool) -> None: *self._roots(), } known.update(Path(entry.path) for entry in receipt.subvolumes) + known.update(Path(path) for path in self._tether.get("known_paths", ())) count = 0 pending = list(self._roots()) while pending: @@ -351,6 +486,11 @@ def desired_bindings(settings: Settings) -> tuple[list[QuadletBase], dict[str, s from lychd.extensions.host import assemble_extensions from lychd.system.services.bind_compilation import compile_bind_request + if "tether" in settings.extensions.builtins: + from lychd.system.services.tether.service import NativeTetherService + + request = NativeTetherService().bind_request() + return list(request.manifests), request.plain_unit_mapping() request = compile_bind_request( settings=settings, extensions=assemble_extensions(settings), @@ -451,6 +591,30 @@ def observe_unit(self, name: str) -> dict[str, str]: fields.update(MainPID="0", ControlPID="0", Result="", ExecMainStatus="") return fields + def observe_owned_units( + self, bindings: OwnedBindings, unit_names: frozenset[str] | None = None + ) -> dict[str, dict[str, str]]: + """Attest exact owned source provenance and effective definitions for a subset.""" + selected = frozenset(bindings.runtime_units) if unit_names is None else unit_names + if not bindings.receipt_present or not bindings.generation or not selected.issubset(bindings.runtime_units): + msg = "unowned_unit_selection" + raise DeploymentObservationError(msg) + quadlets = tuple( + path + for path in bindings.quadlet_sources + if path.suffix in {".pod", ".container"} and runtime_unit_for_source(path.name) in selected + ) + plain = tuple(path for path in bindings.systemd_sources if runtime_unit_for_source(path.name) in selected) + if frozenset(runtime_unit_for_source(path.name) for path in (*quadlets, *plain)) != selected: + msg = "owned_unit_source_missing" + raise DeploymentObservationError(msg) + subset = replace( + bindings, quadlet_sources=quadlets, systemd_sources=plain, runtime_units=tuple(sorted(selected)) + ) + observed = {name: self.observe_unit(name) for name in sorted(selected)} + self._require_effective_bindings(subset, observed) + return observed + @staticmethod def unit_quiescent(value: dict[str, str], *, absent: bool = False) -> bool: return ( @@ -497,6 +661,118 @@ def _footprint(self, bindings: OwnedBindings) -> dict[str, list[str]]: found["foreign_sources"] = foreign return found + def _runtime_projection(self, kind: str, identity: str, template: str, size: int) -> list[object]: + """Read only the public fields needed for a runtime association.""" + try: + value: object = json.loads(self.run("podman", kind, "inspect", identity, "--format", template)) + except ValueError: + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) from None + if not isinstance(value, list): + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + entries = cast("list[object]", value) + if len(entries) != size: + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + return entries + + def _pod_identity(self, name: str, identity: str | None = None) -> tuple[str, str]: + row = self._runtime_projection("pod", identity or name, _POD_PROJECTION, 4) + if ( + not isinstance(row[0], str) + or _RUNTIME_ID.fullmatch(row[0]) is None + or row[1] != name + or row[2] is not True + or not isinstance(row[3], str) + or _RUNTIME_ID.fullmatch(row[3]) is None + or row[0] == row[3] + or (identity is not None and row[0] != identity) + ): + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + return row[0], row[3] + + def _infra_identity(self, identity: str) -> tuple[str, str, int]: + row = self._runtime_projection("container", identity, _INFRA_PROJECTION, 6) + if ( + row[0] != identity + or not isinstance(row[1], str) + or not row[1] + or not isinstance(row[2], str) + or _RUNTIME_ID.fullmatch(row[2]) is None + or row[3] is not True + or row[4] is not True + or type(row[5]) is not int + or row[5] <= 0 + ): + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + return row[1], row[2], row[5] + + def _pod_unit_incarnation(self, name: str, expected: dict[str, str]) -> str: + current = self.observe_unit(name) + invocation = self.run("systemctl", "--user", "show", name, "--property=InvocationID", "--value").strip() + if ( + current != expected + or not self.unit_active(current) + or current["SubState"] != "running" + or _INVOCATION_ID.fullmatch(invocation) is None + or invocation == "0" * 32 + ): + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + return invocation + + def _owned_infra_names( + self, + manifests: list[QuadletBase], + bindings: OwnedBindings, + observed: dict[str, dict[str, str]], + footprint: dict[str, list[str]], + ) -> frozenset[str]: + """Join implicit infra to compiled, effective Pod units without adopting names. + + Called only after complete binding-generation reconciliation is a no-op. + Quadlet's Pod service tracks its infrastructure conmon as MainPID. Both + immutable engine identities and that invocation must remain unchanged. + """ + pods = {row.pod_name for row in manifests if isinstance(row, QuadletPod)} & set(footprint["pods"]) + if not bindings.receipt_present or not bindings.generation or len(pods) > _MAX_TREE_ENTRIES: + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + names: set[str] = set() + identities: set[str] = set() + for name in sorted(pods): + unit_name = runtime_unit_for_source(name + ".pod") + sources = [path for path in bindings.quadlet_sources if path.name == name + ".pod"] + if len(sources) != 1 or unit_name not in bindings.runtime_units or unit_name not in observed: + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + invocation = self._pod_unit_incarnation(unit_name, observed[unit_name]) + pod_id, infra_id = self._pod_identity(name) + infra = self._infra_identity(infra_id) + infra_name, parent, conmon = infra + if ( + parent != pod_id + or str(conmon) != observed[unit_name]["MainPID"] + or infra_name not in footprint["containers"] + or infra_name not in footprint["running_containers"] + or infra_name in names + or {pod_id, infra_id} & identities + or self._pod_identity(name, pod_id) != (pod_id, infra_id) + or self._infra_identity(infra_id) != infra + or self._pod_unit_incarnation(unit_name, observed[unit_name]) != invocation + ): + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + names.add(infra_name) + identities.update((pod_id, infra_id)) + if self.scribe.inspect_owned_bindings() != bindings: + message = "pod_infrastructure_identity_unproven" + raise DeploymentObservationError(message) + return frozenset(names) + def _image(self, request: DeploymentRequest) -> dict[str, str]: payload: object = json.loads(self.run("podman", "image", "inspect", request.image_ref)) if not isinstance(payload, list): @@ -521,6 +797,29 @@ def _image(self, request: DeploymentRequest) -> dict[str, str]: "reference": request.image_ref, } + def _require_tether_image(self, request: DeploymentRequest) -> None: + """Compare the native declared image to the separately admitted OCI identity.""" + payload: object = json.loads(self.run("podman", "image", "inspect", self._tether["image"])) + if not isinstance(payload, list): + message = "tether_image_unavailable" + raise DeploymentObservationError(message) + entries = cast("list[object]", payload) + if len(entries) != 1 or not isinstance(entries[0], dict): + message = "tether_image_unavailable" + raise DeploymentObservationError(message) + image = cast("dict[str, object]", entries[0]) + identifier = image.get("Id") + if not isinstance(identifier, str) or identifier.removeprefix("sha256:") != request.tether_image_id: + message = "tether_image_identity_mismatch" + raise DeploymentObservationError(message) + raw_labels = image.get("Labels") + if ( + not isinstance(raw_labels, dict) + or cast("dict[str, object]", raw_labels).get("org.opencontainers.image.revision") != request.revision + ): + message = "tether_image_revision_mismatch" + raise DeploymentObservationError(message) + def _data_empty(self) -> bool: if not os.path.lexists(constants.PATH_POSTGRESS_DATA_DIR): return True diff --git a/src/lychd/system/services/intercom.py b/src/lychd/system/services/intercom.py index b68ff66c..7a472468 100644 --- a/src/lychd/system/services/intercom.py +++ b/src/lychd/system/services/intercom.py @@ -28,8 +28,10 @@ from lychd.domain.intercom.codec import encode_request, send_params, task_params from lychd.domain.intercom.native import PeerRunBinding, PeerTaskRef from lychd.domain.intercom.transport import IntercomEndpoint, IntercomHandoff, PreparedRequest, TaskMethod +from lychd.domain.tether.models import TetherSelection from lychd.system.services.intercom_http import BoundedIntercomHTTP, prepare_request, verify_response from lychd.system.services.peer_authority import PeerAuthority +from lychd.system.services.tether.connector import TetherConnector _NETWORK_ALLOWANCE = 512 * 1024 _MAX_PORT = 65535 @@ -79,7 +81,10 @@ def _instant(value: str) -> datetime: def _endpoint(record: dict[str, Any]) -> tuple[IntercomEndpoint, str]: try: - if set(record) != {"address", "hostname", "port", "path", "ca_file"}: + if set(record) not in ( + {"address", "hostname", "port", "path", "ca_file"}, + {"address", "hostname", "port", "path", "ca_file", "tether"}, + ): _refuse("endpoint") address, hostname, port, path, ca_file = ( record["address"], @@ -104,9 +109,16 @@ def _endpoint(record: dict[str, Any]) -> tuple[IntercomEndpoint, str]: or not Path(ca_file).is_absolute() ): _refuse("endpoint") + tether = None + if "tether" in record: + if type(record["tether"]) is not dict: + _refuse("endpoint") + tether = TetherSelection.model_validate(record["tether"], strict=True) authority = f"[{hostname}]" if ":" in hostname else hostname uri = f"https://{authority}:{port}{path}" - return IntercomEndpoint(address=address, hostname=hostname, port=port, path=path, ca_file=Path(ca_file)), uri + return IntercomEndpoint( + address=address, hostname=hostname, port=port, path=path, ca_file=Path(ca_file), tether=tether + ), uri except (KeyError, TypeError, ValueError): _refuse("endpoint") @@ -197,6 +209,7 @@ def __init__( workload_id: UUID, signing_key: bytes, signing_key_id: str, + tether_connector: TetherConnector | None = None, ) -> None: """Compose trusted custody and an in-memory workload signing credential.""" if not signing_key_id or not signing_key_id.isascii() or len(signing_key_id) > _MAX_KEY_ID_BYTES: @@ -215,7 +228,7 @@ def __init__( self._workload_id = workload_id self._signing_key = signing_key self._signing_key_id = signing_key_id - self._http = BoundedIntercomHTTP() + self._http = BoundedIntercomHTTP(tether_connector=tether_connector) self._declarations: dict[UUID, _LiveDeclaration] = {} self._native_declarations: dict[UUID, UUID] = {} self._pending: dict[UUID, _TransmissionGate] = {} diff --git a/src/lychd/system/services/intercom_http.py b/src/lychd/system/services/intercom_http.py index 1a1044c3..2555ef03 100644 --- a/src/lychd/system/services/intercom_http.py +++ b/src/lychd/system/services/intercom_http.py @@ -45,6 +45,7 @@ ) from lychd.domain.scout.transport import FetchCode, FetchError, NetworkHandoff, NetworkTarget from lychd.system.services.scout_socket import open_pinned_tls +from lychd.system.services.tether.connector import TetherConnector EXTENSION_URI = "https://github.com/hexanomicon/lychd/blob/main/docs/adr/26-a2a.md#peer-task-jsonrpc-1" SIGNATURE_LABEL = "lychd" @@ -461,6 +462,10 @@ def _request_fresh(prepared: PreparedRequest) -> None: class BoundedIntercomHTTP: """One pinned TLS connection, one POST, one bounded response, always closed.""" + def __init__(self, *, tether_connector: TetherConnector | None = None) -> None: + """Keep Direct as default; a selected Tether requires trusted assembly.""" + self._tether_connector = tether_connector + async def exchange( # noqa: C901, PLR0912, PLR0915 - One finite response parser and owned closure boundary. self, endpoint: IntercomEndpoint, @@ -493,12 +498,26 @@ async def exchange( # noqa: C901, PLR0912, PLR0915 - One finite response parser raise IntercomError(IntercomCode.CONFIG) from None stream = None try: - stream = await open_pinned_tls( - NetworkTarget(endpoint.address, endpoint.port, endpoint.hostname, "origin"), - gate=_SocketGate(endpoint, prepared, gate), - deadline=deadline, - ca_file=endpoint.ca_file, - ) + target = NetworkTarget(endpoint.address, endpoint.port, endpoint.hostname, "origin") + socket_gate = _SocketGate(endpoint, prepared, gate) + if endpoint.tether is None: + stream = await open_pinned_tls( + target, + gate=socket_gate, + deadline=deadline, + ca_file=endpoint.ca_file, + ) + else: + if self._tether_connector is None: + self._tether_connector = TetherConnector.from_catalog(caller="intercom") + stream = await self._tether_connector.open_tls( + endpoint.tether, + target=target, + application_identity_ref=prepared.peer_id, + gate=socket_gate, + deadline=deadline, + ca_file=endpoint.ca_file, + ) await stream.send(prepared.wire) connection = h11.Connection(h11.CLIENT, max_incomplete_event_size=MAX_HEADER_BYTES) connection.send( diff --git a/src/lychd/system/services/secrets.py b/src/lychd/system/services/secrets.py index af102b2e..71fc78b3 100644 --- a/src/lychd/system/services/secrets.py +++ b/src/lychd/system/services/secrets.py @@ -59,7 +59,7 @@ def __init__( *, runner: InputProcessRunner | None = None, ) -> None: - """Bind every command to the preflight-attested Podman executable.""" + """Bind every command to the preflight-attested executable's local engine.""" self._podman = podman_bin self._runner = runner or SubprocessRunner() @@ -68,7 +68,7 @@ def read(self, name: str) -> SecretStr: validate_podman_secret_name(name, field_name="Podman secret name") try: result = self._runner.run( - (self._podman, "secret", "inspect", "--showsecret", name), + (self._podman, "--remote=false", "secret", "inspect", "--showsecret", name), timeout_s=_PODMAN_PROBE_TIMEOUT_SECONDS, ) value = _exact_secret_value(result.stdout if result.returncode == 0 else "null", name) @@ -80,7 +80,7 @@ def read(self, name: str) -> SecretStr: def exists(self, name: str) -> bool: """Return exact presence, distinguishing absence from probe failure.""" result = self._run( - (self._podman, "secret", "exists", name), + (self._podman, "--remote=false", "secret", "exists", name), timeout_s=_PODMAN_PROBE_TIMEOUT_SECONDS, operation=f"inspect Podman secret {name!r}", ) @@ -113,7 +113,7 @@ def _create( ) -> ProcessResult: """Invoke Podman's create primitive without exposing secret material.""" replace_args = ("--replace",) if replace else () - argv = (self._podman, "secret", "create", *replace_args, name, "-") + argv = (self._podman, "--remote=false", "secret", "create", *replace_args, name, "-") try: return self._runner.run_with_input( argv, diff --git a/src/lychd/system/services/tether/__init__.py b/src/lychd/system/services/tether/__init__.py new file mode 100644 index 00000000..f528d80c --- /dev/null +++ b/src/lychd/system/services/tether/__init__.py @@ -0,0 +1 @@ +"""Native Tether custody, isolated networking, and explicit transport adapters.""" diff --git a/src/lychd/system/services/tether/connector.py b/src/lychd/system/services/tether/connector.py new file mode 100644 index 00000000..b8c40dec --- /dev/null +++ b/src/lychd/system/services/tether/connector.py @@ -0,0 +1,335 @@ +"""Explicit generation-bound broker streams; the catalog grants no application authority.""" + +# The broker carrier deliberately reuses the bounded stream mechanics. + +from __future__ import annotations + +import asyncio +import errno +import json +import math +import os +import re +import socket +import ssl +import stat +import struct +import time +from collections.abc import Callable +from pathlib import Path +from typing import cast + +from pydantic import ValidationError + +from lychd.domain.scout.transport import FetchCode, FetchError, NetworkGate, NetworkHandoff, NetworkTarget +from lychd.domain.tether.models import TetherRoute, TetherSelection +from lychd.system.services import scout_socket + +_ACK = b'{"version":1,"ok":true}\n' +_SOCKET_DEPTH = 2 +_MAX_PRELUDE = 2048 +_ROOT = Path("/run/lychd-tether") +_CATALOG = _ROOT / "catalog.json" +_MAX_CATALOG_BYTES = 1024 * 1024 +_CATALOG_MODE = 0o600 +_DIRECTORY_MODE = 0o700 +_SOCKET_MODE = 0o600 +_CATALOG_KEYS = {"schema", "version", "installation_epoch", "routes"} +_PENDING = {errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, errno.EINTR} + + +class _CurrentGate: + def __init__(self, current: Callable[[], None], delegate: NetworkGate) -> None: + self.current, self.delegate = current, delegate + + def handoff(self, handoff: NetworkHandoff, action: Callable[[], int]) -> int: + self.current() + return self.delegate.handoff(handoff, action) + + +class _BrokerStream(scout_socket.PinnedTcpStream): + """AF_UNIX carrier retaining the existing finite TLS stream's syscall gates.""" + + def _current(self) -> None: + if not isinstance(self._gate, _CurrentGate): + raise FetchError(FetchCode.CONFIG) + self._gate.current() + + async def _receive_wire(self, max_bytes: int) -> bytes: + self._current() + result = await super()._receive_wire(max_bytes) + self._current() + return result + + async def wrap_tls(self, *, ca_file: Path) -> scout_socket.PinnedTlsStream: + """Verify application TLS over this connected, gated broker carrier. + + Reuse the shared stream's exact target and trust policy; Tether owns + only broker-carrier assembly and closure if that assembly fails. + """ + try: + # The broker reuses Scout's validator without acquiring a new IP socket. + scout_socket._target_address(self._target, self._deadline) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + if self._target.role != "origin": + raise FetchError(FetchCode.CONFIG) # noqa: TRY301 - carrier owns failed assembly cleanup + # Keep one CA/hostname/ALPN policy for both numeric and broker carriers. + context = scout_socket._tls_context(ca_file) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + stream = scout_socket.PinnedTlsStream(self, context) + # The shared TLS engine retains all syscall gates, bounds and deadline. + await stream._handshake() # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + except ssl.SSLError: + self.close() + raise FetchError(FetchCode.TLS) from None + except BaseException: + self.close() + raise + return stream + + async def connect_broker(self, path: str, header: bytes) -> None: + handoff = NetworkHandoff(self._target, "connect", b"", None) + status = self._handoff(handoff, lambda: self._socket.connect_ex(path)) + if status in _PENDING: + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + descriptor = self._socket.fileno() + + def connected() -> None: + if not ready.done(): + ready.set_result(None) + + loop.add_writer(descriptor, connected) + try: + async with asyncio.timeout(max(0, self._deadline - time.monotonic())): + await ready + except TimeoutError: + raise FetchError(FetchCode.TIMEOUT) from None + finally: + loop.remove_writer(descriptor) + status = self._socket.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + if status: + raise FetchError(FetchCode.CONNECT) + credentials = self._socket.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")) + _, uid, _ = struct.unpack("3i", credentials) + if uid != os.getuid(): + raise FetchError(FetchCode.PEER) + # The prelude causes the broker's fixed TCP connect; it is egress-gated + # as control traffic before TLS, with no application disclosure. + await self._send_wire(header, "tls", None) + acknowledgement = bytearray() + while len(acknowledgement) < len(_ACK): + data = await self._receive_wire(len(_ACK) - len(acknowledgement)) + if not data: + raise FetchError(FetchCode.CONNECT) + acknowledgement.extend(data) + if bytes(acknowledgement) != _ACK: + raise FetchError(FetchCode.PROTOCOL) + + +class TetherConnector: + """Trusted assembly binds caller and fresh catalog; requests supply only a selection. + + The catalog reader must return current host-published admission, including an + empty catalog while reconciliation is fenced. No connection pool is retained. + """ + + def __init__( + self, + *, + catalog: Callable[[], tuple[TetherRoute, ...]], + installation_epoch: str, + caller: str, + ) -> None: + """Bind only trusted host assembly, never request-supplied caller metadata.""" + self.catalog = catalog + self.installation_epoch = installation_epoch + self.caller = caller + + @classmethod + def from_catalog(cls, *, caller: str, catalog_path: Path = _CATALOG) -> TetherConnector: + """Pin the installation epoch from trusted host assembly's catalog path.""" + epoch, _ = _read_catalog(catalog_path) + + def current_rows() -> tuple[TetherRoute, ...]: + current_epoch, rows = _read_catalog(catalog_path) + if current_epoch != epoch: + raise FetchError(FetchCode.EGRESS) + return rows + + return cls(catalog=current_rows, installation_epoch=epoch, caller=caller) + + def resolve(self, selection: TetherSelection) -> TetherRoute: + """Require one exact current admitted tuple without address-based inference.""" + if type(selection) is not TetherSelection: + raise FetchError(FetchCode.CONFIG) + rows = self.catalog() + matches = [row for row in rows if row.selection == selection] + if len(matches) != 1: + raise FetchError(FetchCode.DESTINATION) + route = matches[0] + if route.installation_epoch != self.installation_epoch or self.caller not in route.allowed_callers: + raise FetchError(FetchCode.EGRESS) + path = Path(route.socket_path) + if not path.is_relative_to(_ROOT) or len(path.relative_to(_ROOT).parts) != _SOCKET_DEPTH or ".." in path.parts: + raise FetchError(FetchCode.CONFIG) + return route + + def current(self, route: TetherRoute) -> None: + """Refuse changed epoch, policy, generation or destination on this stream.""" + if self.resolve(route.selection) != route: + raise FetchError(FetchCode.EGRESS) + + async def open_tls( + self, + selection: TetherSelection, + *, + target: NetworkTarget, + application_identity_ref: str, + gate: NetworkGate, + deadline: float, + ca_file: Path, + ) -> scout_socket.PinnedTlsStream: + """Open only the compiler's socket, then verify end-to-end application TLS.""" + route = self.resolve(selection) + if ( + target != NetworkTarget(route.address, route.port, route.hostname, "origin") + or route.application_identity_ref != application_identity_ref + ): + raise FetchError(FetchCode.DESTINATION) + if type(deadline) not in {int, float} or not math.isfinite(deadline) or deadline <= time.monotonic(): + raise FetchError(FetchCode.CONFIG) + if not callable(getattr(gate, "handoff", None)): + raise FetchError(FetchCode.CONFIG) + path = Path(route.socket_path) + self._check_socket(path) + sock: socket.socket | None = None + try: + header = ( + json.dumps( + { + "version": 1, + "installation_epoch": route.installation_epoch, + "policy_digest": route.policy_digest, + **selection.model_dump(), + "caller": self.caller, + }, + separators=(",", ":"), + ensure_ascii=True, + ).encode("ascii") + + b"\n" + ) + if len(header) > _MAX_PRELUDE: + raise FetchError(FetchCode.CONFIG) + self.current(route) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(0.0) + sock.set_inheritable(False) + except OSError: + if sock is not None: + sock.close() + raise FetchError(FetchCode.CONNECT) from None + stream = _BrokerStream(sock, target, _CurrentGate(lambda: self.current(route), gate), deadline) + try: + await stream.connect_broker(str(path), header) + self.current(route) + tls = await stream.wrap_tls(ca_file=ca_file) + except BaseException: + stream.close() + raise + else: + return tls + + @staticmethod + def _check_socket(path: Path) -> None: + try: + if not os.statvfs(_ROOT).f_flag & os.ST_RDONLY: + raise FetchError(FetchCode.CONFIG) + for parent in (path.parent.parent, path.parent): + metadata = parent.lstat() + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != _DIRECTORY_MODE + ): + raise FetchError(FetchCode.CONFIG) + metadata = path.lstat() + if ( + not stat.S_ISSOCK(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != _SOCKET_MODE + ): + raise FetchError(FetchCode.CONFIG) + except OSError: + raise FetchError(FetchCode.CONFIG) from None + + +def _read_catalog(path: Path) -> tuple[str, tuple[TetherRoute, ...]]: + """Read a bounded non-symlink owner-only publication through one descriptor.""" + try: + if not path.is_absolute() or path.parent.resolve() != path.parent: + raise FetchError(FetchCode.CONFIG) + parent = path.parent.stat() + if parent.st_uid != os.getuid() or parent.st_mode & 0o022: + raise FetchError(FetchCode.CONFIG) + if path == _CATALOG and not os.statvfs(path.parent).f_flag & os.ST_RDONLY: + raise FetchError(FetchCode.CONFIG) + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as source: + metadata = os.fstat(source.fileno()) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != _CATALOG_MODE + or metadata.st_size > _MAX_CATALOG_BYTES + ): + raise FetchError(FetchCode.CONFIG) + data = source.read(_MAX_CATALOG_BYTES + 1) + if len(data) > _MAX_CATALOG_BYTES: + raise FetchError(FetchCode.LIMIT) + return _decode_catalog(data) + except (OSError, ValueError, TypeError, RecursionError, ValidationError): + raise FetchError(FetchCode.CONFIG) from None + + +def _decode_catalog(data: bytes) -> tuple[str, tuple[TetherRoute, ...]]: + """Validate exact public catalog fields before narrowing their JSON types.""" + decoded: object = json.loads(data, object_pairs_hook=_unique_object) + if type(decoded) is not dict: + raise FetchError(FetchCode.CONFIG) + record = cast("dict[str, object]", decoded) + epoch = record.get("installation_epoch") + raw_routes = record.get("routes") + if ( + set(record) != _CATALOG_KEYS + or record["schema"] != "lychd.tether.catalog" + or type(record["version"]) is not int + or record["version"] != 1 + or not isinstance(epoch, str) + or re.fullmatch(r"[0-9a-f]{32}", epoch) is None + or type(raw_routes) is not list + ): + raise FetchError(FetchCode.CONFIG) + rows = tuple( + TetherRoute.model_validate_json(json.dumps(row), strict=True) for row in cast("list[object]", raw_routes) + ) + selections = { + ( + row.selection.remote_lich_id, + row.selection.attachment_id, + row.selection.generation, + row.selection.service_id, + ) + for row in rows + } + if any(row.installation_epoch != epoch for row in rows) or len(selections) != len(rows): + raise FetchError(FetchCode.CONFIG) + return epoch, rows + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for name, value in pairs: + if name in result: + raise FetchError(FetchCode.CONFIG) + result[name] = value + return result diff --git a/src/lychd/system/services/tether/custody.py b/src/lychd/system/services/tether/custody.py new file mode 100644 index 00000000..6456cdb7 --- /dev/null +++ b/src/lychd/system/services/tether/custody.py @@ -0,0 +1,576 @@ +"""Descriptor-bound custody for persistent host Tether records and projections. + +The root receipt records strong persistent identities. Directory locks serialize +writers; an interrupted file/receipt transaction is refused on the next access. +No operation adopts an existing unreceipted path or changes inherited permissions. +Runtime sockets under /run are deliberately outside this persistent store. +""" + +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import re +import stat +import uuid +from collections.abc import Callable, Generator +from contextlib import contextmanager +from pathlib import Path +from typing import Literal, Protocol, cast + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from lychd.lib.json_objects import unique_json_object +from lychd.system.constants import PATH_TETHER_STATE_DIR +from lychd.system.path_identity import PathIdentityError, PersistentPathIdentity, observe_identity_fd +from lychd.system.services.tether.models import MAX_DOCUMENT_BYTES, TetherError, TetherIntent, canonical_bytes, digest + +_RECEIPT = ".custody.json" +_MAX_PATHS = 4096 +_MAX_SNAPSHOT_BYTES = 4 * MAX_DOCUMENT_BYTES +_MAX_RELATIVE_BYTES = 1024 +_RECEIPT_ENTRY_RESERVE = 4096 +_PRIVATE_FILE = 0o600 +_PUBLIC_FILE = 0o444 +_PRIVATE_DIRECTORY = 0o700 +_PUBLIC_DIRECTORY = 0o755 +_NAME = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\Z") +_DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC +_FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK + + +class _Directory(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + identity: PersistentPathIdentity + mode: Literal[0o700, 0o755] + + +class _File(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + identity: PersistentPathIdentity + mode: Literal[0o600, 0o444] + digest: str = Field(pattern=r"^[0-9a-f]{64}$") + immutable: bool + + +class _Receipt(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + schema_name: Literal["lychd.tether.custody"] = Field(alias="schema") + version: Literal[1] + revision: int = Field(ge=1) + root: PersistentPathIdentity + directories: dict[str, _Directory] + files: dict[str, _File] + + +class CustodyRecords(Protocol): + """Read-only record access valid only inside the custody lock interval.""" + + @property + def names(self) -> tuple[str, ...]: ... + + def read(self, name: str) -> dict[str, object] | None: ... + + +class _RecordView: + def __init__(self, names: tuple[str, ...], reader: Callable[[str], bytes | None]) -> None: + self._names = names + self._reader: Callable[[str], bytes | None] | None = reader + self._reads = 0 + self._bytes = 0 + + @property + def names(self) -> tuple[str, ...]: + if self._reader is None: + raise _refuse() + return self._names + + def read(self, name: str) -> dict[str, object] | None: + if self._reader is None or self._reads >= _MAX_PATHS: + raise _refuse() + self._reads += 1 + raw = self._reader(_relative(name)) + if raw is None: + return None + self._bytes += len(raw) + if self._bytes > _MAX_SNAPSHOT_BYTES: + raise _refuse() + return _object(raw) + + def close(self) -> None: + self._reader = None + + +def _refuse() -> TetherError: + return TetherError("Tether custody cannot establish current ownership") + + +def _relative(value: str | Path) -> str: + path = Path(value) + if ( + path.is_absolute() + or not path.parts + or str(path) != str(value) + or any(_NAME.fullmatch(part) is None or part in {".", ".."} for part in path.parts) + ): + raise _refuse() + result = path.as_posix() + if result == _RECEIPT or len(result.encode()) > _MAX_RELATIVE_BYTES: + raise _refuse() + return result + + +def _directory_metadata(descriptor: int, *, owned: bool, mode: int | None = None) -> os.stat_result: + metadata = os.fstat(descriptor) + if not stat.S_ISDIR(metadata.st_mode) or metadata.st_mode & 0o022: + raise _refuse() + if owned and metadata.st_uid != os.getuid(): + raise _refuse() + if mode is not None and stat.S_IMODE(metadata.st_mode) != mode: + raise _refuse() + if owned and metadata.st_mode & _PRIVATE_DIRECTORY != _PRIVATE_DIRECTORY: + raise _refuse() + return metadata + + +@contextmanager +def _open_directory(path: Path) -> Generator[int]: + """Pin every absolute path component without following symlinks.""" + if not path.is_absolute() or ".." in path.parts: + raise _refuse() + descriptor = os.open("/", _DIRECTORY_FLAGS) + try: + for part in path.parts[1:]: + child = os.open(part, _DIRECTORY_FLAGS, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + metadata = os.fstat(descriptor) + sticky_system = metadata.st_uid == 0 and bool(metadata.st_mode & stat.S_ISVTX) + if metadata.st_uid not in {0, os.getuid()} or (metadata.st_mode & 0o022 and not sticky_system): + raise _refuse() + yield descriptor + finally: + os.close(descriptor) + + +def _same_path(path: Path, descriptor: int) -> None: + """Rewalk the path to reject renaming/replacement of the pinned directory.""" + with _open_directory(path) as observed: + old, new = os.fstat(descriptor), os.fstat(observed) + if (old.st_dev, old.st_ino) != (new.st_dev, new.st_ino): + raise _refuse() + + +def _read_file( + parent: int, name: str, *, mode: int, persistent: bool = True +) -> tuple[bytes, PersistentPathIdentity | None]: + descriptor = os.open(name, _FILE_FLAGS, dir_fd=parent) + try: + before = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or before.st_nlink != 1 + or before.st_uid != os.getuid() + or stat.S_IMODE(before.st_mode) != mode + or before.st_size > MAX_DOCUMENT_BYTES + ): + raise _refuse() + identity = observe_identity_fd(descriptor) if persistent else None + parts: list[bytes] = [] + count = 0 + while chunk := os.read(descriptor, min(65536, MAX_DOCUMENT_BYTES + 1 - count)): + parts.append(chunk) + count += len(chunk) + if count > MAX_DOCUMENT_BYTES: + raise _refuse() + after = os.fstat(descriptor) + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + if (before.st_dev, before.st_ino, before.st_mtime_ns, before.st_ctime_ns, before.st_size) != ( + after.st_dev, + after.st_ino, + after.st_mtime_ns, + after.st_ctime_ns, + after.st_size, + ) or (current.st_dev, current.st_ino) != (after.st_dev, after.st_ino): + raise _refuse() + return b"".join(parts), identity + finally: + os.close(descriptor) + + +def _nonfinite(_value: str) -> object: + raise _refuse() + + +def _object(raw: bytes) -> dict[str, object]: + result = json.loads(raw, object_pairs_hook=unique_json_object, parse_constant=_nonfinite) + if type(result) is not dict: + raise _refuse() + # JSON object keys are strings; the exact dict check rejects other roots. + return cast("dict[str, object]", result) + + +def load_intent(path: Path) -> TetherIntent: + """Load one owner-only ordinary JSON request; never execute imported content.""" + try: + if not path.is_absolute() or path.name in {"", ".", ".."}: + raise _refuse() + with _open_directory(path.parent) as parent: + _directory_metadata(parent, owned=True) + raw, _identity = _read_file(parent, path.name, mode=_PRIVATE_FILE, persistent=False) + _same_path(path.parent, parent) + intent = TetherIntent.model_validate_json(canonical_bytes(_object(raw)), strict=True) + # Validate cross-object admission before original initialization writes + # anything. Live host-address exclusion remains a preparation check. + from lychd.domain.tether.compiler import compile_tether + from lychd.domain.tether.models import TetherSnapshot + + compile_tether( + rune=intent.rune, + snapshot=TetherSnapshot( + installation_epoch="00" * 16, + offered_peers=intent.offered_peers, + attachments=intent.attachments, + ), + runtime_root=Path("/run/user/1000/lychd-tether"), + config_root=Path("/var/lib/lychd/tether/runtime"), + core_web_port=intent.web_port, + consumer_uid=1000, + forbidden_backend_addresses=(), + ) + except (OSError, ValueError, TypeError, RecursionError, PathIdentityError, ValidationError, TetherError): + msg = "Tether public intent was refused" + raise TetherError(msg) from None + else: + return intent + + +class TetherCustody: + """Own only receipt-recorded persistent files beneath one dedicated root.""" + + def __init__(self, root: Path = PATH_TETHER_STATE_DIR) -> None: + """Select a persistent root without creating or adopting it.""" + if not root.is_absolute() or ".." in root.parts or root.name in {"", ".", ".."}: + raise _refuse() + self.root = root + + @contextmanager + def _locked(self) -> Generator[tuple[int, _Receipt]]: + try: + with _open_directory(self.root) as descriptor: + fcntl.flock(descriptor, fcntl.LOCK_EX) + receipt = self._attest(descriptor) + _same_path(self.root, descriptor) + yield descriptor, receipt + _same_path(self.root, descriptor) + except (OSError, ValueError, TypeError, RecursionError, PathIdentityError, ValidationError): + raise _refuse() from None + + def initialize(self) -> bool: + """Create one absent root and receipt; existing roots must already attest.""" + try: + with _open_directory(self.root.parent) as parent: + _directory_metadata(parent, owned=True) + fcntl.flock(parent, fcntl.LOCK_EX) + try: + os.stat(self.root.name, dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + os.mkdir(self.root.name, mode=_PRIVATE_DIRECTORY, dir_fd=parent) + os.fsync(parent) + created = True + else: + created = False + _same_path(self.root.parent, parent) + with _open_directory(self.root) as descriptor: + fcntl.flock(descriptor, fcntl.LOCK_EX) + if not created: + self._attest(descriptor) + return False + _directory_metadata(descriptor, owned=True, mode=_PRIVATE_DIRECTORY) + if os.listdir(descriptor): # noqa: PTH208 - enumerate the pinned directory descriptor + raise _refuse() + receipt = _Receipt( + schema="lychd.tether.custody", + version=1, + revision=1, + root=observe_identity_fd(descriptor), + directories={}, + files={}, + ) + self._replace(descriptor, _RECEIPT, canonical_bytes(receipt), mode=_PRIVATE_FILE) + self._attest(descriptor) + _same_path(self.root, descriptor) + return True + except (OSError, ValueError, TypeError, RecursionError, PathIdentityError, ValidationError): + raise _refuse() from None + + def _attest(self, descriptor: int) -> _Receipt: # noqa: C901 - exact receipt and complete-tree attestation + _directory_metadata(descriptor, owned=True, mode=_PRIVATE_DIRECTORY) + raw, _ = _read_file(descriptor, _RECEIPT, mode=_PRIVATE_FILE) + receipt = _Receipt.model_validate(_object(raw)) + if raw != canonical_bytes(receipt): + raise _refuse() + if type(_object(raw).get("version")) is not int or receipt.root != observe_identity_fd(descriptor): + raise _refuse() + if len(receipt.directories) + len(receipt.files) > _MAX_PATHS or set(receipt.directories).intersection( + receipt.files + ): + raise _refuse() + for name in (*receipt.directories, *receipt.files): + _relative(name) + parent = Path(name).parent.as_posix() + if parent != "." and parent not in receipt.directories: + raise _refuse() + expected: dict[str, set[str]] = {".": {_RECEIPT}} + for name in receipt.directories: + expected[name] = set() + for name in (*receipt.directories, *receipt.files): + relative = Path(name) + expected[relative.parent.as_posix()].add(relative.name) + for name, children in expected.items(): + with self._child_directory(descriptor, name, receipt) as child: + if set(os.listdir(child)) != children: # noqa: PTH208 - descriptor-relative inventory + raise _refuse() + for name, record in receipt.files.items(): + with self._child_directory(descriptor, Path(name).parent.as_posix(), receipt) as parent: + content, identity = _read_file(parent, Path(name).name, mode=record.mode) + if record.identity != identity or hashlib.sha256(content).hexdigest() != record.digest: + raise _refuse() + return receipt + + @contextmanager + def _child_directory(self, root: int, name: str, receipt: _Receipt) -> Generator[int]: + # A duplicate shares the directory open-file description and its readdir + # state/cache. Open the pinned directory itself afresh so enumeration + # after atomic publication sees the current inventory on overlayfs too. + descriptor = os.open(".", _DIRECTORY_FLAGS, dir_fd=root) + try: + if name != ".": + partial = Path() + for component in Path(name).parts: + partial /= component + record = receipt.directories.get(partial.as_posix()) + if record is None: + raise _refuse() + child = os.open(component, _DIRECTORY_FLAGS, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + _directory_metadata(descriptor, owned=True, mode=record.mode) + if observe_identity_fd(descriptor) != record.identity: + raise _refuse() + yield descriptor + finally: + os.close(descriptor) + + @staticmethod + def _replace( + parent: int, name: str, content: bytes, *, mode: int, expected: _File | None = None + ) -> PersistentPathIdentity: + temporary = ".tether-write-" + uuid.uuid4().hex + descriptor = os.open( + temporary, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, _PRIVATE_FILE, dir_fd=parent + ) + try: + os.fchmod(descriptor, mode) + remaining = memoryview(content) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise _refuse() + remaining = remaining[written:] + os.fsync(descriptor) + identity = observe_identity_fd(descriptor) + if expected is None: + try: + os.stat(name, dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise _refuse() + else: + old, old_identity = _read_file(parent, name, mode=expected.mode) + if old_identity != expected.identity or hashlib.sha256(old).hexdigest() != expected.digest: + raise _refuse() + named = os.stat(temporary, dir_fd=parent, follow_symlinks=False) + pinned = os.fstat(descriptor) + if (named.st_dev, named.st_ino) != (pinned.st_dev, pinned.st_ino): + raise _refuse() + os.replace(temporary, name, src_dir_fd=parent, dst_dir_fd=parent) + os.fsync(parent) + return identity + finally: + os.close(descriptor) + # Ambiguous residue is retained and refused; never broad cleanup. + + def _commit(self, root: int, previous: _Receipt, receipt: _Receipt) -> None: + _same_path(self.root, root) + raw, identity = _read_file(root, _RECEIPT, mode=_PRIVATE_FILE) + if raw != canonical_bytes(previous) or identity is None: + raise _refuse() + expected = _File(identity=identity, mode=_PRIVATE_FILE, digest=hashlib.sha256(raw).hexdigest(), immutable=False) + self._replace(root, _RECEIPT, canonical_bytes(receipt), mode=_PRIVATE_FILE, expected=expected) + self._attest(root) + + def attest(self) -> None: + """Verify exact ownership, bytes and inventory; do not repair drift.""" + with self._locked(): + pass + + def read(self, name: str) -> dict[str, object] | None: + """Read an owned JSON record, or report a truly absent unowned name.""" + relative = _relative(name) + with self._locked() as (root, receipt): + raw = self._read_record(root, receipt, relative) + return None if raw is None else _object(raw) + + def _read_record(self, root: int, receipt: _Receipt, relative: str) -> bytes | None: + record = receipt.files.get(relative) + if record is None: + if relative in receipt.directories: + raise _refuse() + return None + with self._child_directory(root, Path(relative).parent.as_posix(), receipt) as parent: + raw, identity = _read_file(parent, Path(relative).name, mode=record.mode) + if identity != record.identity or hashlib.sha256(raw).hexdigest() != record.digest: + raise _refuse() + return raw + + @contextmanager + def records_snapshot(self) -> Generator[CustodyRecords]: + """Bracket bounded reads with fresh complete-tree and unchanged-receipt proof. + + The caller may perform read-only backend observations while this lock is + held. It must not call custody mutations or acquire this lock again. + Returned records are detached values, never continuing effect authority. + """ + with self._locked() as (root, receipt): + original, identity = _read_file(root, _RECEIPT, mode=_PRIVATE_FILE) + if original != canonical_bytes(receipt) or identity is None: + raise _refuse() + + def read_owned(name: str) -> bytes | None: + return self._read_record(root, receipt, name) + + view = _RecordView(tuple(sorted(receipt.files)), read_owned) + try: + yield view + finally: + view.close() + current = self._attest(root) + raw, current_identity = _read_file(root, _RECEIPT, mode=_PRIVATE_FILE) + if canonical_bytes(current) != original or raw != original or current_identity != identity: + raise _refuse() + + def write(self, name: str, value: dict[str, object], *, immutable: bool = False) -> bool: + """Atomically replace one owned private record and then its ownership receipt.""" + return self._publish(Path(_relative(name)), value, public=False, immutable=immutable) + + def publish(self, relative: Path, value: dict[str, object], *, public: bool = False) -> bool: + """Publish a role document only below already receipted directories.""" + return self._publish(relative, value, public=public, immutable=False) + + def _publish(self, relative: Path, value: dict[str, object], *, public: bool, immutable: bool) -> bool: # noqa: C901 - guarded two-record commit + name = _relative(relative) + if type(value) is not dict: + raise _refuse() + content = canonical_bytes(value) + mode = _PUBLIC_FILE if public else _PRIVATE_FILE + with self._locked() as (root, receipt): + if name in receipt.directories: + raise _refuse() + current = receipt.files.get(name) + if current is None: + self._reserve_entry(receipt) + if current is not None: + if current.mode != mode or (immutable and not current.immutable): + raise _refuse() + if current.digest == hashlib.sha256(content).hexdigest(): + return False + if current.immutable: + raise _refuse() + with self._child_directory(root, relative.parent.as_posix(), receipt) as parent: + _directory_metadata(parent, owned=True) + if current is None: + try: + os.stat(relative.name, dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise _refuse() + else: + old, identity = _read_file(parent, relative.name, mode=current.mode) + if identity != current.identity or hashlib.sha256(old).hexdigest() != current.digest: + raise _refuse() + _same_path(self.root / relative.parent, parent) + identity = self._replace(parent, relative.name, content, mode=mode, expected=current) + _same_path(self.root / relative.parent, parent) + files = dict(receipt.files) + files[name] = _File( + identity=identity, + mode=mode, + digest=hashlib.sha256(content).hexdigest(), + immutable=immutable or bool(current and current.immutable), + ) + self._commit(root, receipt, receipt.model_copy(update={"revision": receipt.revision + 1, "files": files})) + return True + + def directory(self, name: str, *, public: bool = False) -> Path: + """Create and receipt each missing directory before returning its path.""" + relative = _relative(name) + mode = _PUBLIC_DIRECTORY if public else _PRIVATE_DIRECTORY + with self._locked() as (root, initial_receipt): + receipt = initial_receipt + partial = Path() + for component in Path(relative).parts: + parent_name = partial.as_posix() + partial /= component + child_name = partial.as_posix() + existing = receipt.directories.get(child_name) + if existing is not None: + if existing.mode != mode: + raise _refuse() + continue + if child_name in receipt.files: + raise _refuse() + self._reserve_entry(receipt) + with self._child_directory(root, parent_name, receipt) as parent: + _same_path(self.root / Path(parent_name), parent) + os.mkdir(component, mode=mode, dir_fd=parent) + child = os.open(component, _DIRECTORY_FLAGS, dir_fd=parent) + try: + # Mode adjustment is confined to the just-created inode. + os.fchmod(child, mode) + identity = observe_identity_fd(child) + _directory_metadata(child, owned=True, mode=mode) + os.fsync(child) + finally: + os.close(child) + os.fsync(parent) + directories = dict(receipt.directories) + directories[child_name] = _Directory(identity=identity, mode=mode) + updated = receipt.model_copy(update={"revision": receipt.revision + 1, "directories": directories}) + self._commit(root, receipt, updated) + receipt = updated + return self.root / relative + + @staticmethod + def _reserve_entry(receipt: _Receipt) -> None: + """Refuse bounded inventory exhaustion before creating any new object.""" + if len(receipt.directories) + len(receipt.files) >= _MAX_PATHS: + raise _refuse() + if len(canonical_bytes(receipt)) + _RECEIPT_ENTRY_RESERVE > MAX_DOCUMENT_BYTES: + raise _refuse() + + def known_paths(self) -> tuple[Path, ...]: + """Return exact receipted paths, including root and its receipt.""" + with self._locked() as (_root, receipt): + return ( + self.root, + self.root / _RECEIPT, + *(self.root / name for name in sorted((*receipt.directories, *receipt.files))), + ) + + +__all__ = ("TetherCustody", "TetherError", "TetherIntent", "digest", "load_intent") diff --git a/src/lychd/system/services/tether/host.py b/src/lychd/system/services/tether/host.py new file mode 100644 index 00000000..c84d591c --- /dev/null +++ b/src/lychd/system/services/tether/host.py @@ -0,0 +1,655 @@ +"""Bounded native effects over exact Scribe-owned Tether units and incarnations.""" + +from __future__ import annotations + +import json +import os +import re +import stat +import time +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from lychd.lib.json_objects import unique_json_object +from lychd.system.host_tools import trusted_host_tool +from lychd.system.operator.process import SubprocessRunner +from lychd.system.services.tether.models import TetherError + +if TYPE_CHECKING: + from lychd.domain.tether.compiler import RuntimeConfig + from lychd.system.operator.process import InputProcessRunner + +_ID = re.compile(r"[0-9a-f]{64}") +_MAX_OUTPUT = 1024 * 1024 +_PRIVATE_DIRECTORY_MODE = 0o700 +_POD_NAME = re.compile(r"lychd-tether-[0-9a-f]{16}") +_ROLE_NAME = re.compile(r"(lychd-tether-[0-9a-f]{16})-(?:wg|outbound|ingress|ssh-[0-9a-f]{12})") +_ROLE_FIELDS = { + "container_id", + "container_name", + "unit_name", + "pod_id", + "image_id", + "netns", + "cgroup", + "invocation_id", + "unit_cgroup", +} +_POD_FIELDS = { + "pod_id", + "pod_name", + "unit_name", + "infra_id", + "infra_cgroup", + "netns", + "invocation_id", + "unit_cgroup", +} + + +def _require(value: bool) -> None: # noqa: FBT001 - invariant assertion + if not value: + message = "Tether host ownership or runtime observation refused" + raise TetherError(message) + + +def _identifier(value: Any) -> str: + _require(type(value) is str and _ID.fullmatch(value) is not None) + return value + + +def _user_group(group: str) -> Path: + _require(type(group) is str) + path = Path(group) + _require(group.startswith("/") and str(path) == group and ".." not in path.parts) + _require(path.parts[:3] == ("/", "user.slice", f"user-{os.getuid()}.slice")) + return path + + +def _container_group(group: str, identity: str, unit_group: str | None = None) -> str: + """Own the full exact payload/scope subtree, not the initial process's leaf.""" + parts = _user_group(group).parts + markers = {f"libpod-payload-{identity}", f"libpod-{identity}.scope"} + matches = [index for index, component in enumerate(parts) if component in markers] + _require(len(matches) == 1) + index = matches[0] + root = Path(*parts[: index + 1]) + if parts[index].startswith("libpod-payload-") and unit_group is not None: + _require(bool(unit_group) and root.is_relative_to(_user_group(unit_group)) and root != Path(unit_group)) + return str(root) + + +class TetherHost: + """Never act on a guessed process, wildcard unit, tag, or container name alone.""" + + def __init__(self, runner: InputProcessRunner | None = None) -> None: + """Resolve only root-controlled executables; keep command diagnostics private.""" + self.runner = runner or SubprocessRunner() + + def command(self, tool: str, *arguments: str) -> str: + """Use local Podman for host effects and suppress captured diagnostics.""" + try: + _require(tool in {"podman", "systemctl", "ip"}) + executable = trusted_host_tool(tool) + _require(executable is not None) + options = ("--remote=false",) if tool == "podman" else () + result = self.runner.run((cast("str", executable), *options, *arguments), timeout_s=45) + _require(not result.returncode and len(result.stdout.encode()) <= _MAX_OUTPUT) + except Exception: # noqa: BLE001 - quarantine process/observation diagnostics + message = "Tether host command failed; private diagnostics suppressed" + raise TetherError(message) from None + else: + return result.stdout + + def owned_unit(self, name: str) -> dict[str, str]: + """Recheck current Scribe authority and effective generated unit provenance.""" + from lychd.system.services.deployment import DeploymentInspectionService + from lychd.system.services.scribe.facade import ScribeService + + try: + _require(type(name) is str and name.endswith(".service")) + stem = name.removesuffix(".service") + _require( + stem == "lychd-vessel" + or _ROLE_NAME.fullmatch(stem) is not None + or (stem.endswith("-pod") and _POD_NAME.fullmatch(stem.removesuffix("-pod")) is not None) + ) + owned = ScribeService().inspect_owned_bindings() + _require(owned.receipt_present and name in owned.runtime_units) + observed = DeploymentInspectionService(runner=self.runner).observe_owned_units( + owned, unit_names=frozenset({name}) + ) + return observed[name] + except Exception: # noqa: BLE001 - never expose filesystem or process diagnostics + message = "Tether unit lacks current effective binding authority" + raise TetherError(message) from None + + def unit_incarnation(self, name: str) -> dict[str, str]: + """Bind a launch to the current systemd invocation and owned control group.""" + unit = self.owned_unit(name) + content = self.command( + "systemctl", + "--user", + "show", + name, + "--property=Id", + "--property=InvocationID", + "--property=ControlGroup", + ) + fields: dict[str, str] = {} + for line in content.splitlines(): + key, separator, value = line.partition("=") + _require(bool(separator) and key not in fields) + fields[key] = value + _require(set(fields) == {"Id", "InvocationID", "ControlGroup"} and fields["Id"] == name) + invocation, group = fields["InvocationID"], fields["ControlGroup"] + _require(not invocation or re.fullmatch(r"[0-9a-f]{32}", invocation) is not None) + if group: + _user_group(group) + _require(Path(group).name == name) + if not invocation: + _require(self._quiescent(unit) and not group) + return {"InvocationID": invocation, "ControlGroup": group} + + @staticmethod + def _quiescent(unit: dict[str, str]) -> bool: + return ( + unit.get("LoadState") == "loaded" + and unit.get("ActiveState") == "inactive" + and unit.get("SubState") == "dead" + and unit.get("MainPID") == unit.get("ControlPID") == "0" + and unit.get("Job") in {"", "0"} + ) + + def exists(self, kind: str, identity: str) -> bool: + """Distinguish exact local absence from an unsuccessful Podman observation.""" + if kind not in {"container", "pod"}: + message = "Unsupported Tether runtime kind" + raise TetherError(message) + executable = trusted_host_tool("podman") + if executable is None: + message = "A trusted Podman executable is unavailable" + raise TetherError(message) + result = self.runner.run((executable, "--remote=false", kind, "exists", identity), timeout_s=5) + if result.returncode not in {0, 1}: + message = "Tether runtime absence is uncertain" + raise TetherError(message) + return result.returncode == 0 + + def row(self, kind: str, identity: str) -> dict[str, Any]: + """Read exactly one requested Podman object; never dump WireGuard keys.""" + _require( + kind in {"image", "container", "pod"} + and type(identity) is str + and bool(identity) + and not identity.startswith("-") + ) + rows = json.loads( + self.command("podman", kind, "inspect", identity), + object_pairs_hook=unique_json_object, + ) + if type(rows) is not list: + message = "Malformed Tether runtime observation" + raise TetherError(message) + validated_rows = cast("list[object]", rows) + if len(validated_rows) != 1 or type(validated_rows[0]) is not dict: + message = "Malformed Tether runtime observation" + raise TetherError(message) + return cast("dict[str, Any]", validated_rows[0]) + + def image(self, reference: str) -> dict[str, str]: + """Attest an already imported digest, including immutable image identity.""" + row = self.row("image", reference) + identity = row.get("Id", "") + expected = reference.rsplit("@", 1)[-1] + if not _ID.fullmatch(identity) or not expected.startswith("sha256:") or row.get("Digest") != expected: + message = "Tether requires the exact admitted local digest" + raise TetherError(message) + return {"id": identity, "digest": expected} + + def host_addresses(self) -> tuple[str, ...]: + """Observe local numeric IPv4 addresses before admitting external backends.""" + rows = json.loads(self.command("ip", "-j", "-4", "address", "show")) + return tuple(sorted({item["local"] for row in rows for item in row.get("addr_info", ())})) + + @staticmethod + def runtime_directories(root: Path, directories: tuple[Path, ...]) -> None: + """Create boot-local private directories without persisting tmpfs identities.""" + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + descriptor = os.open("/", flags) + try: + for component in root.parent.parts[1:]: + child = os.open(component, flags, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + metadata = os.fstat(descriptor) + if metadata.st_uid != os.getuid() or metadata.st_mode & 0o022: + message = "Unsafe Tether runtime parent" + raise TetherError(message) + for path in (root, *directories): + if root not in (path, path.parent): + message = "Runtime directory is outside its fixed parent" + raise TetherError(message) + parent = descriptor if path == root else os.open(root.name, flags, dir_fd=descriptor) + try: + with suppress(FileExistsError): + os.mkdir(path.name, _PRIVATE_DIRECTORY_MODE, dir_fd=parent) + child = os.open(path.name, flags, dir_fd=parent) + try: + observed = os.fstat(child) + if observed.st_uid != os.getuid() or stat.S_IMODE(observed.st_mode) != _PRIVATE_DIRECTORY_MODE: + message = "Unsafe Tether runtime directory" + raise TetherError(message) + finally: + os.close(child) + finally: + if parent != descriptor: + os.close(parent) + finally: + os.close(descriptor) + + @staticmethod + def process_cgroup(pid: int) -> str: + """Retain the kernel's exact cgroup, never a process-group approximation.""" + _require(type(pid) is int and pid > 0) + rows = Path(f"/proc/{pid}/cgroup").read_text().splitlines() + paths = [line[3:] for line in rows if line.startswith("0::/")] + _require(len(paths) == 1) + _user_group(paths[0]) + return paths[0] + + @staticmethod + def cgroup_empty(group: str) -> bool: + """Require the complete retained subtree to contain no surviving tasks.""" + _user_group(group) + root = Path("/sys/fs/cgroup") / group.lstrip("/") + if not root.exists(): + return True + _require((root / "cgroup.procs").is_file()) + return all(not path.read_text().strip() for path in root.rglob("cgroup.procs")) + + def _netns(self, pid: int) -> int: + _require(type(pid) is int and pid > 0) + netns = Path(f"/proc/{pid}/ns/net").stat().st_ino + if netns == Path("/proc/self/ns/net").stat().st_ino: + message = "Tether role unexpectedly shares the host network" + raise TetherError(message) + if self.exists("container", "lychd-vessel"): + core = self.row("container", "lychd-vessel") + core_pid = core.get("State", {}).get("Pid", 0) + if type(core_pid) is int and core_pid > 0 and netns == Path(f"/proc/{core_pid}/ns/net").stat().st_ino: + message = "Tether role unexpectedly shares the Core network" + raise TetherError(message) + return netns + + def capture(self, config: RuntimeConfig, image: dict[str, str]) -> dict[str, Any]: + """Prove image, namespace and an exact owned cgroup before route admission.""" + match = _ROLE_NAME.fullmatch(config.name) + _require(match is not None) + if match is None: + message = "Invalid Tether role name" + raise TetherError(message) + launch = self.unit_incarnation(config.name + ".service") + _require(bool(launch["InvocationID"])) + row = self.row("container", config.name) + identity = _identifier(row.get("Id")) + pod = self.row("pod", match[1]) + _require(pod.get("Name") == match[1]) + pod_id = _identifier(pod.get("Id")) + state = row.get("State", {}) + _require( + row.get("Name") == config.name + and row.get("Image") == image["id"] + and row.get("ImageDigest") == image["digest"] + and row.get("Pod") == pod_id + and state.get("Running") is True + ) + pid = state.get("Pid") + netns = self._netns(pid) + group = _container_group(self.process_cgroup(pid), identity, launch["ControlGroup"]) + fresh = self.row("container", identity) + _require( + fresh.get("Id") == identity + and fresh.get("State", {}).get("Pid") == pid + and fresh.get("State", {}).get("Running") is True + ) + _require(self.unit_incarnation(config.name + ".service") == launch) + return { + "container_id": identity, + "container_name": config.name, + "unit_name": config.name + ".service", + "pod_id": pod_id, + "image_id": image["id"], + "netns": netns, + "cgroup": group, + "invocation_id": launch["InvocationID"], + "unit_cgroup": launch["ControlGroup"], + } + + def capture_pod(self, pod_name: str) -> dict[str, Any]: + """Retain exact generated Pod/infra identity before admitting its roles.""" + _require(type(pod_name) is str and _POD_NAME.fullmatch(pod_name) is not None) + unit = pod_name + "-pod.service" + launch = self.unit_incarnation(unit) + _require(bool(launch["InvocationID"])) + row = self.row("pod", pod_name) + pod_id, infra_id = ( + _identifier(row.get("Id")), + _identifier(row.get("InfraContainerID")), + ) + _require(row.get("Name") == pod_name) + infra = self.row("container", infra_id) + _require( + infra.get("Id") == infra_id and infra.get("Pod") == pod_id and infra.get("State", {}).get("Running") is True + ) + pid = infra["State"].get("Pid") + netns = self._netns(pid) + group = _container_group(self.process_cgroup(pid), infra_id, launch["ControlGroup"]) + fresh = self.row("pod", pod_id) + _require( + fresh.get("Id") == pod_id and fresh.get("InfraContainerID") == infra_id and fresh.get("Name") == pod_name + ) + _require(self.unit_incarnation(unit) == launch) + return { + "pod_id": pod_id, + "pod_name": pod_name, + "unit_name": unit, + "infra_id": infra_id, + "infra_cgroup": group, + "invocation_id": launch["InvocationID"], + "unit_cgroup": launch["ControlGroup"], + "netns": netns, + } + + def observe(self, config: RuntimeConfig) -> dict[str, Any]: + """Read a fresh role heartbeat bound to this exact public configuration.""" + raw = self.command( + "podman", + "exec", + config.name, + "python3", + "-m", + "lychd_tether_runtime", + "--observe", + "/etc/lychd-tether/config.json", + ) + value = json.loads(raw) + keys = ( + "role", + "network_id", + "installation_epoch", + "generation", + "policy_digest", + ) + if any(value.get(key) != config.payload[key] for key in keys) or value.get("enabled") is not True: + message = "Tether runtime has not acknowledged current admission" + raise TetherError(message) + return value + + @staticmethod + def _retained_launch(retained: dict[str, Any]) -> None: + invocation, group = retained["invocation_id"], retained["unit_cgroup"] + _require(type(invocation) is str and re.fullmatch(r"[0-9a-f]{32}", invocation) is not None) + _require(type(group) is str) + if group: + _require(_user_group(group).name == retained["unit_name"]) + + @staticmethod + def _retained_role(retained: dict[str, Any]) -> None: + _require(type(retained) is dict and set(retained) == _ROLE_FIELDS) + name = retained["container_name"] + _require( + type(name) is str and _ROLE_NAME.fullmatch(name) is not None and retained["unit_name"] == name + ".service" + ) + for field in ("container_id", "pod_id", "image_id"): + _identifier(retained[field]) + TetherHost._retained_launch(retained) + _require(type(retained["netns"]) is int and retained["netns"] > 0) + _require( + _container_group(retained["cgroup"], retained["container_id"], retained["unit_cgroup"]) + == retained["cgroup"] + ) + if "libpod-payload-" in retained["cgroup"]: + _require(Path(retained["cgroup"]).parent.name == retained["unit_name"]) + + def _role_present(self, retained: dict[str, Any]) -> bool: + identity, name = retained["container_id"], retained["container_name"] + by_id, by_name = ( + self.exists("container", identity), + self.exists("container", name), + ) + if by_name: + _require(self.row("container", name).get("Id") == identity) + if by_id: + row = self.row("container", identity) + _require( + by_name + and row.get("Id") == identity + and row.get("Name") == name + and row.get("Image") == retained["image_id"] + and row.get("Pod") == retained["pod_id"] + ) + return row.get("State", {}).get("Running") is True + _require(not by_name) + return False + + def _settled(self, unit: str, group: str) -> None: + deadline = time.monotonic() + 10 + while not (self.cgroup_empty(group) and self._quiescent(self.owned_unit(unit))): + if time.monotonic() >= deadline: + message = "Tether descendant or unit-job closure remains incomplete" + raise TetherError(message) + time.sleep(0.05) + + def _unit_closed(self, name: str) -> bool: + """Observe a retired fixed unit without acquiring authority to actuate it.""" + properties = ("Id", "LoadState", "ActiveState", "SubState", "MainPID", "ControlPID", "Job") + executable = trusted_host_tool("systemctl") + _require(executable is not None) + result = self.runner.run( + (cast("str", executable), "--user", "show", name, *("--property=" + key for key in properties)), timeout_s=5 + ) + _require(result.returncode in {0, 1, 4} and len(result.stdout.encode()) <= _MAX_OUTPUT) + fields: dict[str, str] = {} + for line in result.stdout.splitlines(): + key, separator, value = line.partition("=") + _require(bool(separator) and key not in fields) + fields[key] = value + _require(set(fields) == set(properties) and fields["Id"] == name) + if result.returncode: + _require(fields["LoadState"] == "not-found") + return ( + fields["LoadState"] in {"loaded", "not-found"} + and fields["ActiveState"] == "inactive" + and fields["SubState"] == "dead" + and fields["MainPID"] == fields["ControlPID"] == "0" + and fields["Job"] in {"", "0"} + ) + + def _failed_role_state(self, retained: dict[str, Any]) -> dict[str, str] | None: + """Observe only the exact owned terminal failure, with no pending restart intent.""" + unit = self.owned_unit(retained["unit_name"]) + if unit.get("ActiveState") != "failed" or unit.get("SubState") != "failed": + return None + expected = { + "Id": retained["unit_name"], + "LoadState": "loaded", + "ActiveState": "failed", + "SubState": "failed", + "MainPID": "0", + "ControlPID": "0", + "Job": unit.get("Job", ""), + "NRestarts": "0", + "InvocationID": retained["invocation_id"], + } + _require(unit.get("Job") in {"", "0"}) + _require(all(unit.get(key) == expected[key] for key in ("LoadState", "MainPID", "ControlPID"))) + properties = (*expected, "ControlGroup") + raw = self.command( + "systemctl", "--user", "show", retained["unit_name"], *("--property=" + key for key in properties) + ) + fields: dict[str, str] = {} + for line in raw.splitlines(): + key, separator, value = line.partition("=") + _require(bool(separator) and key not in fields) + fields[key] = value + _require(fields.get("ControlGroup") in {"", retained["unit_cgroup"]}) + _require(fields == {**expected, "ControlGroup": fields.get("ControlGroup")}) + return fields + + def _failed_role_closed(self, retained: dict[str, Any]) -> bool: + """Bracket vanished-body and whole-cgroup proofs with exact failed-launch authority.""" + before = self._failed_role_state(retained) + if before is None: + return False + _require(not self.exists("container", retained["container_id"])) + _require(not self.exists("container", retained["container_name"])) + _require(self.cgroup_empty(retained["cgroup"])) + _require(self.cgroup_empty(retained["unit_cgroup"])) + _require(self._failed_role_state(retained) == before) + return True + + def closed(self, retained: dict[str, Any]) -> bool: + """Prove retired or exactly retained terminal-failed role closure without effects.""" + self._retained_role(retained) + return ( + not self._role_present(retained) + and self.cgroup_empty(retained["cgroup"]) + and (self._unit_closed(retained["unit_name"]) or self._failed_role_closed(retained)) + ) + + def pod_closed(self, retained: dict[str, Any]) -> bool: + """Prove retained Pod closure without requiring a surviving binding.""" + self._retained_pod(retained) + return ( + not self._pod_present(retained) + and self.cgroup_empty(retained["infra_cgroup"]) + and self._unit_closed(retained["unit_name"]) + ) + + def require_core(self, image_reference: str) -> None: + """Bind the running Core body to its exact owned manager invocation.""" + name, unit_name = "lychd-vessel", "lychd-vessel.service" + unit = self.owned_unit(unit_name) + _require(unit.get("LoadState") == "loaded" and unit.get("ActiveState") == "active") + launch = self.unit_incarnation(unit_name) + _require(bool(launch["InvocationID"]) and bool(launch["ControlGroup"])) + expected = _identifier(self.row("image", image_reference).get("Id")) + row = self.row("container", name) + identity = _identifier(row.get("Id")) + state = row.get("State", {}) + _require(row.get("Name") == name and row.get("Image") == expected and state.get("Running") is True) + pid = state.get("Pid") + _require(type(pid) is int and pid > 0) + group = _container_group(self.process_cgroup(pid), identity, launch["ControlGroup"]) + # Core's Quadlet uses split cgroups: its payload belongs below this unit. + _require(Path(group).is_relative_to(_user_group(launch["ControlGroup"]))) + fresh = self.row("container", identity) + _require( + fresh.get("Id") == identity + and fresh.get("Name") == name + and fresh.get("Image") == expected + and fresh.get("State", {}).get("Running") is True + and fresh.get("State", {}).get("Pid") == pid + ) + _require(self.unit_incarnation(unit_name) == launch) + + def stop_unretained(self, container_name: str) -> None: + """Cancel an owned failed launch only while no named body exists.""" + _require(type(container_name) is str and _ROLE_NAME.fullmatch(container_name) is not None) + unit_name = container_name + ".service" + _require(not self.exists("container", container_name)) + if self._unit_closed(unit_name): + return + launch = self.unit_incarnation(unit_name) + group = launch["ControlGroup"] + _require(not self.exists("container", container_name)) + self.command("systemctl", "--user", "stop", unit_name) + deadline = time.monotonic() + 10 + while not (self._quiescent(self.owned_unit(unit_name)) and (not group or self.cgroup_empty(group))): + if time.monotonic() >= deadline: + message = "Tether failed-launch closure remains incomplete" + raise TetherError(message) + time.sleep(0.05) + _require(not self.exists("container", container_name)) + + def _stop_launch(self, retained: dict[str, Any]) -> None: + current = self.unit_incarnation(retained["unit_name"]) + if current["InvocationID"]: + _require( + current + == { + "InvocationID": retained["invocation_id"], + "ControlGroup": retained["unit_cgroup"], + } + ) + + def stop(self, retained: dict[str, Any]) -> None: + """Cancel exact owned-unit restart intent even if its retained body vanished.""" + if self.closed(retained): + return + self._stop_launch(retained) + self._role_present(retained) + self.command("systemctl", "--user", "stop", retained["unit_name"]) + self._settled(retained["unit_name"], retained["cgroup"]) + _require(not self._role_present(retained)) + + @staticmethod + def _retained_pod(retained: dict[str, Any]) -> None: + _require(type(retained) is dict and set(retained) == _POD_FIELDS) + name = retained["pod_name"] + _require( + type(name) is str + and _POD_NAME.fullmatch(name) is not None + and retained["unit_name"] == name + "-pod.service" + ) + for field in ("pod_id", "infra_id"): + _identifier(retained[field]) + TetherHost._retained_launch(retained) + _require(type(retained["netns"]) is int and retained["netns"] > 0) + _require( + _container_group(retained["infra_cgroup"], retained["infra_id"], retained["unit_cgroup"]) + == retained["infra_cgroup"] + ) + if "libpod-payload-" in retained["infra_cgroup"]: + _require(Path(retained["infra_cgroup"]).parent.name == retained["unit_name"]) + + def _pod_present(self, retained: dict[str, Any]) -> bool: + identity, name, infra_id = ( + retained["pod_id"], + retained["pod_name"], + retained["infra_id"], + ) + by_id, by_name = self.exists("pod", identity), self.exists("pod", name) + if by_name: + _require(self.row("pod", name).get("Id") == identity) + if by_id: + row = self.row("pod", identity) + _require( + by_name + and row.get("Id") == identity + and row.get("Name") == name + and row.get("InfraContainerID") == infra_id + ) + members = row.get("Containers") + _require(type(members) is list) + members = cast("list[Any]", members) + _require(all(type(member) is dict and member.get("Id") == infra_id for member in members)) + else: + _require(not by_name) + if self.exists("container", infra_id): + infra = self.row("container", infra_id) + _require(infra.get("Id") == infra_id and infra.get("Pod") == identity) + return infra.get("State", {}).get("Running") is True + return False + + def stop_pod(self, retained: dict[str, Any]) -> None: + """Stop only a retained generated Pod and settle its exact infra subtree.""" + if self.pod_closed(retained): + return + self._stop_launch(retained) + self._pod_present(retained) + self.command("systemctl", "--user", "stop", retained["unit_name"]) + self._settled(retained["unit_name"], retained["infra_cgroup"]) + _require(not self._pod_present(retained)) diff --git a/src/lychd/system/services/tether/keys.py b/src/lychd/system/services/tether/keys.py new file mode 100644 index 00000000..52d9babd --- /dev/null +++ b/src/lychd/system/services/tether/keys.py @@ -0,0 +1,579 @@ +"""Absent-only native key custody with exact public Podman identity receipts. + +The caller holds the host lifecycle transaction lock across each public method. +Private values exist only in bounded local variables, stdin, Podman's secret +store, and a short-lived owned SSH generation directory. Pending receipts carry +public identity before creation; uncertainty never authorizes replacement. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import re +import stat +import struct +import tempfile +import uuid +from collections.abc import Iterable, Mapping +from contextlib import AbstractContextManager +from dataclasses import dataclass +from itertools import islice +from pathlib import Path +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast + +from lychd.lib.json_objects import unique_json_object +from lychd.system.host_tools import trusted_host_tool +from lychd.system.operator.process import SubprocessRunner +from lychd.system.secret_names import is_valid_podman_secret_name +from lychd.system.services.tether.models import TetherError + +if TYPE_CHECKING: + from lychd.system.operator.process import InputProcessRunner + from lychd.system.services.tether.custody import CustodyRecords + +Purpose = Literal["wireguard", "ssh-host", "preshared"] +_MAX_KEY_BYTES = 8192 +_RAW_KEY_BYTES = 32 +_SSH_PRIVATE_BYTES = 64 +_SSH_PUBLIC_BYTES = 51 +_SSH_PUBLIC_FIELDS = 2 +_UINT32_BYTES = 4 +_SSH_ENVELOPE_LINES = 3 +_MAX_OUTPUT = 65536 +_MAX_REFERENCES = 512 +_PRIVATE_MODE = 0o600 +_DIRECTORY_MODE = 0o700 +_SCHEMA = "lychd.tether.key" +_FIELDS = { + "schema", + "version", + "installation_epoch", + "reference", + "purpose", + "operation_id", + "public_key", + "fingerprint", +} +_LABEL_EPOCH = "io.lychd.tether.epoch" +_LABEL_PURPOSE = "io.lychd.tether.purpose" +_LABEL_OPERATION = "io.lychd.tether.operation" + + +class KeyCustody(Protocol): + """The existing custody operations used by key creation and observation.""" + + def attest(self) -> None: ... + + def read(self, name: str) -> dict[str, object] | None: ... + + def write(self, name: str, value: dict[str, object], *, immutable: bool = False) -> bool: ... + + def directory(self, name: str, *, public: bool = False) -> Path: ... + + def records_snapshot(self) -> AbstractContextManager[CustodyRecords]: ... + + +@dataclass(frozen=True) +class KeyObservation: + """Detached public results; no private material or continuing authority.""" + + identities: Mapping[str, Mapping[str, object]] + phases: Mapping[str, Literal["pending", "ready"]] + + def preparation_state(self, reference: str) -> Literal["absent", "pending", "ready"]: + _reference(reference) + return self.phases.get(reference, "absent") + + def require(self, references: Iterable[str]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for index, reference in enumerate(references): + _require(index < _MAX_REFERENCES) + _require(self.preparation_state(reference) == "ready" and reference in self.identities) + result[reference] = dict(self.identities[reference]) + _require(len(result) <= _MAX_REFERENCES) + return result + + +@dataclass(frozen=True) +class _KeyEntry: + phase: Literal["pending", "ready"] + record: dict[str, Any] + + +def _refuse() -> TetherError: + return TetherError("Tether key custody or operation refused") + + +def _require(condition: bool) -> None: # noqa: FBT001 - assertion helper + if not condition: + raise _refuse() + + +def _reference(reference: str) -> None: + _require( + type(reference) is str and is_valid_podman_secret_name(reference) and reference.startswith("lychd-tether-") + ) + + +def _name(reference: str, *, pending: bool = False) -> str: + suffix = "-pending" if pending else "" + return f"key-{hashlib.sha256(reference.encode()).hexdigest()}{suffix}.json" + + +def _raw32(value: str) -> bytes: + _require(type(value) is str) + encoded = value.removesuffix("\n") + decoded = base64.b64decode(encoded, validate=True) + _require(len(decoded) == _RAW_KEY_BYTES and any(decoded) and base64.b64encode(decoded).decode() == encoded) + return decoded + + +def _fingerprint(public: bytes) -> str: + return "SHA256:" + base64.b64encode(hashlib.sha256(public).digest()).decode().rstrip("=") + + +def _ssh_public(value: str) -> tuple[str, bytes]: + parts = value.strip().split(" ") + _require(len(parts) == _SSH_PUBLIC_FIELDS and parts[0] == "ssh-ed25519") + decoded = base64.b64decode(parts[1], validate=True) + _require(len(decoded) == _SSH_PUBLIC_BYTES and decoded[:19] == b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20") + _require(any(decoded[19:]) and base64.b64encode(decoded).decode() == parts[1]) + return " ".join(parts), decoded + + +class _SSHFields: + """Bounded parser for the generated unencrypted one-key OpenSSH envelope.""" + + def __init__(self, value: bytes) -> None: + """Consume only already size-bounded bytes.""" + self.value = value + self.offset = 0 + + def number(self) -> int: + _require(len(self.value) - self.offset >= _UINT32_BYTES) + result = struct.unpack_from(">I", self.value, self.offset)[0] + self.offset += 4 + return result + + def field(self) -> bytes: + size = self.number() + _require(size <= len(self.value) - self.offset) + result = self.value[self.offset : self.offset + size] + self.offset += size + return result + + def remaining(self) -> bytes: + return self.value[self.offset :] + + +def _ssh_seed(value: str) -> bytes: + """Extract seed only for in-memory cross-purpose comparison, never a receipt.""" + lines = value.splitlines() + _require(len(value.encode()) <= _MAX_KEY_BYTES and len(lines) >= _SSH_ENVELOPE_LINES) + _require(lines[0] == "-----BEGIN OPENSSH PRIVATE KEY-----" and lines[-1] == "-----END OPENSSH PRIVATE KEY-----") + raw = base64.b64decode("".join(lines[1:-1]), validate=True) + magic = b"openssh-key-v1\x00" + _require(raw.startswith(magic)) + outer = _SSHFields(raw[len(magic) :]) + _require(outer.field() == b"none" and outer.field() == b"none" and outer.field() == b"" and outer.number() == 1) + public = outer.field() + private = _SSHFields(outer.field()) + _require(not outer.remaining() and private.number() == private.number() and private.field() == b"ssh-ed25519") + public_bytes, private_bytes = private.field(), private.field() + _require(len(public_bytes) == _RAW_KEY_BYTES and len(private_bytes) == _SSH_PRIVATE_BYTES) + _require(hmac.compare_digest(private_bytes[_RAW_KEY_BYTES:], public_bytes)) + _require(public == b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + public_bytes) + _require(private.field() == b"") # Factory admits no comments or foreign metadata. + padding = private.remaining() + _require(bool(padding) and padding == bytes(range(1, len(padding) + 1))) + seed = private_bytes[:_RAW_KEY_BYTES] + _require(any(seed)) + return seed + + +def _private_file(path: Path) -> str: + """Open through non-symlink directories and validate the private inode.""" + _require(path.is_absolute() and ".." not in path.parts and bool(path.name)) + parent = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + for component in path.parent.parts[1:]: + following = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent) + os.close(parent) + parent = following + directory = os.fstat(parent) + _require(directory.st_uid == os.getuid() and stat.S_IMODE(directory.st_mode) == _DIRECTORY_MODE) + fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=parent) + try: + before = os.fstat(fd) + _require(stat.S_ISREG(before.st_mode) and before.st_uid == os.getuid() and before.st_nlink == 1) + _require(stat.S_IMODE(before.st_mode) == _PRIVATE_MODE and 0 < before.st_size <= _MAX_KEY_BYTES) + raw = os.read(fd, _MAX_KEY_BYTES + 1) + after = os.fstat(fd) + _require( + len(raw) == before.st_size + and (before.st_mtime_ns, before.st_ctime_ns) == (after.st_mtime_ns, after.st_ctime_ns) + ) + return raw.decode("ascii") + finally: + os.close(fd) + finally: + os.close(parent) + + +def _default_driver_directory(graph_root: str, configured: str) -> None: + """Prove the default local secret directory without following another storage owner.""" + root = Path(graph_root) + _require(root.is_absolute() and root.anchor == "/" and str(root) == graph_root and ".." not in root.parts) + _require(configured == str(root / "secrets" / "filedriver")) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + parent = os.open("/", flags) + try: + for component in root.parts[1:]: + following = os.open(component, flags, dir_fd=parent) + os.close(parent) + parent = following + metadata = os.fstat(parent) + sticky_system = metadata.st_uid == 0 and bool(metadata.st_mode & stat.S_ISVTX) + _require(metadata.st_uid in {0, os.getuid()} and (not metadata.st_mode & 0o022 or sticky_system)) + _require(os.fstat(parent).st_uid == os.getuid()) + for component in ("secrets", "filedriver"): + following = os.open(component, flags, dir_fd=parent) + os.close(parent) + parent = following + metadata = os.fstat(parent) + _require(metadata.st_uid == os.getuid() and stat.S_IMODE(metadata.st_mode) == _DIRECTORY_MODE) + finally: + os.close(parent) + + +class TetherKeys: + """Exact-reference factories and attestations; no replace, ignore, or deletion.""" + + def __init__(self, custody: KeyCustody, epoch: str, runner: InputProcessRunner | None = None) -> None: + """Bind existing host custody; callers serialize complete transactions.""" + _require(type(epoch) is str and re.fullmatch(r"[0-9a-f]{32}", epoch) is not None) + self.custody, self.epoch, self.runner = custody, epoch, runner or SubprocessRunner() + + def _tool(self, name: str) -> str: + result = trusted_host_tool(name, fallbacks=(Path("/usr/bin") / name, Path("/usr/sbin") / name)) + if result is None: + raise _refuse() + return result + + def _call(self, name: str, *arguments: str, value: str | None = None, absence: bool = False) -> str | None: + try: + local_engine = ("--remote=false",) if name == "podman" else () + argv = (self._tool(name), *local_engine, *arguments) + result = ( + self.runner.run(argv, timeout_s=30) + if value is None + else self.runner.run_with_input(argv, timeout_s=30, input_text=value) + ) + if absence and result.returncode == 1: + return None + _require(result.returncode == 0 and len(result.stdout.encode()) <= _MAX_OUTPUT) + except Exception: # noqa: BLE001 - suppress tool diagnostics and any secret-bearing exception chain + raise _refuse() from None + else: + return result.stdout + + def _record(self, value: Any, reference: str, *, final: bool) -> dict[str, Any]: + _require(type(value) is dict and set(value) == (_FIELDS | {"secret_id"} if final else _FIELDS)) + value = cast("dict[str, Any]", value) + _require(value["schema"] == _SCHEMA and type(value["version"]) is int and value["version"] == 1) + _require(value["installation_epoch"] == self.epoch and value["reference"] == reference) + _require(value["purpose"] in {"wireguard", "ssh-host", "preshared"}) + _require( + type(value["operation_id"]) is str and re.fullmatch(r"[0-9a-f]{32}", value["operation_id"]) is not None + ) + _require( + type(value["fingerprint"]) is str + and re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", value["fingerprint"]) is not None + ) + if value["purpose"] == "preshared": + _require(value["public_key"] is None) + elif value["purpose"] == "wireguard": + _raw32(value["public_key"]) + else: + _ssh_public(value["public_key"]) + if final: + _require( + type(value["secret_id"]) is str and re.fullmatch(r"[a-zA-Z0-9]{12,128}", value["secret_id"]) is not None + ) + return value + + def _labels(self, record: dict[str, Any]) -> dict[str, str]: + return {_LABEL_EPOCH: self.epoch, _LABEL_PURPOSE: record["purpose"], _LABEL_OPERATION: record["operation_id"]} + + def _identity(self, value: str, purpose: Purpose) -> tuple[str | None, str, bytes]: + if purpose == "ssh-host": + material = _ssh_seed(value) + output = self._call("ssh-keygen", "-y", "-P", "", "-f", "/dev/stdin", value=value) + if output is None: + raise _refuse() + public, encoded = _ssh_public(output) + return public, _fingerprint(encoded), material + material = _raw32(value) + if purpose == "preshared": + return None, _fingerprint(material), material + output = self._call("wg", "pubkey", value=base64.b64encode(material).decode() + "\n") + if output is None: + raise _refuse() + encoded = _raw32(output) + return base64.b64encode(encoded).decode(), _fingerprint(encoded), material + + def _file_driver(self, driver: Any) -> None: + """Admit only the file backend's freshly proved default storage descriptor.""" + _require(type(driver) is dict and driver.get("Name") == "file") + options = driver.get("Options") + _require(type(options) is dict and set(options) == {"path"} and type(options["path"]) is str) + graph_root = self._call("podman", "info", "--format", "{{.Store.GraphRoot}}") + _require(graph_root is not None) + _default_driver_directory(cast("str", graph_root).removesuffix("\n"), cast("dict[str, str]", options)["path"]) + + def _inspect(self, record: dict[str, Any]) -> tuple[dict[str, Any], str, bytes]: + reference = record["reference"] + target = record.get("secret_id", reference) + output = self._call("podman", "secret", "inspect", "--showsecret", target) + if output is None: + raise _refuse() + rows = json.loads(output, object_pairs_hook=unique_json_object) + _require(type(rows) is list and len(rows) == 1 and type(rows[0]) is dict) + row = cast("dict[str, Any]", rows[0]) + spec = row.get("Spec") + _require(type(spec) is dict and spec.get("Name") == reference and spec.get("Labels") == self._labels(record)) + spec = cast("dict[str, Any]", spec) + self._file_driver(spec.get("Driver")) + secret_id = row.get("ID") + _require(type(secret_id) is str and re.fullmatch(r"[a-zA-Z0-9]{12,128}", secret_id) is not None) + _require("secret_id" not in record or record["secret_id"] == secret_id) + value = row.get("SecretData") + _require(type(value) is str and 0 < len(value.encode()) <= _MAX_KEY_BYTES) + value = cast("str", value) + public, fingerprint, material = self._identity(value, record["purpose"]) + _require(public == record["public_key"] and hmac.compare_digest(fingerprint, record["fingerprint"])) + return {**record, "secret_id": secret_id}, value, material + + def _inventory(self, view: CustodyRecords) -> dict[str, _KeyEntry]: + pairs: dict[str, dict[bool, dict[str, Any]]] = {} + for name in view.names: + if not Path(name).name.startswith("key-") or Path(name).suffix != ".json": + continue + _require(re.fullmatch(r"key-[0-9a-f]{64}(?:-pending)?\.json", name) is not None) + row = view.read(name) + _require(row is not None) + reference = cast("dict[str, object]", row).get("reference") + _require(type(reference) is str) + reference = cast("str", reference) + _reference(reference) + pending = name.endswith("-pending.json") + _require(name == _name(reference, pending=pending)) + pairs.setdefault(reference, {})[pending] = self._record(row, reference, final=not pending) + _require(len(pairs) <= _MAX_REFERENCES) + result: dict[str, _KeyEntry] = {} + for reference, pair in sorted(pairs.items()): + pending = pair.get(True) + _require(pending is not None) + pending = cast("dict[str, Any]", pending) + final = pair.get(False) + if final is not None: + _require({name: final[name] for name in _FIELDS} == pending) + result[reference] = _KeyEntry("pending" if final is None else "ready", pending if final is None else final) + return result + + def observe(self) -> KeyObservation: + """Inspect each live owned backend once inside one fresh custody interval.""" + try: + identities: dict[str, Mapping[str, object]] = {} + materials: list[tuple[bytes, str | None, str]] = [] + with self.custody.records_snapshot() as view: + inventory = self._inventory(view) + for reference, entry in inventory.items(): + if ( + entry.phase == "pending" + and self._call("podman", "secret", "exists", reference, absence=True) is None + ): + continue + identity, _value, material = self._inspect(entry.record) + purpose, public = identity["purpose"], identity["public_key"] + for previous, previous_public, previous_purpose in materials: + _require(not hmac.compare_digest(material, previous)) + if purpose == "wireguard" or previous_purpose == "wireguard": + left, right = bytearray(material), bytearray(previous) + left[0] &= 248 + right[0] &= 248 + left[-1] = (left[-1] & 127) | 64 + right[-1] = (right[-1] & 127) | 64 + _require(not hmac.compare_digest(left, right)) + if public is not None and purpose == previous_purpose: + _require(public != previous_public) + materials.append((material, public, purpose)) + identities[reference] = MappingProxyType(identity) + result = KeyObservation( + MappingProxyType(identities), + MappingProxyType({name: entry.phase for name, entry in inventory.items()}), + ) + except Exception: # noqa: BLE001 - secret observations never escape diagnostics + raise _refuse() from None + else: + return result + + def owned_references(self) -> tuple[str, ...]: + """Observe exact owned live secrets, including recoverable pending creations.""" + return tuple(self.observe().identities) + + def preparation_state(self, reference: str) -> Literal["absent", "pending", "ready"]: + """Classify receipted preparation only; readiness still requires attestation.""" + try: + _reference(reference) + with self.custody.records_snapshot() as view: + entry = self._inventory(view).get(reference) + phase: Literal["absent", "pending", "ready"] = "absent" if entry is None else entry.phase + except Exception: # noqa: BLE001 - metadata diagnostics remain content-free + raise _refuse() from None + else: + return phase + + def known_references(self) -> tuple[str, ...]: + """Discover only positively receipted key records, including pending work.""" + try: + with self.custody.records_snapshot() as view: + result = tuple(self._inventory(view)) + except Exception: # noqa: BLE001 - public gateway has content-free diagnostics + raise _refuse() from None + else: + return result + + def _unique_material(self, reference: str, material: bytes, public: str | None, purpose: Purpose) -> None: + """Compare existing owned material without coupling absent pending creations.""" + + def scalar(value: bytes) -> bytes: + copy = bytearray(value) + copy[0] &= 248 + copy[-1] = (copy[-1] & 127) | 64 + return bytes(copy) + + with self.custody.records_snapshot() as view: + for other, entry in self._inventory(view).items(): + if other == reference: + continue + if entry.phase == "pending" and self._call("podman", "secret", "exists", other, absence=True) is None: + continue + record = entry.record + _identity, _value, previous = self._inspect(record) + _require(not hmac.compare_digest(material, previous)) + if purpose == "wireguard" or record["purpose"] == "wireguard": + _require(not hmac.compare_digest(scalar(material), scalar(previous))) + if public is not None and purpose == record["purpose"]: + _require(public != record["public_key"]) + # Snapshot exit proves unchanged custody before the caller may publish a receipt. + + def _existing(self, reference: str, purpose: Purpose, imported: str | None = None) -> dict[str, Any] | None: + final = self.custody.read(_name(reference)) + pending = self.custody.read(_name(reference, pending=True)) + if final is None and pending is None: + return None + _require(pending is not None) + pending = self._record(pending, reference, final=False) + _require(pending["purpose"] == purpose) + record = pending if final is None else self._record(final, reference, final=True) + _require({name: record[name] for name in _FIELDS} == pending) + identity, current, material = self._inspect(record) + if imported is not None: + _require(hmac.compare_digest(_raw32(current), _raw32(imported))) + self._unique_material(reference, material, identity["public_key"], purpose) + if final is None: + self.custody.write(_name(reference), identity, immutable=True) + return identity + + def _create(self, reference: str, purpose: Purpose, value: str) -> dict[str, Any]: + public, fingerprint, material = self._identity(value, purpose) + self._unique_material(reference, material, public, purpose) + pending: dict[str, Any] = { + "schema": _SCHEMA, + "version": 1, + "installation_epoch": self.epoch, + "reference": reference, + "purpose": purpose, + "operation_id": uuid.uuid4().hex, + "public_key": public, + "fingerprint": fingerprint, + } + self.custody.write(_name(reference, pending=True), pending, immutable=True) + labels = tuple(part for name, label in self._labels(pending).items() for part in ("--label", f"{name}={label}")) + secret_id = self._call("podman", "secret", "create", "--driver", "file", *labels, reference, "-", value=value) + if secret_id is None: + raise _refuse() + candidate = self._record({**pending, "secret_id": secret_id.strip()}, reference, final=True) + identity, _value, _material = self._inspect(candidate) + self.custody.write(_name(reference), identity, immutable=True) + return identity + + def ensure(self, reference: str, purpose: Literal["wireguard", "ssh-host"]) -> dict[str, Any]: + """Generate only for a proven absent name; resume exact labeled pending creation.""" + try: + _reference(reference) + _require(purpose in {"wireguard", "ssh-host"}) + self.custody.attest() + existing = self._existing(reference, purpose) + if existing is not None: + return existing + _require(self._call("podman", "secret", "exists", reference, absence=True) is None) + if purpose == "wireguard": + value = self._call("wg", "genkey") + if value is None: + raise _refuse() # noqa: TRY301 - retain type narrowing inside quarantine + value = base64.b64encode(_raw32(value)).decode() + "\n" + else: + directory = self.custody.directory("key-staging") + with tempfile.TemporaryDirectory(prefix="generation-", dir=directory) as scratch: + path = Path(scratch) / "host" + self._call("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "", "-f", str(path)) + value = _private_file(path) + except Exception: # noqa: BLE001 - secret values and tool errors never cross this boundary + raise _refuse() from None + else: + try: + return self._create(reference, purpose, value) + except Exception: # noqa: BLE001 - preserve the same diagnostic quarantine during creation + raise _refuse() from None + + def import_key(self, reference: str, path: Path, purpose: Literal["wireguard", "preshared"]) -> dict[str, Any]: + """Import one private owned file without hooks, replacement, or name adoption.""" + try: + _reference(reference) + _require(purpose in {"wireguard", "preshared"}) + self.custody.attest() + value = base64.b64encode(_raw32(_private_file(path))).decode() + "\n" + existing = self._existing(reference, purpose, imported=value) + if existing is not None: + return existing + _require(self._call("podman", "secret", "exists", reference, absence=True) is None) + return self._create(reference, purpose, value) + except Exception: # noqa: BLE001 - import diagnostics are intentionally content-free + raise _refuse() from None + + def attest(self, reference: str) -> dict[str, Any]: + """Check exact receipt ID, labels, name and current cryptographic identity.""" + return self.attest_many((reference,))[reference] + + def attest_many(self, references: Iterable[str]) -> dict[str, dict[str, Any]]: + """Freshly attest all requested ready identities without repeating backend reads.""" + try: + requested = tuple(islice(references, _MAX_REFERENCES + 1)) + _require(len(requested) <= _MAX_REFERENCES) + for reference in requested: + _reference(reference) + result = self.observe().require(requested) + except Exception: # noqa: BLE001 - no secret-bearing diagnostic escapes + raise _refuse() from None + else: + return result + + +__all__ = ("TetherKeys",) diff --git a/src/lychd/system/services/tether/models.py b/src/lychd/system/services/tether/models.py new file mode 100644 index 00000000..99964865 --- /dev/null +++ b/src/lychd/system/services/tether/models.py @@ -0,0 +1,80 @@ +"""Closed public host-operation intent; secret values never enter these DTOs.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from lychd.domain.tether.models import AttachmentAdmission, OfferedPeer, TetherRune + +MAX_DOCUMENT_BYTES = 1024 * 1024 +DEFAULT_OWNER_WEB_PORT = 7134 + + +class TetherError(RuntimeError): + """A bounded host refusal that never renders input or secret material.""" + + +class TetherIntent(BaseModel): + """One original public topology and initial admission request.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, populate_by_name=False) + + schema_name: Literal["lychd.tether.intent"] = Field(default="lychd.tether.intent", alias="schema") + version: Literal[1] = 1 + rune: TetherRune + offered_peers: tuple[OfferedPeer, ...] = Field(default=(), max_length=128) + attachments: tuple[AttachmentAdmission, ...] = Field(default=(), max_length=32) + web_port: int = Field(default=DEFAULT_OWNER_WEB_PORT, strict=True, ge=1, le=65535) + + @field_validator("rune") + @classmethod + def immutable_image(cls, value: TetherRune) -> TetherRune: + """Refuse a moving image before original initialization creates any files.""" + if re.fullmatch(r"[a-z0-9._:/-]+@sha256:[0-9a-f]{64}", value.quadlet.image) is None: + message = "Native Tether intent requires an immutable OCI image digest" + raise ValueError(message) + return value + + @field_validator("version", mode="before") + @classmethod + def exact_version(cls, value: object) -> object: + """Reject a boolean masquerading as protocol version one.""" + if type(value) is not int: + message = "Tether intent version must be an integer" + raise ValueError(message) + return value + + +def canonical_bytes(value: object) -> bytes: + """Encode bounded canonical JSON without non-finite numbers or implicit objects.""" + try: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json", by_alias=True) + result = ( + json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + "\n" + ).encode("ascii") + if len(result) > MAX_DOCUMENT_BYTES: + msg = "Tether document exceeds its bound" + raise TetherError(msg) + except (ValueError, TypeError, OverflowError, RecursionError): + msg = "Tether document cannot be encoded" + raise TetherError(msg) from None + return result + + +def digest(value: object) -> str: + """Identify canonical public intent independently of live filesystem identity.""" + return hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def load_intent(path: Path) -> TetherIntent: + """Load private public-intent input through the descriptor-safe custody boundary.""" + from lychd.system.services.tether.custody import load_intent as read_intent + + return read_intent(path) diff --git a/src/lychd/system/services/tether/runtime.py b/src/lychd/system/services/tether/runtime.py new file mode 100644 index 00000000..7814cd5f --- /dev/null +++ b/src/lychd/system/services/tether/runtime.py @@ -0,0 +1,961 @@ +"""Standalone, standard-library Tether container roles with closed public inputs. + +The compiler supplies destinations; clients select them but cannot supply new ones. +Every relay owns both streams. Durable fences are polled independently of the host +request, and all missing, malformed or mismatched authority fails closed. +""" + +from __future__ import annotations + +import asyncio +import base64 +import errno +import fcntl +import hashlib +import ipaddress +import json +import os +import re +import signal +import socket +import stat +import struct +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, cast + +CONFIG = Path("/etc/lychd-tether/config.json") +FENCES = Path("/etc/lychd-tether/fences.json") +IP, NFT, WG, SSHD = "/usr/sbin/ip", "/usr/sbin/nft", "/usr/bin/wg", "/usr/sbin/sshd" +POLL_SECONDS = 0.1 +MAX_CONFIG = 262144 +MAX_HEADER = 2048 +KEY_BYTES = 32 +MAX_SOCKET_PATH = 108 +MAX_STREAMS = 128 +STATUS_MAX_AGE = 3 +PRIVATE_DIRECTORY_MODE = 0o700 +PRIVATE_FILE_MODE = 0o600 +STATUS = Path("/tmp/lychd-tether-status/status.json") # noqa: S108 - private role tmpfs, directory mode verified +ACK = b'{"version":1,"ok":true}\n' +COMMON = {"schema", "version", "role", "network_id", "installation_epoch", "generation", "policy_digest", "fence_path"} +ROLE_FIELDS = { + "wireguard": { + "interface", + "address", + "listen_port", + "private_key_path", + "peers", + "ingress", + "egress", + "owner_loopback_port", + }, + "imported-relay": {"remote_lich_id", "allowed_callers", "caller_uid", "routes"}, + "offered-relay": {"services", "peers"}, + "owner-ssh": { + "peer_id", + "peer_generation", + "peer_address", + "listen_address", + "listen_port", + "ssh_public_key", + "ssh_host_key_path", + "target_address", + "target_port", + }, +} + + +def require(value: bool) -> None: # noqa: FBT001 - assertion helper, not a mode flag + """Keep runtime failure messages free of input and secret material.""" + if not value: + message = "Tether runtime input or authority refused" + raise ValueError(message) + + +def exact(value: Any, fields: set[str]) -> dict[str, Any]: + """Admit one closed object, never a permissive imported configuration.""" + require(type(value) is dict and set(value) == fields) + return value + + +def integer(value: Any, minimum: int = 1, maximum: int = 65535) -> int: + require(type(value) is int and minimum <= value <= maximum) + return value + + +def identifier(value: Any) -> str: + require(type(value) is str and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:@-]{0,127}", value) is not None) + return value + + +def address(value: Any) -> str: + require(type(value) is str) + parsed = ipaddress.IPv4Address(value) + require(str(parsed) == value and not parsed.is_multicast and not parsed.is_unspecified) + return value + + +def key(value: Any) -> str: + require(type(value) is str) + decoded = base64.b64decode(value, validate=True) + require(len(decoded) == KEY_BYTES and any(decoded) and base64.b64encode(decoded).decode() == value) + return value + + +def rows(value: Any, maximum: int = 256) -> list[Any]: + require(type(value) is list and len(value) <= maximum) + return value + + +def unique(values: list[Any]) -> None: + require(len(values) == len(set(values))) + + +def read_file(path: Path, maximum: int = MAX_CONFIG, *, secret: bool = False) -> bytes: + """Read one non-symlink regular file through its verified descriptor.""" + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + info = os.fstat(fd) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and not info.st_mode & 0o022) + if secret: + require(info.st_uid == os.geteuid() and not info.st_mode & 0o077) + result = os.read(fd, maximum + 1) + require(len(result) <= maximum) + return result + finally: + os.close(fd) + + +def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + unique([name for name, _ in pairs]) + return dict(pairs) + + +def decode(raw: bytes) -> Any: + return json.loads(raw.decode("ascii"), object_pairs_hook=_object) + + +def validate_config(p: Any) -> dict[str, Any]: # noqa: C901, PLR0912, PLR0915 - closed role wire validator + """Validate the compiler wire format again at the capability boundary.""" + require(type(p) is dict and p.get("role") in ROLE_FIELDS) + p = cast("dict[str, Any]", p) + exact(p, COMMON | ROLE_FIELDS[p["role"]]) + require(p["schema"] == "lychd.tether.runtime" and type(p["version"]) is int and p["version"] == 1) + require(p["fence_path"] == str(FENCES)) + identifier(p["network_id"]) + integer(p["generation"], 1, 2**63 - 1) + require(type(p["installation_epoch"]) is str and re.fullmatch(r"[0-9a-f]{32}", p["installation_epoch"]) is not None) + require(type(p["policy_digest"]) is str and re.fullmatch(r"[0-9a-f]{64}", p["policy_digest"]) is not None) + role = p["role"] + if role == "wireguard": + require( + type(p["interface"]) is str and re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,14}", p["interface"]) is not None + ) + require(p["interface"] not in {"lo", "eth0", "tap0"}) + local = ipaddress.IPv4Interface(p["address"]) + require(str(local) == p["address"]) + address(str(local.ip)) + integer(p["listen_port"], 0) + require(p["private_key_path"] == "/run/secrets/wg-private") + for peer in rows(p["peers"]): + exact( + peer, + { + "id", + "generation", + "public_key", + "allowed_ips", + "endpoint_address", + "endpoint_port", + "keepalive", + "preshared_key_path", + }, + ) + identifier(peer["id"]) + integer(peer["generation"], 1, 2**63 - 1) + key(peer["public_key"]) + for item in rows(peer["allowed_ips"], 64): + network = ipaddress.IPv4Network(item) + require(network.prefixlen == KEY_BYTES and str(network) == item) + address(str(network.network_address)) + require(bool(peer["allowed_ips"])) + if peer["endpoint_address"] is None: + require(peer["endpoint_port"] is None and p["listen_port"] != 0) + else: + address(peer["endpoint_address"]) + integer(peer["endpoint_port"]) + integer(peer["keepalive"], 0, 65535) + secret = peer["preshared_key_path"] + require( + secret is None + or (type(secret) is str and re.fullmatch(r"/run/secrets/psk-[0-9a-f]{8,64}", secret) is not None) + ) + unique([peer["id"] for peer in p["peers"]]) + unique([peer["public_key"] for peer in p["peers"]]) + unique([item for peer in p["peers"] for item in peer["allowed_ips"]]) + require(f"{local.ip}/32" not in [item for peer in p["peers"] for item in peer["allowed_ips"]]) + for entry in rows(p["ingress"]): + exact(entry, {"peer_address", "port"}) + address(entry["peer_address"]) + integer(entry["port"]) + for entry in rows(p["egress"]): + exact(entry, {"address", "port", "protocol"}) + address(entry["address"]) + integer(entry["port"]) + require(entry["protocol"] in {"tcp", "udp"}) + if p["owner_loopback_port"] is not None: + integer(p["owner_loopback_port"]) + elif role == "imported-relay": + identifier(p["remote_lich_id"]) + integer(p["caller_uid"], 0, 2**32 - 2) + for caller in rows(p["allowed_callers"], 64): + identifier(caller) + require(bool(p["allowed_callers"])) + for route in rows(p["routes"], 64): + exact(route, {"service_id", "generation", "socket_path", "address", "port", "application_identity_ref"}) + identifier(route["service_id"]) + identifier(route["application_identity_ref"]) + require(route["generation"] == p["generation"] and type(route["generation"]) is int) + require( + type(route["socket_path"]) is str + and re.fullmatch(r"/run/lychd-tether/[0-9a-f]{8,64}/[0-9a-f]{8,64}\.sock", route["socket_path"]) + is not None + ) + require(len(route["socket_path"].encode()) < MAX_SOCKET_PATH) + address(route["address"]) + integer(route["port"]) + unique([route["service_id"] for route in p["routes"]]) + unique([route["socket_path"] for route in p["routes"]]) + elif role == "offered-relay": + for service in rows(p["services"], 64): + exact(service, {"service_id", "listen_address", "listen_port", "backend_address", "backend_port"}) + identifier(service["service_id"]) + address(service["listen_address"]) + address(service["backend_address"]) + integer(service["listen_port"]) + integer(service["backend_port"]) + unique([service["service_id"] for service in p["services"]]) + unique([(service["listen_address"], service["listen_port"]) for service in p["services"]]) + for peer in rows(p["peers"]): + exact(peer, {"id", "generation", "address", "service_ids"}) + identifier(peer["id"]) + integer(peer["generation"], 1, 2**63 - 1) + address(peer["address"]) + for name in rows(peer["service_ids"], 64): + require(name in {service["service_id"] for service in p["services"]}) + unique([peer["id"] for peer in p["peers"]]) + unique([peer["address"] for peer in p["peers"]]) + else: + identifier(p["peer_id"]) + integer(p["peer_generation"], 1, 2**63 - 1) + address(p["peer_address"]) + address(p["listen_address"]) + integer(p["listen_port"]) + integer(p["target_port"]) + require(p["target_address"] == "127.0.0.1" and p["ssh_host_key_path"] == "/run/secrets/ssh-host") + require( + type(p["ssh_public_key"]) is str + and re.fullmatch(r"ssh-ed25519 [A-Za-z0-9+/]{68}", p["ssh_public_key"]) is not None + ) + return p + + +def load_config() -> dict[str, Any]: + return validate_config(decode(read_file(CONFIG))) + + +def validate_fences(raw: Any, p: dict[str, Any]) -> dict[str, Any]: + f = exact(raw, {"schema", "version", "installation_epoch", "network_id", "generation", "enabled", "peers"}) + require(f["schema"] == "lychd.tether.fences" and type(f["version"]) is int and f["version"] == 1) + require(f["installation_epoch"] == p["installation_epoch"] and f["network_id"] == p["network_id"]) + require(type(f["enabled"]) is bool and type(f["generation"]) is int and f["generation"] == p["generation"]) + for peer in rows(f["peers"]): + exact(peer, {"id", "generation", "enabled"}) + identifier(peer["id"]) + integer(peer["generation"], 1, 2**63 - 1) + require(type(peer["enabled"]) is bool) + unique([peer["id"] for peer in f["peers"]]) + return f + + +def load_fences(p: dict[str, Any]) -> dict[str, Any] | None: + try: + return validate_fences(decode(read_file(FENCES)), p) + except (OSError, ValueError, TypeError, KeyError): + return None + + +def admitted( + p: dict[str, Any], fence: dict[str, Any] | None, peer_id: str | None = None, generation: int | None = None +) -> bool: + if fence is None or not fence["enabled"]: + return False + if peer_id is None or (p["role"] == "wireguard" and p["listen_port"] == 0): + return True + return any( + row["id"] == peer_id and row["enabled"] and (generation is None or row["generation"] == generation) + for row in fence["peers"] + ) + + +def run(argv: tuple[str, ...], *, content: str | None = None, pass_fds: tuple[int, ...] = ()) -> str: + result = subprocess.run( # noqa: S603 - fixed tools and validated closed projections + argv, input=content, capture_output=True, text=True, timeout=10, check=False, pass_fds=pass_fds + ) + require(result.returncode == 0 and len(result.stdout) <= MAX_CONFIG) + return result.stdout + + +def nft_policy(p: dict[str, Any], fence: dict[str, Any] | None) -> str: # noqa: C901 - exact direction-specific rules + """No transit, IPv6, ambient DNS, or imported inner traffic on outer NICs.""" + interface = p["interface"] + local = p["address"].split("/")[0] + incoming: list[str] = [] + outgoing: list[str] = [] + if admitted(p, fence): + if p["listen_port"]: + incoming.append(f' iifname != "{interface}" meta nfproto ipv4 udp dport {p["listen_port"]} accept') + outgoing.append(f' oifname != "{interface}" meta nfproto ipv4 udp sport {p["listen_port"]} accept') + active_addresses = { + item.split("/")[0] + for peer in p["peers"] + if admitted(p, fence, peer["id"], peer["generation"]) + for item in peer["allowed_ips"] + } + for peer in p["peers"]: + if admitted(p, fence, peer["id"], peer["generation"]) and peer["endpoint_address"] is not None: + endpoint, port = peer["endpoint_address"], peer["endpoint_port"] + outgoing.append(f' oifname != "{interface}" ip daddr {endpoint} udp dport {port} accept') + incoming.append( + f' iifname != "{interface}" ip saddr {endpoint} udp sport {port} ct state established accept' + ) + for row in p["ingress"]: + if row["peer_address"] in active_addresses: + peer, port = row["peer_address"], row["port"] + incoming.append( + f' iifname "{interface}" ip saddr {peer} ip daddr {local} tcp dport {port} ct state new,established accept' + ) + outgoing.append( + f' oifname "{interface}" ip saddr {local} ip daddr {peer} tcp sport {port} ct state established accept' + ) + for row in p["egress"]: + if row["protocol"] != "tcp": + continue # Outer UDP comes only from the admitted WireGuard peer endpoints. + target, port = row["address"], row["port"] + operator = "!=" if p["listen_port"] else "" + outgoing.append( + f' oifname {operator} "{interface}" ip daddr {target} tcp dport {port} ct state new,established accept' + ) + incoming.append( + f' iifname {operator} "{interface}" ip saddr {target} tcp sport {port} ct state established accept' + ) + if p["owner_loopback_port"] is not None: + port = p["owner_loopback_port"] + for rules in (incoming, outgoing): + iface = "iifname" if rules is incoming else "oifname" + rules.extend( + ( + f' {iface} "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp dport {port} ct state new,established accept', + f' {iface} "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport {port} ct state established accept', + ) + ) + return ( + f'table inet lychd_tether {{\n comment "{ownership_marker(p)}"\n' + " chain input { type filter hook input priority -150; policy drop;\n" + + "\n".join(incoming) + + "\n }\n chain output { type filter hook output priority -150; policy drop;\n" + + "\n".join(outgoing) + + "\n }\n chain forward { type filter hook forward priority -150; policy drop; }\n}\n" + ) + + +def ownership_marker(p: dict[str, Any]) -> str: + """Identify the exact installation/network in its private namespace.""" + return f"lychd-tether:{p['installation_epoch']}:{p['network_id']}" + + +def existing_owned_table(p: dict[str, Any]) -> bool: + """Adopt only our exact marked table; never flush another firewall owner.""" + try: + document = decode(run((NFT, "-j", "list", "table", "inet", "lychd_tether")).encode()) + except ValueError: + return False # Create handles absence and refuses other tool failures. + require(type(document) is dict and type(document.get("nftables")) is list) + document = cast("dict[str, Any]", document) + tables: list[Any] = [row["table"] for row in document["nftables"] if type(row) is dict and "table" in row] + require(len(tables) == 1) + table = tables[0] + require(type(table) is dict) + table = cast("dict[str, Any]", table) + require( + table.get("family") == "inet" + and table.get("name") == "lychd_tether" + and table.get("comment") == ownership_marker(p) + ) + return True + + +def owned_interface_routes(p: dict[str, Any]) -> set[str] | None: + """Recover only the marked WireGuard interface and its exact static routes.""" + try: + links = decode(run((IP, "-j", "-d", "link", "show", "dev", p["interface"])).encode()) + except ValueError: + return None + require(type(links) is list and len(links) == 1) + links = cast("list[dict[str, Any]]", links) + require( + links[0].get("ifalias") == ownership_marker(p) and links[0].get("linkinfo", {}).get("info_kind") == "wireguard" + ) + addresses = decode(run((IP, "-j", "address", "show", "dev", p["interface"])).encode()) + require(type(addresses) is list and len(addresses) == 1) + addresses = cast("list[dict[str, Any]]", addresses) + actual = {(row.get("family"), row.get("local"), row.get("prefixlen")) for row in addresses[0].get("addr_info", [])} + expected = ipaddress.IPv4Interface(p["address"]) + require(actual == {("inet", str(expected.ip), expected.network.prefixlen)}) + result: set[str] = set() + for row in decode(run((IP, "-j", "route", "show", "dev", p["interface"])).encode()): + if row.get("protocol") == "kernel": + continue + destination = row.get("dst") + require(type(destination) is str and destination != "default") + network = ipaddress.IPv4Network(destination if "/" in destination else destination + "/32") + require(network.prefixlen == KEY_BYTES) + result.add(str(network)) + return result + + +def install_policy(p: dict[str, Any], fence: dict[str, Any] | None, *, initial: bool = False) -> None: + policy = nft_policy(p, fence) + content = "create " + policy if initial else "delete table inet lychd_tether\n" + policy + run((NFT, "-f", "-"), content=content) + + +def wireguard_config(p: dict[str, Any], fence: dict[str, Any] | None) -> str: + private = key(read_file(Path(p["private_key_path"]), 128, secret=True).decode().strip()) + lines = ["[Interface]", f"PrivateKey = {private}", f"ListenPort = {p['listen_port']}"] + for peer in p["peers"]: + if not admitted(p, fence, peer["id"], peer["generation"]): + continue + lines.extend( + ( + "[Peer]", + f"PublicKey = {peer['public_key']}", + f"AllowedIPs = {','.join(peer['allowed_ips'])}", + f"PersistentKeepalive = {peer['keepalive']}", + ) + ) + if peer["endpoint_address"] is not None: + lines.append(f"Endpoint = {peer['endpoint_address']}:{peer['endpoint_port']}") + if peer["preshared_key_path"] is not None: + secret = key(read_file(Path(peer["preshared_key_path"]), 128, secret=True).decode().strip()) + lines.append(f"PresharedKey = {secret}") + return "\n".join(lines) + "\n" + + +def configure_peers(p: dict[str, Any], fence: dict[str, Any] | None) -> None: + fd = os.memfd_create("tether-wireguard", os.MFD_CLOEXEC) + try: + os.write(fd, wireguard_config(p, fence).encode()) + os.lseek(fd, 0, os.SEEK_SET) + run((WG, "syncconf", p["interface"], f"/proc/self/fd/{fd}"), pass_fds=(fd,)) + finally: + os.close(fd) + + +def capabilities(role: str) -> dict[str, str]: + fields = dict(line.split(":", 1) for line in Path("/proc/self/status").read_text().splitlines() if ":" in line) + expected = 1 << 12 if role == "wireguard" else ((1 << 6) | (1 << 7) | (1 << 18) if role == "owner-ssh" else 0) + require(all(int(fields[name].strip(), 16) == expected for name in ("CapEff", "CapPrm", "CapBnd"))) + require( + all(int(fields[name].strip(), 16) == 0 for name in ("CapInh", "CapAmb")) + and fields.get("NoNewPrivs", "").strip() == "1" + ) + return {name: fields[name].strip() for name in ("CapEff", "CapPrm", "CapBnd", "NoNewPrivs")} + + +def notify_ready() -> None: + name = os.environ.get("NOTIFY_SOCKET") + if not name: + return + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as connection: + connection.connect("\x00" + name[1:] if name.startswith("@") else name) + connection.sendall(b"READY=1") + + +async def wireguard(p: dict[str, Any]) -> None: + """Reconcile public peer sets without restarting the shared interface.""" + interface = p["interface"] + created = False + install_policy(p, None, initial=not existing_owned_table(p)) + try: + existing_routes = owned_interface_routes(p) + if existing_routes is None: + run((IP, "link", "add", "dev", interface, "type", "wireguard")) + created = True + run((IP, "link", "set", "dev", interface, "alias", ownership_marker(p))) + run((IP, "address", "add", p["address"], "dev", interface)) + else: + created = True + previous: bytes | None = None + routes: set[str] = existing_routes or set() + observed = 0.0 + while True: + new = load_config() + require( + all( + new[field] == p[field] + for field in ( + "role", + "network_id", + "installation_epoch", + "interface", + "address", + "listen_port", + "private_key_path", + ) + ) + ) + require(new["generation"] >= p["generation"]) + p = new + fence = load_fences(p) + fingerprint = json.dumps([p, fence], sort_keys=True).encode() + if fingerprint != previous: + install_policy(p, None) + configure_peers(p, fence) + desired_routes = { + item + for peer in p["peers"] + if admitted(p, fence, peer["id"], peer["generation"]) + for item in peer["allowed_ips"] + } + # Route installation requires an up device; denial remains until reconciliation finishes. + run((IP, "link", "set", "dev", interface, "up")) + for item in routes - desired_routes: + run((IP, "route", "delete", item, "dev", interface)) + for item in desired_routes - routes: + run((IP, "route", "add", item, "dev", interface)) + routes = desired_routes + install_policy(p, fence) + previous = fingerprint + notify_ready() + if time.monotonic() - observed >= 1: + public_status(p, fence=fence, wg=True) + observed = time.monotonic() + await asyncio.sleep(POLL_SECONDS) + finally: + install_policy(p, None) + if created: + run((IP, "link", "delete", "dev", interface)) + + +class Relay: + """Own bounded streams and terminate the exact generation when fenced.""" + + def __init__(self, config: dict[str, Any]) -> None: + """Own stream tasks within exactly one immutable role generation.""" + self.config = config + self.tasks: dict[asyncio.Task[Any], tuple[str | None, int | None]] = {} + self.servers: list[asyncio.Server] = [] + self.sockets: list[Path] = [] + self.lock_descriptor: int | None = None + + def current(self, peer_id: str | None = None, generation: int | None = None) -> bool: + if peer_id is not None and not any( + row["id"] == peer_id and row["generation"] == generation for row in self.config["peers"] + ): + return False + return admitted(self.config, load_fences(self.config), peer_id, generation) + + def validate_header(self, raw: bytes, route: dict[str, Any], uid: int) -> None: + require(len(raw) <= MAX_HEADER and raw.endswith(b"\n") and uid == self.config["caller_uid"]) + h = exact( + decode(raw), + { + "version", + "installation_epoch", + "policy_digest", + "remote_lich_id", + "attachment_id", + "generation", + "service_id", + "caller", + }, + ) + require(type(h["version"]) is int and h["version"] == 1 and type(h["generation"]) is int) + require(h["policy_digest"] == self.config["policy_digest"]) + require( + h["installation_epoch"] == self.config["installation_epoch"] + and h["remote_lich_id"] == self.config["remote_lich_id"] + ) + require( + h["attachment_id"] == self.config["network_id"] + and h["generation"] == route["generation"] + and h["service_id"] == route["service_id"] + ) + require(type(h["caller"]) is str and h["caller"] in self.config["allowed_callers"]) + + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, route: dict[str, Any]) -> None: # noqa: PLR0915 - owned stream lifecycle + task = asyncio.current_task() + require(task is not None) + task = cast("asyncio.Task[Any]", task) + upstream: asyncio.StreamWriter | None = None + peer_id: str | None = None + generation: int | None = None + self.tasks[task] = (None, None) + try: + require(len(self.tasks) <= MAX_STREAMS and self.current()) + if self.config["role"] == "imported-relay": + credentials = writer.get_extra_info("socket").getsockopt( + socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i") + ) + _, uid, _ = struct.unpack("3i", credentials) + raw = await asyncio.wait_for(reader.readuntil(b"\n"), 5) + self.validate_header(raw, route, uid) + target, port = route["address"], route["port"] + else: + source = writer.get_extra_info("peername")[0] + peer = next( + ( + row + for row in self.config["peers"] + if row["address"] == source and route["service_id"] in row["service_ids"] + ), + None, + ) + require(peer is not None) + peer = cast("dict[str, Any]", peer) + peer_id, generation = peer["id"], peer["generation"] + target, port = route["backend_address"], route["backend_port"] + self.tasks[task] = (peer_id, generation) + require(self.current(peer_id, generation)) + remote, upstream = await asyncio.wait_for( + asyncio.open_connection(target, port, family=socket.AF_INET, limit=65536), 10 + ) + require(self.current(peer_id, generation)) + if self.config["role"] == "imported-relay": + writer.write(ACK) + await writer.drain() + + async def pump(source: asyncio.StreamReader, destination: asyncio.StreamWriter) -> None: + while True: + chunk = await source.read(16384) + if not chunk: + return + require(self.current(peer_id, generation)) + destination.write(chunk) + await destination.drain() + + left = asyncio.create_task(pump(reader, upstream)) + right = asyncio.create_task(pump(remote, writer)) + try: + await asyncio.wait((left, right), return_when=asyncio.FIRST_COMPLETED) + finally: + left.cancel() + right.cancel() + await asyncio.gather(left, right, return_exceptions=True) + except ( + OSError, + ValueError, + TypeError, + KeyError, + TimeoutError, + asyncio.IncompleteReadError, + asyncio.LimitOverrunError, + ): + pass + finally: + writer.close() + if upstream is not None: + upstream.close() + self.tasks.pop(task, None) + + def lock_producer(self, directory: Path) -> None: + """Hold an exclusive producer lease across restart-safe socket recovery.""" + metadata = directory.lstat() + require( + stat.S_ISDIR(metadata.st_mode) + and metadata.st_uid == os.geteuid() + and stat.S_IMODE(metadata.st_mode) == PRIVATE_DIRECTORY_MODE + ) + fd = os.open(directory / ".producer.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) + try: + info = os.fstat(fd) + require( + stat.S_ISREG(info.st_mode) + and info.st_nlink == 1 + and info.st_uid == os.geteuid() + and stat.S_IMODE(info.st_mode) == PRIVATE_FILE_MODE + ) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BaseException: + os.close(fd) + raise + self.lock_descriptor = fd + + def recover_socket(self, path: Path) -> None: + """Unlink only an owned stale socket, refusing live listeners and other types.""" + require(self.lock_descriptor is not None) + try: + before = path.lstat() + except FileNotFoundError: + return + require( + stat.S_ISSOCK(before.st_mode) + and before.st_uid == os.geteuid() + and stat.S_IMODE(before.st_mode) == PRIVATE_FILE_MODE + ) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe: + probe.settimeout(0.0) + result = probe.connect_ex(str(path)) + require(result == errno.ECONNREFUSED) + after = path.lstat() + require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino)) + path.unlink() + + async def serve(self) -> None: # noqa: C901, PLR0912 - fixed listener and fence lifecycle + try: + if self.config["role"] == "imported-relay": + require(bool(self.config["routes"])) + directories = {Path(row["socket_path"]).parent for row in self.config["routes"]} + require(len(directories) == 1) + self.lock_producer(next(iter(directories))) + for route in self.config["routes"]: + path = Path(route["socket_path"]) + self.recover_socket(path) + server = await asyncio.start_unix_server( + lambda r, w, route=route: self.handle(r, w, route), path=str(path), limit=MAX_HEADER + ) + path.chmod(0o600) # noqa: ASYNC240 - bounded local socket admission + self.sockets.append(path) + self.servers.append(server) + else: + for route in self.config["services"]: + server = await asyncio.start_server( + lambda r, w, route=route: self.handle(r, w, route), + route["listen_address"], + route["listen_port"], + family=socket.AF_INET, + limit=65536, + ) + self.servers.append(server) + notify_ready() + while True: + if self.config["role"] == "offered-relay": + new = load_config() + require( + all( + new[field] == self.config[field] + for field in ("role", "network_id", "installation_epoch", "generation", "services") + ) + ) + self.config = new + for task, (peer, generation) in tuple(self.tasks.items()): + if not self.current(peer, generation): + task.cancel() + counts: dict[str, int] = {} + for peer, _generation in self.tasks.values(): + name = peer or self.config["network_id"] + counts[name] = counts.get(name, 0) + 1 + public_status(self.config, fence=load_fences(self.config), streams=counts) + await asyncio.sleep(POLL_SECONDS) + finally: + for server in self.servers: + server.close() + await server.wait_closed() + tasks = tuple(self.tasks) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + for path in self.sockets: + path.unlink(missing_ok=True) + if self.lock_descriptor is not None: + os.close(self.lock_descriptor) + self.lock_descriptor = None + + +def ssh_config(p: dict[str, Any]) -> str: + """Generate only the single-purpose forwarding daemon's closed directives.""" + return f"""Port {p["listen_port"]} +ListenAddress {p["listen_address"]} +AddressFamily inet +HostKey /run/secrets/ssh-host +PidFile /run/sshd/tether.pid +AuthorizedKeysFile /run/sshd/authorized_keys +AllowUsers tether +AuthenticationMethods publickey +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitEmptyPasswords no +HostbasedAuthentication no +UsePAM no +UseDNS no +MaxSessions 0 +MaxAuthTries 3 +LoginGraceTime 20 +MaxStartups 8:30:16 +AllowTcpForwarding local +PermitOpen 127.0.0.1:{p["target_port"]} +PermitListen none +AllowStreamLocalForwarding no +PermitTTY no +PermitTunnel no +X11Forwarding no +AllowAgentForwarding no +PermitUserRC no +PermitUserEnvironment no +StrictModes yes +LogLevel ERROR +""" + + +def write_private(path: Path, content: str, mode: int = 0o600) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + try: + os.fchmod(fd, mode) + os.write(fd, content.encode()) + finally: + os.close(fd) + + +async def owner_ssh(p: dict[str, Any]) -> None: + require(admitted(p, load_fences(p), p["peer_id"], p["peer_generation"])) + read_file(Path(p["ssh_host_key_path"]), 8192, secret=True) + Path("/run/sshd").mkdir(mode=0o755, exist_ok=True) # noqa: ASYNC240 - fixed tmpfs + Path("/run/sshd").chmod(0o755) # noqa: ASYNC240 - permit only public key traversal + write_private(Path("/run/sshd/authorized_keys"), p["ssh_public_key"] + "\n", 0o644) + write_private(Path("/run/sshd/config"), ssh_config(p)) + run((SSHD, "-t", "-f", "/run/sshd/config")) + process = await asyncio.create_subprocess_exec( + SSHD, "-D", "-e", "-f", "/run/sshd/config", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + try: + notify_ready() + while process.returncode is None and admitted(p, load_fences(p), p["peer_id"], p["peer_generation"]): + public_status(p, fence=load_fences(p)) + await asyncio.sleep(POLL_SECONDS) + finally: + # This process is the container init. Its exit settles the PID namespace; + # host additionally stops the exact owner-peer container/cgroup. + if process.returncode is None: + process.terminate() + try: + await asyncio.wait_for(process.wait(), 2) + except TimeoutError: + process.kill() + await process.wait() + + +def policy_tokens(value: str) -> list[str]: + """Normalize nft formatting only, keeping interface comparisons significant.""" + value = value.replace("priority -150", "priority mangle") + value = re.sub(r"new\s*,\s*established", "established,new", value) + return re.findall(r'"[^"\n]*"|[A-Za-z0-9_.:/+-]+|!=|[{};,]', value) + + +def public_status( + p: dict[str, Any], *, fence: dict[str, Any] | None, streams: dict[str, int] | None = None, wg: bool = False +) -> None: + """Observe the supplied applied snapshot; consumers separately require current authority. + + WireGuard passes the fence used for its kernel reconciliation, so a concurrent + publication cannot turn an unapplied admission change into apparent kernel drift. + """ + result: dict[str, Any] = { + "schema": "lychd.tether.runtime-observation", + "version": 1, + "role": p["role"], + "network_id": p["network_id"], + "installation_epoch": p["installation_epoch"], + "generation": p["generation"], + "policy_digest": p["policy_digest"], + "enabled": admitted(p, fence), + "fence_digest": hashlib.sha256(json.dumps(fence, sort_keys=True).encode()).hexdigest(), + "observed_monotonic": time.monotonic(), + "capabilities": capabilities(p["role"]), + "streams": streams or {}, + } + if wg: + actual = set(run((WG, "show", p["interface"], "peers")).split()) + expected = {peer["public_key"] for peer in p["peers"] if admitted(p, fence, peer["id"], peer["generation"])} + require(actual == expected) + actual_allowed: dict[str, set[str]] = {} + for line in run((WG, "show", p["interface"], "allowed-ips")).splitlines(): + parts = line.split() + require(bool(parts) and bool(parts[1:])) + actual_allowed[parts[0]] = set(parts[1:]) + expected_allowed = { + peer["public_key"]: set(peer["allowed_ips"]) for peer in p["peers"] if peer["public_key"] in expected + } + require(actual_allowed == expected_allowed) + own = key(run((WG, "show", p["interface"], "public-key")).strip()) + policy = run((NFT, "--stateless", "list", "table", "inet", "lychd_tether")) + require(policy_tokens(policy) == policy_tokens(nft_policy(p, fence))) + result.update( + public_key=own, peer_public_keys=sorted(actual), nft_digest=hashlib.sha256(policy.encode()).hexdigest() + ) + STATUS.parent.mkdir(mode=0o700, exist_ok=True) + directory = STATUS.parent.lstat() + require( + stat.S_ISDIR(directory.st_mode) + and stat.S_IMODE(directory.st_mode) == PRIVATE_DIRECTORY_MODE + and directory.st_uid == os.geteuid() + ) + temporary = STATUS.with_suffix(".tmp") + write_private(temporary, json.dumps(result, sort_keys=True) + "\n") + temporary.replace(STATUS) + + +def observe() -> None: + """Read an independently refreshed bounded observation, refusing stale output.""" + p = load_config() + raw = read_file(STATUS) + value = decode(raw) + require(type(value) is dict and value.get("policy_digest") == p["policy_digest"]) + value = cast("dict[str, Any]", value) + stamp = value.get("observed_monotonic") + require(type(stamp) in {int, float}) + stamp = cast("int | float", stamp) + require(0 <= time.monotonic() - stamp < STATUS_MAX_AGE) + require( + value.get("fence_digest") == hashlib.sha256(json.dumps(load_fences(p), sort_keys=True).encode()).hexdigest() + ) + print(raw.decode().strip()) # noqa: T201 - public bounded observation protocol + + +async def main_async() -> None: + require(sys.argv[1:] == [str(CONFIG)]) + task = asyncio.current_task() + require(task is not None) + task = cast("asyncio.Task[Any]", task) + loop = asyncio.get_running_loop() + for signum in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(signum, task.cancel) + p = load_config() + capabilities(p["role"]) + require(admitted(p, load_fences(p))) + if p["role"] == "wireguard": + await wireguard(p) + elif p["role"] == "owner-ssh": + await owner_ssh(p) + else: + await Relay(p).serve() + + +def main() -> None: + os.umask(0o077) + try: + if sys.argv[1:] == ["--observe", str(CONFIG)]: + observe() + else: + asyncio.run(main_async()) + except (Exception, KeyboardInterrupt, asyncio.CancelledError): # noqa: BLE001 - no foreign or secret-bearing diagnostics + print("Tether runtime refused or lost its admitted profile", file=sys.stderr) # noqa: T201 + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/src/lychd/system/services/tether/service.py b/src/lychd/system/services/tether/service.py new file mode 100644 index 00000000..b6292fa2 --- /dev/null +++ b/src/lychd/system/services/tether/service.py @@ -0,0 +1,1341 @@ +"""Native Tether lifecycle: durable admission, exact custody, isolated activation. + +Ansible and Click call this owner; neither is an alternative VPN orchestrator. +The complete Scribe generation supplies fresh CAS, while admission and retained +runtime closure remain per network and per peer. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from contextlib import nullcontext, suppress +from dataclasses import replace +from pathlib import Path +from typing import Any, Literal, cast +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field + +from lychd.config.runes.registry import RuneRegistry +from lychd.domain.tether.compiler import TetherDeployment, compile_tether +from lychd.domain.tether.models import AttachmentAdmission, ImportedAttachment, OfferedPeer, TetherRune, TetherSnapshot +from lychd.system import constants +from lychd.system.schemas import QuadletBase, QuadletContainer, QuadletPod +from lychd.system.services.lifecycle.lock import LifecycleLock +from lychd.system.services.tether.custody import TetherCustody +from lychd.system.services.tether.host import TetherHost +from lychd.system.services.tether.keys import TetherKeys +from lychd.system.services.tether.models import TetherError, TetherIntent, canonical_bytes, load_intent + +_RUNTIME_UNIT = "lychd-tether-runtime.service" +_RESUME_UNIT = "lychd-tether-resume.service" +_PRIVATE_FILE_MODE = 0o600 +_MAX_KEY_OWNERS = 512 + + +class NativeState(BaseModel): + """Public durable authority; no key bytes, passwords, tokens, or tmpfs identity.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + version: Literal[1] = 1 + intent: TetherIntent + topology: TetherRune | None = None + key_owners: dict[str, str] = Field(default_factory=dict, max_length=_MAX_KEY_OWNERS) + snapshot: TetherSnapshot + image: dict[str, str] + active: tuple[str, ...] = () + incarnations: dict[str, dict[str, Any]] = Field(default_factory=dict) + pods: dict[str, dict[str, Any]] = Field(default_factory=dict) + launches: dict[str, dict[str, Any]] = Field(default_factory=dict) + revocations: dict[str, dict[str, Any]] = Field(default_factory=dict) + + +class NativeTetherService: + """Run rootless under the same lifecycle lock as init, bind and the Reactor.""" + + def __init__( + self, + *, + custody: TetherCustody | None = None, + host: TetherHost | None = None, + runtime_root: Path | None = None, + ) -> None: + """Bind fixed production paths or explicit isolated test ports.""" + if os.geteuid() == 0: + msg = "Tether requires the ordinary LychD runtime user" + raise TetherError(msg) + self.custody = custody or TetherCustody(constants.PATH_TETHER_STATE_DIR) + self.host = host or TetherHost() + self.runtime_root = runtime_root or constants.PATH_TETHER_RUNTIME_DIR + + def _declared(self) -> tuple[Any, Any, Any, TetherRune]: + from lychd.config.runes.registry import load_rune_registry + from lychd.config.settings.root import get_settings + from lychd.extensions.host import get_extensions + from lychd.system.services.lifecycle.receipt import LifecycleReceiptStore + + receipt = LifecycleReceiptStore() + receipt.require_dedicated_root_identities( + (constants.PATH_CODEX_ROOT, constants.PATH_CRYPT_ROOT, constants.PATH_CACHE_ROOT) + ) + settings = get_settings() + extensions = get_extensions() + if "tether" not in settings.extensions.builtins: + msg = "Select Tether during original initialization before preparing it" + raise TetherError(msg) + runes = load_rune_registry(extensions) + rune = runes.one(TetherRune) + # Original configuration remains the immutable installation admission. + # A changed deployment is a new explicit campaign, never silent adoption. + receipt.read_original_file(constants.PATH_LYCHD_TOML, expected_mode=0o600, max_bytes=1048576) + return settings, extensions, runes, rune + + def _load_retained(self) -> NativeState: + """Retain denial authority when current images or credentials are unavailable.""" + from lychd.system.services.lifecycle.receipt import LifecycleReceiptStore + + self.custody.attest() + raw = self.custody.read("state.json") + if raw is None: + msg = "Tether has not been prepared" + raise FileNotFoundError(msg) + LifecycleReceiptStore().require_dedicated_root_identities( + (constants.PATH_CODEX_ROOT, constants.PATH_CRYPT_ROOT, constants.PATH_CACHE_ROOT) + ) + return NativeState.model_validate_json(canonical_bytes(raw)) + + def _load(self) -> NativeState: + state = self._load_retained() + settings, _extensions, _runes, rune = self._declared() + # Loader source provenance is not serialized into the public preparation intent. + if canonical_bytes(rune) != canonical_bytes(state.intent.rune) or settings.server.port != state.intent.web_port: + msg = "Tether topology differs from its admitted preparation" + raise TetherError(msg) + if self.host.image(rune.quadlet.image) != state.image: + msg = "The prepared Tether image was replaced" + raise TetherError(msg) + self.effective_rune(state) + return state + + @staticmethod + def _key_assignments(rune: TetherRune, peers: tuple[OfferedPeer, ...]) -> tuple[tuple[str, str], ...]: + assignments: list[tuple[str, str]] = [] + if rune.offered: + assignments.append((rune.offered.private_key_ref, "offered:wireguard")) + for attachment in rune.attachments: + assignments.append((attachment.private_key_ref, f"attachment:{attachment.id}:wireguard")) + if attachment.preshared_key_ref: + assignments.append((attachment.preshared_key_ref, f"attachment:{attachment.id}:preshared")) + for peer in peers: + if peer.preshared_key_ref: + assignments.append((peer.preshared_key_ref, f"peer:{peer.id}:preshared")) + if peer.owner_access: + assignments.append((peer.owner_access.ssh_host_key_ref, f"peer:{peer.id}:ssh-host")) + return tuple(assignments) + + @staticmethod + def _key_ownership(state: NativeState, rune: TetherRune, peers: tuple[OfferedPeer, ...]) -> dict[str, str]: + """Retain original and retired reference ownership across managed updates.""" + owners = dict(state.key_owners) + configurations = ( + (state.intent.rune, state.intent.offered_peers), + (state.topology or state.intent.rune, state.snapshot.offered_peers), + (rune, peers), + ) + for definition, admitted_peers in configurations: + for reference, owner in NativeTetherService._key_assignments(definition, admitted_peers): + if reference in owners and owners[reference] != owner: + message = "Tether key reference belongs to another road or credential purpose" + raise TetherError(message) + owners[reference] = owner + if len(owners) > _MAX_KEY_OWNERS: + message = "Tether historical key reference capacity is exhausted" + raise TetherError(message) + return owners + + @staticmethod + def effective_rune(state: NativeState) -> TetherRune: + """Resolve managed roads without changing the immutable installation envelope.""" + rune = state.topology or state.intent.rune + if rune.model_dump(exclude={"attachments"}) != state.intent.rune.model_dump(exclude={"attachments"}): + message = "Managed Tether topology may change only imported attachments" + raise TetherError(message) + NativeTetherService._key_ownership(state, rune, state.snapshot.offered_peers) + return rune + + @staticmethod + def _effective_registry(state: NativeState, runes: RuneRegistry) -> RuneRegistry: + """Overlay exactly one Tether definition while preserving every other Rune.""" + runes.one(TetherRune) + effective = NativeTetherService.effective_rune(state) + return RuneRegistry(tuple(effective if isinstance(row, TetherRune) else row for row in runes.all())) + + def _save(self, state: NativeState) -> bool: + return self.custody.write("state.json", state.model_dump(mode="json", by_alias=True)) + + def _empty_preparation(self) -> bool: + if not os.path.lexists(self.custody.root): + return True + self.custody.attest() + if self.custody.read("state.json") is not None: + return False + if set(self.custody.known_paths()) != {self.custody.root, self.custody.root / ".custody.json"}: + message = "Tether state is absent but retained material remains; preserve uncertain custody" + raise TetherError(message) + return True + + def preparation_status(self) -> dict[str, Any]: + """Distinguish missing admission from explicit imports and replaced credentials.""" + if self._empty_preparation(): + paths = self.custody.known_paths() if os.path.lexists(self.custody.root) else () + return { + "state": "absent", + "missing_secrets": [], + "required_secrets": [], + "owned_secrets": [], + "known_paths": [str(path) for path in paths], + } + state = self._load() + deployment = self._projection(state) + keys = self._keys(state) + observation = keys.observe() + owned = set(observation.identities) + local = self._local_references(state) + missing: list[str] = [] + retry_prepare = False + for reference in deployment.required_secrets: + phase = observation.preparation_state(reference) + if phase == "ready": + if reference not in owned: + message = "Ready Tether credential lacks current owned backend evidence" + raise TetherError(message) + elif reference in local: + retry_prepare = True + elif phase == "pending": + message = "Retry native import-key to reconcile the pending client-key operation" + raise TetherError(message) + else: + missing.append(reference) + return { + "state": "absent" if retry_prepare else "waiting_keys" if missing else "prepared", + "missing_secrets": sorted(missing), + "required_secrets": list(deployment.required_secrets), + "owned_secrets": sorted(owned), + "known_paths": [str(path) for path in self.custody.known_paths()], + } + + def _keys(self, state: NativeState) -> TetherKeys: + return TetherKeys(self.custody, state.snapshot.installation_epoch, runner=self.host.runner) + + @staticmethod + def _local_references(state: NativeState) -> dict[str, Literal["wireguard", "ssh-host"]]: + result: dict[str, Literal["wireguard", "ssh-host"]] = {} + if state.intent.rune.offered: + result[state.intent.rune.offered.private_key_ref] = "wireguard" + for peer in state.snapshot.offered_peers: + if peer.owner_access: + result[peer.owner_access.ssh_host_key_ref] = "ssh-host" + return result + + @staticmethod + def _references(state: NativeState) -> dict[str, str]: + rune = NativeTetherService.effective_rune(state) + return { + reference: owner.rsplit(":", 1)[1] + for reference, owner in NativeTetherService._key_assignments(rune, state.snapshot.offered_peers) + } + + def _attest_keys(self, state: NativeState, deployment: TetherDeployment) -> None: + self._keys(state).attest_many(deployment.required_secrets) + + def _projection(self, state: NativeState) -> TetherDeployment: + return compile_tether( + rune=self.effective_rune(state), + snapshot=state.snapshot, + runtime_root=self.runtime_root, + config_root=self.custody.root / "runtime", + core_web_port=state.intent.web_port, + consumer_uid=os.getuid(), + forbidden_backend_addresses=self.host.host_addresses(), + ) + + def _retained_projection(self, state: NativeState) -> TetherDeployment: + """Reconstruct withdrawal targets only; this grants no startup authority.""" + return compile_tether( + rune=self.effective_rune(state), + snapshot=state.snapshot, + runtime_root=self.runtime_root, + config_root=self.custody.root / "runtime", + core_web_port=state.intent.web_port, + consumer_uid=os.getuid(), + forbidden_backend_addresses=(), + ) + + def projection(self) -> TetherDeployment: + """Return current pure projection after persistent image and key attestation.""" + state = self._load() + deployment = self._projection(state) + self._attest_keys(state, deployment) + return deployment + + def binding_context(self, state_override: NativeState | None = None) -> dict[str, Any]: + """Supply explicit current authority to the pure complete bind compiler.""" + state = state_override or self._load() + self._attest_keys(state, self._projection(state)) + return { + "tether_snapshot": state.snapshot, + "tether_runtime_root": self.runtime_root, + "tether_config_root": self.custody.root / "runtime", + "tether_consumer_uid": os.getuid(), + "tether_forbidden_backend_addresses": self.host.host_addresses(), + } + + def bind_request(self, state_override: NativeState | None = None) -> Any: + """Compile the same complete body as ordinary bind, including boot helpers.""" + from lychd.domain.animation.services.declarations import compile_animator_declarations + from lychd.system.services.bind_compilation import compile_bind_request + + settings, extensions, runes, _rune = self._declared() + state = state_override or self._load() + runes = self._effective_registry(state, runes) + declarations = compile_animator_declarations(settings=settings, runes=runes) + request = compile_bind_request( + settings=settings, + extensions=extensions, + runes=runes, + soulstones=declarations.soulstones, + portals=declarations.portals, + **self.binding_context(state), + ) + executable = Path(sys.prefix) / "bin/lychd" + from lychd.system.schemas import systemd_environment_assignment, systemd_executable_token + + command = systemd_executable_token(executable) + environment = "\n".join( + "Environment=" + systemd_environment_assignment(key, str(value)) + for key, value in { + "HOME": Path.home(), + "XDG_CONFIG_HOME": constants.PATH_XDG_CONFIG_HOME, + "XDG_DATA_HOME": constants.PATH_XDG_DATA_HOME, + "XDG_CACHE_HOME": constants.PATH_XDG_CACHE_HOME, + }.items() + ) + plain = request.plain_unit_mapping() + plain[_RUNTIME_UNIT] = ( + "[Unit]\nDescription=Prepare private boot-local Tether paths\n" + "[Service]\nType=oneshot\nRemainAfterExit=no\n" + environment + "\n" + f"ExecStart={command} tether runtime-dir\nTimeoutStartSec=45\n" + ) + plain[_RESUME_UNIT] = ( + "[Unit]\nDescription=Resume explicitly activated Tether networks\n" + "After=lychd-vessel.service\nRequires=lychd-vessel.service\n" + "[Service]\nType=oneshot\nRemainAfterExit=no\n" + environment + "\n" + f"ExecStart={command} tether resume\nTimeoutStartSec=120\n" + ) + manifests: list[QuadletBase] = [] + for manifest in request.manifests: + prepared_manifest = manifest + if isinstance(manifest, QuadletContainer) and ( + manifest.tether_role is not None or manifest.container_name == "lychd-vessel" + ): + updates: dict[str, Any] = { + "requires": [*manifest.requires, _RUNTIME_UNIT], + "after": [*manifest.after, _RUNTIME_UNIT], + } + if manifest.container_name == "lychd-vessel": + updates["wants"] = [*manifest.wants, _RESUME_UNIT] + prepared_manifest = manifest.model_copy(update=updates) + manifests.append(prepared_manifest) + return replace(request, manifests=tuple(manifests), plain_units=tuple(sorted(plain.items()))) + + def attest_bind_request(self, request: Any) -> None: + """Recheck full public intent and exact private-key identities under bind's lock.""" + fresh = self.bind_request() + if ( + tuple(row.model_dump(mode="json") for row in request.manifests) + != tuple(row.model_dump(mode="json") for row in fresh.manifests) + or request.plain_units != fresh.plain_units + or request.required_secret_names != fresh.required_secret_names + or request.secret_names != fresh.secret_names + ): + msg = "Tether binding admission changed before commit" + raise TetherError(msg) + + def prepare(self, intent_path: Path, *, dry_run: bool = False) -> dict[str, Any]: + """Create absent-only custody and local server keys; import client keys separately.""" + intent = load_intent(intent_path) + with nullcontext() if dry_run else LifecycleLock(): + settings, _extensions, _runes, rune = self._declared() + if canonical_bytes(rune) != canonical_bytes(intent.rune) or settings.server.port != intent.web_port: + msg = "Preparation intent differs from original initialization" + raise TetherError(msg) + if not self._empty_preparation(): + state = self._load() + if state.intent != intent: + msg = "Existing Tether preparation has different intent" + raise TetherError(msg) + changed = False + else: + state = NativeState( + intent=intent, + snapshot=TetherSnapshot( + installation_epoch=uuid4().hex, + offered_peers=intent.offered_peers, + attachments=intent.attachments, + ), + image=self.host.image(intent.rune.quadlet.image), + ) + self._projection(state) + if dry_run: + return {"state": "absent", "changed": True, "dry_run": True} + changed = self.custody.initialize() + changed = self._save(state) or changed + if dry_run: + return {"state": "prepared", "changed": False, "dry_run": True} + keys = self._keys(state) + local_references = self._local_references(state) + for reference, purpose in local_references.items(): + keys.ensure(reference, purpose) + self._materialize(state) + public_keys = [ + {key: value[key] for key in ("reference", "purpose", "public_key", "fingerprint")} + for value in keys.attest_many(sorted(local_references)).values() + ] + return { + "state": "prepared", + "changed": changed, + "installation_epoch": state.snapshot.installation_epoch, + "public_keys": public_keys, + } + + def _materialize(self, state: NativeState) -> None: + deployment = self._projection(state) + self.custody.directory("runtime", public=True) + for config in deployment.runtime_configs: + self.custody.directory("runtime/" + config.name, public=True) + self.custody.publish(Path("runtime") / config.name / "config.json", config.payload, public=True) + fence = {**config.fences, "enabled": config.network_id in state.active} + self.custody.publish(Path("runtime") / config.name / "fences.json", fence, public=True) + + def runtime_dir(self) -> dict[str, Any]: + """Recreate only boot-local, private paths before a bind-mounted consumer starts.""" + state = self._load() + deployment = self._projection(state) + self.host.runtime_directories( + self.runtime_root, tuple(network.runtime_directory for network in deployment.networks) + ) + # Runtime catalog is rebuilt from fresh observation, never restored from disk. + if not os.path.lexists(self.runtime_root / "catalog.json"): + self._catalog(state, ()) + return {"state": "prepared", "changed": False} + + def _bound(self, request: Any) -> dict[str, dict[str, str]]: + from lychd.system.services.deployment import DeploymentInspectionService + from lychd.system.services.scribe.facade import ScribeService + + scribe = ScribeService() + owned = scribe.inspect_owned_bindings() + if ( + not owned.receipt_present + or scribe.plan_reconcile_all(request.manifests, plain_units=request.plain_unit_mapping()).mutates + ): + msg = "Current complete bindings differ; run native Tether bind" + raise TetherError(msg) + names = frozenset( + unit for network in request.tether.networks for unit in (*network.unit_names, network.pod_unit) + ) + return DeploymentInspectionService().observe_owned_units( + owned, unit_names=names | frozenset({_RUNTIME_UNIT, _RESUME_UNIT, "lychd-vessel.service"}) + ) + + def _bind(self, state: NativeState) -> dict[str, Any]: + from lychd.config.settings import SettingsSnapshot + from lychd.system.services.bind import BindUseCase + from lychd.system.services.binding_preflight import BindingPreflightService + from lychd.system.services.scribe.facade import ScribeService + from lychd.system.services.secrets import PodmanSecretStore + from lychd.system.services.systemd import SystemdUserManager + + settings, _extensions, _runes, _rune = self._declared() + snapshot = SettingsSnapshot.capture(settings) + preflight = BindingPreflightService() + foundation = preflight.inspect(settings).require_ready() + request = self.bind_request() + self._materialize(state) + self.host.runtime_directories( + self.runtime_root, tuple(network.runtime_directory for network in request.tether.networks) + ) + use_case = BindUseCase( + scribe=ScribeService(expected_sites=foundation.sites), + secrets=PodmanSecretStore(foundation.podman_bin), + systemd_factory=lambda: SystemdUserManager(systemctl_bin=foundation.systemctl_bin), + foundation=foundation, + foundation_probe=lambda: preflight.inspect(snapshot.materialize()).require_ready(), + lock_factory=nullcontext, + request_attestor=self.attest_bind_request, + ) + plan = use_case.plan(request) + self._attest_keys(state, request.tether) + if not plan.bindings.mutates and not plan.missing_core_secrets: + return {"state": "bound", "changed": False} + use_case.apply(request, plan) + return {"state": "bound", "changed": True} + + def bind(self, *, dry_run: bool = False) -> dict[str, Any]: + """Materialize public projections and one complete, CAS-checked Scribe generation.""" + with nullcontext() if dry_run else LifecycleLock(): + state = self._load() + if dry_run: + from lychd.system.services.scribe.facade import ScribeService + + request = self.bind_request() + plan = ScribeService().plan_reconcile_all(request.manifests, plain_units=request.plain_unit_mapping()) + return {"state": "prepared", "changed": plan.mutates, "dry_run": True} + return self._bind(state) + + def _catalog(self, state: NativeState, routes: tuple[Any, ...]) -> None: + import stat + + path = self.runtime_root / "catalog.json" + payload = ( + json.dumps( + { + "schema": "lychd.tether.catalog", + "version": 1, + "installation_epoch": state.snapshot.installation_epoch, + "routes": [route.model_dump(mode="json") for route in routes], + }, + sort_keys=True, + ) + + "\n" + ).encode() + if not self.runtime_root.exists(): + return + descriptor = os.open(self.runtime_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + temporary = ".catalog-" + uuid4().hex + try: + if os.path.lexists(path): + current = os.stat(path.name, dir_fd=descriptor, follow_symlinks=False) + if ( + not stat.S_ISREG(current.st_mode) + or current.st_uid != os.getuid() + or current.st_nlink != 1 + or stat.S_IMODE(current.st_mode) != _PRIVATE_FILE_MODE + ): + msg = "Unsafe boot-local route catalog" + raise TetherError(msg) + source = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor) + try: + pinned = os.fstat(source) + if (pinned.st_dev, pinned.st_ino) != (current.st_dev, current.st_ino): + msg = "Boot-local route catalog changed during observation" + raise TetherError(msg) + if os.read(source, len(payload) + 1) == payload: + return + finally: + os.close(source) + target = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=descriptor) + try: + with os.fdopen(target, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(target) + finally: + os.close(target) + os.replace(temporary, path.name, src_dir_fd=descriptor, dst_dir_fd=descriptor) + os.fsync(descriptor) + finally: + with suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=descriptor) + os.close(descriptor) + + def _require_core(self) -> None: + """Bind the ordinary Core's actual image and process to its owned invocation.""" + settings, _extensions, _runes, _rune = self._declared() + self.host.require_core(settings.server.web.image) + + @staticmethod + def _pod_name(config: Any) -> str: + return "-".join(config.name.split("-")[:3]) + + def _require_pending(self, pending: dict[str, Any] | None, config: Any) -> dict[str, Any]: + if ( + pending is None + or pending.get("boot_id") != Path("/proc/sys/kernel/random/boot_id").read_text().strip() + or pending.get("policy_digest") != config.payload["policy_digest"] + or pending.get("network_id") != config.network_id + or self.host.unit_incarnation(config.name + ".service")["InvocationID"] + == pending.get("previous_invocation_id") + ): + message = "The pending Tether launch lacks exact current invocation authority" + raise TetherError(message) + return pending + + def _retain_role(self, state: NativeState, config: Any) -> NativeState: + """Seal role and Pod together before clearing recoverable launch authority.""" + identity = self.host.capture(config, state.image) + pod = self.host.capture_pod(self._pod_name(config)) + old = state.incarnations.get(config.name) + previous_pod = state.pods.get(config.network_id) + pending = state.launches.get(config.name) + if identity["pod_id"] != pod["pod_id"] or identity["netns"] != pod["netns"]: + message = "Tether role does not inhabit its exact owned Pod namespace" + raise TetherError(message) + if pending is not None or old is None: + pending = self._require_pending(pending, config) + if ( + old is not None + and identity["container_id"] != old["container_id"] + and (not self.host.cgroup_empty(old["cgroup"]) or identity["invocation_id"] == old["invocation_id"]) + ): + message = "A previous Tether incarnation is still live or replacement lacks a new owned invocation" + raise TetherError(message) + if previous_pod is None: + pending = self._require_pending(pending, config) + if pod["invocation_id"] == pending.get("previous_pod_invocation_id"): + message = "The pending launch did not create a new owned Pod invocation" + raise TetherError(message) + elif pod["pod_id"] != previous_pod["pod_id"]: + self._require_pending(pending, config) + if ( + not self.host.cgroup_empty(previous_pod["infra_cgroup"]) + or pod["invocation_id"] == previous_pod["invocation_id"] + ): + message = "A replaced Tether Pod still has a live previous incarnation" + raise TetherError(message) + return state.model_copy( + update={ + "incarnations": {**state.incarnations, config.name: identity}, + "pods": {**state.pods, config.network_id: pod}, + "launches": {name: value for name, value in state.launches.items() if name != config.name}, + } + ) + + def _preflight_role(self, state: NativeState, config: Any) -> None: + retained = state.incarnations.get(config.name) + if self.host.exists("container", config.name): + row = self.host.row("container", config.name) + if row.get("State", {}).get("Running") is True: + self._retain_role(state, config) + elif retained is None or row.get("Id") != retained["container_id"]: + message = "An existing stopped Tether container has no retained authority" + raise TetherError(message) + elif retained is not None and not self.host.cgroup_empty(retained["cgroup"]): + message = "Previous Tether role closure is uncertain" + raise TetherError(message) + + def _preflight_activation( + self, state: NativeState, deployment: TetherDeployment, selected: tuple[str, ...] + ) -> None: + """Prove existing names before enabling any role's durable fence.""" + for network in deployment.networks: + if network.id not in selected: + continue + pod_name = network.pod_unit.removesuffix("-pod.service") + previous = state.pods.get(network.id) + if self.host.exists("pod", pod_name): + row = self.host.row("pod", pod_name) + if previous is None or row.get("Id") != previous["pod_id"]: + wg = next( + item + for item in deployment.runtime_configs + if item.network_id == network.id and item.role == "wireguard" + ) + self._require_pending(state.launches.get(wg.name), wg) + self._retain_role(state, wg) + elif previous is not None and not self.host.cgroup_empty(previous["infra_cgroup"]): + message = "Previous Tether Pod closure is uncertain" + raise TetherError(message) + for config in (item for item in deployment.runtime_configs if item.network_id == network.id): + self._preflight_role(state, config) + + def _start_role(self, state: NativeState, config: Any) -> tuple[NativeState, bool]: + """Recover only a manager-proven launch; start stopped owned containers too.""" + changed = False + exists = self.host.exists("container", config.name) + retained = state.incarnations.get(config.name) + if exists: + row = self.host.row("container", config.name) + if row.get("State", {}).get("Running") is True: + state = self._retain_role(state, config) + self._save(state) + return state, False + if retained is None or row.get("Id") != retained["container_id"]: + message = "A stopped Tether container has no exact retained authority" + raise TetherError(message) + if retained is not None and not self.host.cgroup_empty(retained["cgroup"]): + message = "The previous Tether incarnation still has descendants" + raise TetherError(message) + unit = self.host.unit_incarnation(config.name + ".service") + launch = { + "previous_invocation_id": unit["InvocationID"], + "boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip(), + "policy_digest": config.payload["policy_digest"], + "network_id": config.network_id, + "previous_pod_invocation_id": self.host.unit_incarnation(self._pod_name(config) + "-pod.service")[ + "InvocationID" + ], + } + state = state.model_copy(update={"launches": {**state.launches, config.name: launch}}) + self._save(state) + try: + self.host.command("systemctl", "--user", "start", config.name + ".service") + changed = True + finally: + if self.host.exists("container", config.name): + state = self._retain_role(state, config) + self._save(state) + return state, changed + + def _await_policy(self, config: Any, seconds: float = 15.0) -> None: + deadline = time.monotonic() + seconds + while True: + try: + self.host.observe(config) + except TetherError: + if time.monotonic() >= deadline: + raise + time.sleep(0.1) + else: + return + + def _activate_network( + self, state: NativeState, deployment: TetherDeployment, network: Any + ) -> tuple[NativeState, bool]: + pod_name = network.pod_unit.removesuffix("-pod.service") + previous = state.pods.get(network.id) + if self.host.exists("pod", pod_name): + pod = self.host.row("pod", pod_name) + if previous is None or pod.get("Id") != previous["pod_id"]: + wg = next( + item + for item in deployment.runtime_configs + if item.network_id == network.id and item.role == "wireguard" + ) + # This proves pending boot/policy and old infra closure before adoption. + self._require_pending(state.launches.get(wg.name), wg) + state = self._retain_role(state, wg) + self._save(state) + elif previous is not None and not self.host.cgroup_empty(previous["infra_cgroup"]): + message = "The previous Tether Pod still has live descendants" + raise TetherError(message) + changed = False + for config in (item for item in deployment.runtime_configs if item.network_id == network.id): + state, role_changed = self._start_role(state, config) + changed = role_changed or changed + self._await_policy(config) + return state, changed + + def _activate(self, state: NativeState, selected: tuple[str, ...]) -> dict[str, Any]: + """Reconcile selected incarnations without restarting unrelated networks.""" + self._require_core() + if any(row.get("status") != "complete" for row in state.revocations.values()): + message = "Finish pending revocation before activating networks" + raise TetherError(message) + deployment = self._projection(state) + self._attest_keys(state, deployment) + self._bound(self.bind_request()) + self.host.runtime_directories( + self.runtime_root, tuple(network.runtime_directory for network in deployment.networks) + ) + if not set(selected) <= {network.id for network in deployment.networks}: + message = "Activation refers to a disabled or undeclared network" + raise TetherError(message) + self._preflight_activation(state, deployment, selected) + updated = state.model_copy(update={"active": tuple(sorted(set(state.active) | set(selected)))}) + self._save(updated) + self._materialize(updated) + changed = state.active != updated.active + for network in deployment.networks: + if network.id in selected: + updated, network_changed = self._activate_network(updated, deployment, network) + changed = network_changed or changed + for config in deployment.runtime_configs: + if config.network_id in updated.active: + self.host.observe(config) + routes = tuple(route for route in deployment.routes if route.selection.attachment_id in updated.active) + self._catalog(updated, routes) + return {"state": "active", "changed": changed} + + def activate(self, *, dry_run: bool = False) -> dict[str, Any]: + """Activate only admitted networks after the ordinary Core bootstrap.""" + with nullcontext() if dry_run else LifecycleLock(): + state = self._load() + deployment = self._projection(state) + selected = tuple(network.id for network in deployment.networks) + if dry_run: + self._attest_keys(state, deployment) + self._bound(self.bind_request()) + return {"state": "bound", "changed": state.active != tuple(sorted(selected)), "dry_run": True} + return self._activate(state, selected) + + def resume(self) -> dict[str, Any]: + """Boot helper: revive previously granted networks; initial Core start grants none.""" + state = self._load() + if not state.active: + return {"state": "bound", "changed": False} + with LifecycleLock(): + state = self._load() + return self._activate(state, state.active) + + def inspect(self) -> dict[str, Any]: + """Report effective ownership and live acknowledgements without secret values.""" + state = self._load() + deployment = self._projection(state) + self._attest_keys(state, deployment) + bound = False + try: + self._bound(self.bind_request()) + bound = True + except TetherError: + pass + networks: list[dict[str, Any]] = [] + active: set[str] = set() + for network in deployment.networks: + observed = bound and network.id in state.active + if observed: + for config in (item for item in deployment.runtime_configs if item.network_id == network.id): + retained = state.incarnations.get(config.name) + if retained is None or self.host.capture(config, state.image) != retained: + observed = False + break + self.host.observe(config) + if observed: + active.add(network.id) + networks.append( + { + "id": network.id, + "generation": network.generation, + "policy_digest": network.policy_digest, + "unit_names": [network.pod_unit, *network.unit_names], + "active": observed, + } + ) + return { + "schema": "lychd.tether.inspection", + "version": 1, + "state": "active" + if bound and len(active) == len(deployment.networks) + else "bound" + if bound + else "prepared", + "installation_epoch": state.snapshot.installation_epoch, + "image": state.intent.rune.quadlet.image, + "required_secrets": list(deployment.required_secrets), + "owned_secrets": list(self._keys(state).owned_references()), + "unit_names": [unit for network in deployment.networks for unit in (network.pod_unit, *network.unit_names)], + "known_paths": [str(path) for path in self.custody.known_paths()], + "networks": networks, + "routes": [ + route.model_dump(mode="json") for route in deployment.routes if route.selection.attachment_id in active + ], + } + + def import_key(self, secret_ref: str, path: Path, *, dry_run: bool = False) -> dict[str, Any]: + """Import one declared client key from a private file; never replace a key.""" + with nullcontext() if dry_run else LifecycleLock(): + state = self._load() + purpose = self._references(state).get(secret_ref) + if purpose not in {"wireguard", "preshared"}: + msg = "Only a declared WireGuard client or preshared key can be imported" + raise TetherError(msg) + if dry_run: + return {"state": "prepared", "changed": False, "dry_run": True} + admitted_purpose = cast("Literal['wireguard', 'preshared']", purpose) + identity = self._keys(state).import_key(secret_ref, path, admitted_purpose) + return {"state": "prepared", "reference": identity["reference"], "public_key": identity["public_key"]} + + def _preflight_hot_peer( + self, before: NativeState, after: NativeState, peer: OfferedPeer, previous: OfferedPeer | None + ) -> None: + """Allow only additive peers under an unchanged live offered namespace policy.""" + if previous is not None: + message = "Stop the offered network before rotating an existing peer" + raise TetherError(message) + old, new = self._projection(before), self._projection(after) + old_pods = tuple(row.model_dump(mode="json") for row in old.manifests if isinstance(row, QuadletPod)) + new_pods = tuple(row.model_dump(mode="json") for row in new.manifests if isinstance(row, QuadletPod)) + if old_pods != new_pods: + message = "Stop the offered network before changing its Pod policy" + raise TetherError(message) + self._require_core() + self._bound(self.bind_request(state_override=before)) + generated = peer.owner_access.ssh_host_key_ref if peer.owner_access else None + keys = self._keys(after) + keys.attest_many(reference for reference in new.required_secrets if reference != generated) + + def enroll(self, peer: OfferedPeer, *, dry_run: bool = False) -> dict[str, Any]: + """Admit the next peer generation without disturbing unrelated attachments.""" + with nullcontext() if dry_run else LifecycleLock(): + state = self._load() + offered = state.intent.rune.offered + if offered is None: + msg = "Peer admission requires an offered network" + raise TetherError(msg) + if any(row.get("status") != "complete" for row in state.revocations.values()): + msg = "Finish pending revocation before changing admission" + raise TetherError(msg) + previous = next((row for row in state.snapshot.offered_peers if row.id == peer.id), None) + if previous == peer: + if offered.id in state.active and not dry_run: + rebound = self._bind(state) + resumed = self._activate(state, (offered.id,)) + return {"state": "enrolled", "changed": rebound["changed"] or resumed["changed"]} + return {"state": "enrolled", "changed": False} + if not peer.enabled or peer.generation != (previous.generation + 1 if previous else 1): + msg = "Peer admission requires the next exact enabled generation" + raise TetherError(msg) + peers = (*tuple(row for row in state.snapshot.offered_peers if row.id != peer.id), peer) + snapshot = TetherSnapshot.model_validate({**state.snapshot.model_dump(), "offered_peers": peers}) + owners = self._key_ownership(state, self.effective_rune(state), peers) + updated = state.model_copy(update={"snapshot": snapshot, "key_owners": owners}) + self._projection(updated) + hot = offered.id in state.active + if hot: + self._preflight_hot_peer(state, updated, peer, previous) + if dry_run: + return {"state": "enrolled", "changed": True, "dry_run": True} + # Reserve reference ownership without granting the peer before key creation. + # An interrupted ensure must not free its new reference for another peer. + self._save(state.model_copy(update={"key_owners": owners})) + if peer.owner_access: + self._keys(updated).ensure(peer.owner_access.ssh_host_key_ref, "ssh-host") + self._save(updated) + if hot: + self._bind(updated) + self._activate(updated, (offered.id,)) + else: + self._materialize(updated) + return {"state": "enrolled", "changed": True} + + def _validate_registered_topology(self, state: NativeState) -> TetherDeployment: + """Compile every staged definition without authorizing any runtime effect.""" + rune = self.effective_rune(state) + generations = {row.attachment_id: row.generation for row in state.snapshot.attachments} + admissions = tuple( + AttachmentAdmission(attachment_id=row.id, generation=generations.get(row.id, 1)) for row in rune.attachments + ) + validation = state.model_copy( + update={"snapshot": state.snapshot.model_copy(update={"attachments": admissions})} + ) + return self._projection(validation) + + def _require_attachment_closed(self, state: NativeState, attachment_id: str, deployment: TetherDeployment) -> None: + """Require a retired admitted generation and no current runtime or pending launch.""" + admission = next((row for row in state.snapshot.attachments if row.attachment_id == attachment_id), None) + if admission is not None: + journal = state.revocations.get(f"{attachment_id}:@:{admission.generation}") + if admission.enabled or journal is None or journal.get("status") != "complete": + message = "Revoke and reconcile the exact attachment generation before changing its definition" + raise TetherError(message) + names = {row.name for row in deployment.runtime_configs if row.network_id == attachment_id} + if ( + attachment_id in state.active + or attachment_id in state.pods + or names.intersection(state.incarnations) + or any(row.get("network_id") == attachment_id for row in state.launches.values()) + ): + message = "Attachment replacement has retained runtime authority" + raise TetherError(message) + network = next(row for row in deployment.networks if row.id == attachment_id) + if self.host.exists("pod", network.pod_unit.removesuffix("-pod.service")) or any( + self.host.exists("container", name) for name in names + ): + message = "Attachment registration refuses an existing runtime incarnation" + raise TetherError(message) + + def register_attachment(self, attachment: ImportedAttachment, *, dry_run: bool = False) -> dict[str, Any]: + """Stage one closed public road definition without admitting or starting it.""" + attachment = ImportedAttachment.model_validate_json(attachment.model_dump_json()) + with nullcontext() if dry_run else LifecycleLock(): + state = self._load() + rune = self.effective_rune(state) + previous = next((row for row in rune.attachments if row.id == attachment.id), None) + if previous == attachment: + return {"state": "registered", "changed": False, "attachment_id": attachment.id} + attachments = (*tuple(row for row in rune.attachments if row.id != attachment.id), attachment) + topology = TetherRune.model_validate({**rune.model_dump(), "attachments": attachments}) + owners = self._key_ownership(state, topology, state.snapshot.offered_peers) + updated = state.model_copy(update={"topology": topology, "key_owners": owners}) + deployment = self._validate_registered_topology(updated) + self._require_attachment_closed(state, attachment.id, deployment) + if dry_run: + return {"state": "registered", "changed": True, "attachment_id": attachment.id, "dry_run": True} + self._save(updated) + return {"state": "registered", "changed": True, "attachment_id": attachment.id} + + def admit(self, attachment_id: str, generation: int, *, dry_run: bool = False) -> dict[str, Any]: + """Admit a declared attachment generation; bind and activation remain explicit.""" + with nullcontext() if dry_run else LifecycleLock(): + state = self._load() + if type(generation) is not int or generation < 1: + msg = "Attachment generation must be a positive integer" + raise TetherError(msg) + if not any(row.id == attachment_id for row in self.effective_rune(state).attachments): + msg = "Attachment is not registered in managed Tether topology" + raise TetherError(msg) + if any(row.get("status") != "complete" for row in state.revocations.values()): + msg = "Finish pending revocation before changing admission" + raise TetherError(msg) + previous = next((row for row in state.snapshot.attachments if row.attachment_id == attachment_id), None) + if previous is not None and previous.enabled and previous.generation == generation: + return {"state": "admitted", "changed": False} + if attachment_id in state.active or (previous is not None and previous.enabled): + msg = "Revoke the current attachment before changing its generation" + raise TetherError(msg) + if generation != (previous.generation + 1 if previous else 1): + msg = "Attachment admission requires the next exact generation" + raise TetherError(msg) + admission = AttachmentAdmission(attachment_id=attachment_id, generation=generation) + attachments = ( + *tuple(row for row in state.snapshot.attachments if row.attachment_id != attachment_id), + admission, + ) + snapshot = TetherSnapshot.model_validate({**state.snapshot.model_dump(), "attachments": attachments}) + updated = state.model_copy(update={"snapshot": snapshot}) + self._projection(updated) + if dry_run: + return {"state": "admitted", "changed": True, "dry_run": True} + self._save(updated) + self._materialize(updated) + return {"state": "admitted", "changed": True} + + @staticmethod + def _revocation_complete(state: NativeState, journal: dict[str, Any]) -> bool: + """Distinguish a completed withdrawal from a later offered-network regrant.""" + if journal.get("status") != "complete": + return False + offered = state.intent.rune.offered + offered_regranted = ( + journal["peer_id"] is None + and offered is not None + and offered.id == journal["network_id"] + and (offered.id in state.active or any(peer.enabled for peer in state.snapshot.offered_peers)) + ) + return not offered_regranted + + @staticmethod + def _withdraw_peer(state: NativeState, network_id: str, peer_id: str, generation: str) -> TetherSnapshot: + """Validate exact peer generation and retain its disabled tombstone.""" + offered = state.intent.rune.offered + if offered is None or offered.id != network_id: + message = "Peer revocation requires the offered network" + raise TetherError(message) + peer = next((row for row in state.snapshot.offered_peers if row.id == peer_id), None) + if peer is None or str(peer.generation) != generation: + message = "Peer generation is stale or absent" + raise TetherError(message) + peers = tuple( + row.model_copy(update={"enabled": False}) if row.id == peer_id else row + for row in state.snapshot.offered_peers + ) + return TetherSnapshot.model_validate({**state.snapshot.model_dump(), "offered_peers": peers}) + + @staticmethod + def _withdraw_network(state: NativeState, network_id: str) -> TetherSnapshot: + """Disable exactly the selected offered network or imported attachment.""" + offered = state.intent.rune.offered + values = state.snapshot.model_dump() + if offered is not None and offered.id == network_id: + values["offered_peers"] = tuple( + row.model_copy(update={"enabled": False}) for row in state.snapshot.offered_peers + ) + else: + values["attachments"] = tuple( + row.model_copy(update={"enabled": False}) if row.attachment_id == network_id else row + for row in state.snapshot.attachments + ) + return TetherSnapshot.model_validate(values) + + def _begin_revocation( + self, state: NativeState, operation: str, network_id: str, peer_id: str | None, generation: str + ) -> NativeState: + """Build a validated withdrawal and its durable recovery material without effects.""" + deployment = self._retained_projection(state) + network = next((row for row in deployment.networks if row.id == network_id), None) + if network is None: + message = "Unknown or retired Tether network generation" + raise TetherError(message) + active = state.active + if peer_id is not None: + snapshot = self._withdraw_peer(state, network_id, peer_id, generation) + else: + if str(network.generation) != generation: + message = "Network generation is stale" + raise TetherError(message) + snapshot = self._withdraw_network(state, network_id) + active = tuple(row for row in active if row != network_id) + offered = state.intent.rune.offered + previous = state.snapshot.model_copy( + update={ + "offered_peers": state.snapshot.offered_peers if offered and offered.id == network_id else (), + "attachments": tuple(row for row in state.snapshot.attachments if row.attachment_id == network_id), + } + ) + target_names = { + row.name + for row in deployment.runtime_configs + if row.network_id == network_id + and (peer_id is None or row.role != "owner-ssh" or row.payload.get("peer_id") == peer_id) + } + retained = {name: value for name, value in state.incarnations.items() if name in target_names} + journals = { + key: row + for key, row in state.revocations.items() + if row["network_id"] != network_id or row["peer_id"] != peer_id or row["status"] != "complete" + } + journal = { + "status": "pending", + "network_id": network_id, + "peer_id": peer_id, + "generation": generation, + "previous_snapshot": previous.model_dump(mode="json"), + "previous_active": [network_id] if network_id in state.active else [], + "incarnations": retained, + "launches": {name: value for name, value in state.launches.items() if name in target_names}, + "pod": state.pods.get(network_id), + "closed": [], + } + return state.model_copy( + update={"snapshot": snapshot, "active": active, "revocations": {**journals, operation: journal}} + ) + + @staticmethod + def _revocation_before(state: NativeState, journal: dict[str, Any]) -> NativeState: + """Recover the old projection even when its imported attachment is now disabled.""" + snapshot = TetherSnapshot.model_validate_json(canonical_bytes(journal["previous_snapshot"])) + return state.model_copy( + update={ + "snapshot": snapshot, + "active": tuple(journal["previous_active"]), + "incarnations": journal["incarnations"], + } + ) + + def _revocation_fence(self, state: NativeState, configs: tuple[Any, ...]) -> bool: + """Publish every target fence and withdraw catalog rows, attempting each independently.""" + failed = False + for config in configs: + fence: dict[str, object] = { + **config.fences, + "enabled": config.network_id in state.active, + "peers": [ + {"id": row.id, "generation": row.generation, "enabled": row.enabled} + for row in state.snapshot.offered_peers + ], + } + try: + self.custody.publish(Path("runtime") / config.name / "fences.json", fence, public=True) + except (OSError, TetherError): + failed = True + try: + current = self._retained_projection(state) + self._catalog(state, tuple(row for row in current.routes if row.selection.attachment_id in state.active)) + except (OSError, TetherError): + failed = True + return failed + + def _stop_revoked_role(self, config: Any, retained: dict[str, Any] | None) -> None: + """Refuse foreign incarnations; exact retained units close even after container exit.""" + if retained is not None: + self.host.stop(retained) + else: + self.host.stop_unretained(config.name) + + def _revocation_close_roles( + self, state: NativeState, operation: str, configs: tuple[Any, ...] + ) -> tuple[NativeState, bool]: + """Close target role cgroups and checkpoint each outcome for independent retries.""" + journal: dict[str, Any] = state.revocations[operation] + peer_id = journal["peer_id"] + incarnations = dict(state.incarnations) + launches = dict(state.launches) + closed = set(journal["closed"]) + failed = False + for config in reversed(configs): + must_stop = peer_id is None or (config.role == "owner-ssh" and config.payload.get("peer_id") == peer_id) + if not must_stop: + continue + try: + self._stop_revoked_role(config, journal["incarnations"].get(config.name)) + incarnations.pop(config.name, None) + launches.pop(config.name, None) + closed.add(config.name) + except (OSError, TetherError): + failed = True + journal = {**journal, "closed": sorted(closed)} + state = state.model_copy( + update={ + "incarnations": dict(incarnations), + "launches": dict(launches), + "revocations": {**state.revocations, operation: journal}, + } + ) + self._save(state) + return state, failed + + def _recover_revocation_pod(self, state: NativeState, journal: dict[str, Any]) -> dict[str, Any] | None: + """Recover only the owned Pod invocation created by a journaled failed launch.""" + before = self._revocation_before(state, journal) + config = next( + row + for row in self._retained_projection(before).runtime_configs + if row.network_id == journal["network_id"] and row.role == "wireguard" + ) + pod_name = self._pod_name(config) + if not self.host.exists("pod", pod_name): + return None + pending = journal.get("launches", {}).get(config.name) + if ( + pending is None + or pending.get("boot_id") != Path("/proc/sys/kernel/random/boot_id").read_text().strip() + or pending.get("policy_digest") != config.payload["policy_digest"] + or pending.get("network_id") != config.network_id + or not isinstance(pending.get("previous_pod_invocation_id"), str) + ): + message = "Unretained Tether Pod lacks exact pending-launch authority" + raise TetherError(message) + pod = self.host.capture_pod(pod_name) + if pod["invocation_id"] == pending.get("previous_pod_invocation_id"): + message = "Pending Tether launch did not create the observed Pod invocation" + raise TetherError(message) + return pod + + def _revocation_close_pod(self, state: NativeState, operation: str) -> tuple[NativeState, bool]: + """Retire only the selected whole-network Pod after attempting all role closures.""" + journal: dict[str, Any] = state.revocations[operation] + if journal["peer_id"] is not None: + return state, False + try: + if journal.get("pod") is None: + recovered = self._recover_revocation_pod(state, journal) + if recovered is None: + return state, False + journal = {**journal, "pod": recovered} + state = state.model_copy(update={"revocations": {**state.revocations, operation: journal}}) + self._save(state) + self.host.stop_pod(journal["pod"]) + pods = dict(state.pods) + pods.pop(journal["network_id"], None) + state = state.model_copy(update={"pods": pods}) + self._save(state) + except (OSError, TetherError): + return state, True + return state, False + + @staticmethod + def _require_peer_closed(config: Any, observed: dict[str, Any], peer: OfferedPeer) -> None: + """Require WireGuard removal and zero established streams for this peer.""" + if config.role == "wireguard" and peer.public_key in observed.get("peer_public_keys", [peer.public_key]): + message = "WireGuard peer closure remains incomplete" + raise TetherError(message) + if config.role == "offered-relay" and observed.get("streams", {}).get(peer.id, 0) != 0: + message = "Offered peer streams remain active" + raise TetherError(message) + + def _revocation_acknowledge(self, state: NativeState, before: NativeState, operation: str) -> None: + """Prove peer closure from retained authority without depending on fresh grants.""" + journal = state.revocations[operation] + peer_id, network_id = journal["peer_id"], journal["network_id"] + if peer_id is None or network_id not in state.active: + return + peer = next(row for row in before.snapshot.offered_peers if row.id == peer_id) + for config in self._retained_projection(state).runtime_configs: + if config.network_id != network_id or config.role == "owner-ssh": + continue + retained = before.incarnations.get(config.name) + if retained is None or self.host.capture(config, before.image) != retained: + message = "Peer closure requires its exact retained network incarnation" + raise TetherError(message) + deadline = time.monotonic() + 5 + while True: + try: + self._observe_revoked_peer(config, peer) + break + except TetherError: + if time.monotonic() >= deadline: + raise + time.sleep(0.1) + + def revoke( + self, network_id: str, peer_id: str | None, expected_generation: str, *, dry_run: bool = False + ) -> dict[str, Any]: + """Journal withdrawal, attempt independent closure phases, and retain failed work.""" + with nullcontext() if dry_run else LifecycleLock(): + state = self._load_retained() + operation = f"{network_id}:{peer_id or '@'}:{expected_generation}" + journal = state.revocations.get(operation) + if journal is not None and self._revocation_complete(state, journal): + return {"state": "revoked", "changed": False, "physical_closure": True, "bindings_reconciled": True} + if journal is not None and journal.get("status") == "closed": + if dry_run: + return {"state": "revoked", "changed": True, "dry_run": True} + reconciled = self._reconcile_revocation(state, operation) + return { + "state": "revoked", + "changed": reconciled, + "physical_closure": True, + "bindings_reconciled": reconciled, + } + recovering = journal is not None and journal.get("status") != "complete" + updated = ( + state + if recovering + else self._begin_revocation(state, operation, network_id, peer_id, expected_generation) + ) + before = self._revocation_before(updated, updated.revocations[operation]) + if dry_run: + return {"state": "revoked", "changed": True, "dry_run": True} + self._save(updated) + configs = tuple( + row for row in self._retained_projection(before).runtime_configs if row.network_id == network_id + ) + fence_failed = self._revocation_fence(updated, configs) + updated, roles_failed = self._revocation_close_roles(updated, operation, configs) + updated, pod_failed = self._revocation_close_pod(updated, operation) + failed = fence_failed or roles_failed or pod_failed + if not failed: + try: + self._revocation_acknowledge(updated, before, operation) + except (OSError, TetherError): + failed = True + if failed: + message = "Tether revocation remains pending; retry the same target and generation" + raise TetherError(message) + reconciled = self._reconcile_revocation(updated, operation) + return {"state": "revoked", "changed": True, "physical_closure": True, "bindings_reconciled": reconciled} + + def _observe_revoked_peer(self, config: Any, peer: OfferedPeer) -> None: + """Observe the custody-owned published policy during independent pending withdrawals.""" + payload = self.custody.read(f"runtime/{config.name}/config.json") + keys = ("role", "network_id", "installation_epoch", "generation") + if payload is None or any(payload.get(key) != config.payload[key] for key in keys): + message = "Peer closure lacks an owned current role configuration" + raise TetherError(message) + published = replace(config, payload=payload) + self._require_peer_closed(published, self.host.observe(published), peer) + + def _reconcile_revocation(self, state: NativeState, operation: str) -> bool: + """Persist physical closure before attempting strict complete-fileset reconciliation.""" + previous = state.revocations[operation] + journal = { + "status": "closed", + "bindings_pending": True, + "network_id": previous["network_id"], + "peer_id": previous["peer_id"], + "generation": previous["generation"], + } + state = state.model_copy(update={"revocations": {**state.revocations, operation: journal}}) + self._save(state) + try: + self._bind(state) + except (OSError, RuntimeError, ValueError): + return False + journal = {**journal, "status": "complete", "bindings_pending": False} + state = state.model_copy(update={"revocations": {**state.revocations, operation: journal}}) + self._save(state) + return True diff --git a/src/lychd/system/services/tether/transport.py b/src/lychd/system/services/tether/transport.py new file mode 100644 index 00000000..2649729a --- /dev/null +++ b/src/lychd/system/services/tether/transport.py @@ -0,0 +1,221 @@ +"""One-shot bounded HTTPS over an explicitly selected Tether service.""" + +# One finite parser owns refusal normalization and stream closure. +# ruff: noqa: TRY301 + +from __future__ import annotations + +import asyncio +import math +import time +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import h11 +import httpx2 + +from lychd.domain.scout.transport import FetchCode, FetchError, NetworkHandoff, NetworkTarget +from lychd.domain.tether.models import TetherSelection +from lychd.system.services.scout_socket import PinnedTlsStream +from lychd.system.services.tether.connector import TetherConnector + +if TYPE_CHECKING: + from collections.abc import Callable + +_MAX_REQUEST = 64 * 1024 +_MAX_RESPONSE = 4 * 1024 * 1024 +_MAX_HEAD = 16 * 1024 +_MAX_SECONDS = 30.0 +_MAX_FIELDS = 64 +_REDIRECT_MIN = 300 +_REDIRECT_MAX = 400 + + +class _ServiceGate: + def __init__(self, current: Callable[[], None]) -> None: + self.current = current + + def handoff(self, handoff: NetworkHandoff, action: Callable[[], int]) -> int: + self.current() + if handoff.target.role != "origin": + raise FetchError(FetchCode.CONFIG) + # This transport supplies reachability only. The service caller owns its + # request and credentials; the connector checks current route admission. + return action() + + +class TetherHTTPTransport(httpx2.AsyncBaseTransport): + """Exact HTTPS origin, fixed caller and selection, no retries or retained pool. + + Use with the shared create_async_http_client factory. Each request resolves + current admission and verifies TLS again. No ambient DNS or proxy is used. + """ + + def __init__( + self, + *, + connector: TetherConnector, + selection: TetherSelection, + application_identity_ref: str, + ca_file: Path, + ) -> None: + """Bind one trusted caller, application identity and attachment selection.""" + self.connector = connector + self.selection = selection + self.application_identity_ref = application_identity_ref + self.ca_file = ca_file + self._closed = False + self._streams: set[PinnedTlsStream] = set() + + def _admit(self) -> None: + if self._closed: + raise FetchError(FetchCode.EGRESS) + + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: + """Enforce one absolute deadline, including request body consumption.""" + try: + async with asyncio.timeout(_request_seconds(request)): + return await self._exchange(request) + except TimeoutError: + message = "Tether deadline expired." + raise httpx2.TimeoutException(message, request=request) from None + + async def _exchange(self, request: httpx2.Request) -> httpx2.Response: # noqa: C901, PLR0912, PLR0915 - finite parser with owned closure + """Buffer only finite messages; close the selected stream on every outcome.""" + stream = None + try: + if self._closed: + raise FetchError(FetchCode.CONFIG) + route = self.connector.resolve(self.selection) + if ( + request.url.scheme != "https" + or request.url.host != route.hostname + or (request.url.port or 443) != route.port + or request.url.userinfo + or request.url.fragment + or request.headers.get("host") != request.url.netloc.decode("ascii") + or "transfer-encoding" in request.headers + or "proxy-authorization" in request.headers + ): + raise FetchError(FetchCode.DESTINATION) + if not isinstance(request.stream, httpx2.AsyncByteStream): + raise FetchError(FetchCode.CONFIG) + body = bytearray() + async for chunk in request.stream: + body.extend(chunk) + if len(body) > _MAX_REQUEST: + raise FetchError(FetchCode.LIMIT) + headers = [ + (k, v) + for k, v in request.headers.raw + if k.lower() not in {b"connection", b"content-length", b"accept-encoding"} + ] + headers.extend( + [ + (b"accept-encoding", b"identity"), + (b"connection", b"close"), + (b"content-length", str(len(body)).encode("ascii")), + ] + ) + connection = h11.Connection(h11.CLIENT, max_incomplete_event_size=_MAX_HEAD) + head = connection.send(h11.Request(method=request.method, target=request.url.raw_path, headers=headers)) + wire = head + connection.send(h11.Data(data=bytes(body))) + connection.send(h11.EndOfMessage()) + if len(head) > _MAX_HEAD or len(wire) > _MAX_REQUEST: + raise FetchError(FetchCode.LIMIT) + stream = await self.connector.open_tls( + self.selection, + target=NetworkTarget(route.address, route.port, route.hostname, "origin"), + application_identity_ref=self.application_identity_ref, + gate=_ServiceGate(self._admit), + deadline=time.monotonic() + _MAX_SECONDS, + ca_file=self.ca_file, + ) + if self._closed: + raise FetchError(FetchCode.CONFIG) + self._streams.add(stream) + await stream.send(wire) + response: h11.Response | None = None + content = bytearray() + received = 0 + raw_head = bytearray() + while True: + self.connector.current(route) + data = await stream.receive() + received += len(data) + if received > _MAX_RESPONSE + _MAX_HEAD: + raise FetchError(FetchCode.LIMIT) + if response is None: + raw_head.extend(data) + marker = raw_head.find(b"\r\n\r\n") + if (marker < 0 and len(raw_head) > _MAX_HEAD) or marker + 4 > _MAX_HEAD: + raise FetchError(FetchCode.LIMIT) + if marker >= 0: + names = [ + line.partition(b":")[0].lower() for line in bytes(raw_head[:marker]).split(b"\r\n")[1:] + ] + if len(names) != len(set(names)) or b"content-encoding" in names: + raise FetchError(FetchCode.PROTOCOL) + raw_head.clear() + connection.receive_data(data) + while True: + event = connection.next_event() + if event is h11.NEED_DATA: + break + if isinstance(event, h11.Response): + if ( + response is not None + or len(event.headers) > _MAX_FIELDS + or _REDIRECT_MIN <= event.status_code < _REDIRECT_MAX + ): + raise FetchError(FetchCode.PROTOCOL) + response = event + elif isinstance(event, h11.Data): + content.extend(event.data) + if len(content) > _MAX_RESPONSE: + raise FetchError(FetchCode.LIMIT) + elif isinstance(event, h11.EndOfMessage): + if response is None or event.headers or connection.trailing_data[0]: + raise FetchError(FetchCode.PROTOCOL) + self.connector.current(route) + return httpx2.Response( + response.status_code, headers=list(response.headers), content=bytes(content) + ) + else: + raise FetchError(FetchCode.PROTOCOL) + if not data: + raise FetchError(FetchCode.PROTOCOL) + except (FetchError, OSError, ValueError, h11.ProtocolError): + message = "Tether service request refused." + raise httpx2.TransportError(message, request=request) from None + finally: + if stream is not None: + self._streams.discard(stream) + stream.close() + + async def aclose(self) -> None: + """Permanently refuse future requests; each exchange owns its connection.""" + self._closed = True + for stream in tuple(self._streams): + stream.close() + self._streams.clear() + + +def _request_seconds(request: httpx2.Request) -> float: + """Apply the caller's tighter timeout within the transport's absolute ceiling.""" + configured: object = request.extensions.get("timeout", {}) + if not isinstance(configured, dict): + message = "Invalid Tether request timeout." + raise httpx2.TransportError(message, request=request) + seconds = _MAX_SECONDS + for value in cast("dict[object, object]", configured).values(): + if value is None: + continue + if type(value) not in {int, float}: + message = "Invalid Tether request timeout." + raise httpx2.TransportError(message, request=request) + numeric = cast("int | float", value) + if not math.isfinite(numeric) or numeric <= 0: + message = "Invalid Tether request timeout." + raise httpx2.TransportError(message, request=request) + seconds = min(seconds, float(numeric)) + return seconds diff --git a/src/lychd/system/templates/container.jinja b/src/lychd/system/templates/container.jinja index 0e54b218..824b9e66 100644 --- a/src/lychd/system/templates/container.jinja +++ b/src/lychd/system/templates/container.jinja @@ -29,6 +29,11 @@ ContainerName={{ container_name }} NoNewPrivileges=true {% if read_only %} DropCapability=all +{% if tether_role == 'wireguard' %} +AddCapability=NET_ADMIN +{% elif tether_role == 'owner-ssh' %} +AddCapability=SETUID SETGID SYS_CHROOT +{% endif %} ReadOnly=true ReadOnlyTmpfs={{ read_only_tmpfs | lower }} {% if secret_mount_tmpfs %} diff --git a/src/lychd/system/templates/pod.jinja b/src/lychd/system/templates/pod.jinja index 89bafb8d..8cfe3a50 100644 --- a/src/lychd/system/templates/pod.jinja +++ b/src/lychd/system/templates/pod.jinja @@ -10,6 +10,12 @@ UserNS={{ user_ns }} ShmSize={{ shm_size }} {% endif %} +{% if tether_policy %} +Network={{ tether_policy.network }} +{% if tether_policy.kind == 'offered' %} +PublishPort={{ tether_policy.udp_address }}:{{ tether_policy.udp_port }}:{{ tether_policy.udp_port }}/udp +{% endif %} +{% endif %} # Network exposure {% for port_mapping in publish_ports %} PublishPort={{ port_mapping }} diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index be61c272..0110c994 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -311,6 +311,8 @@ def host_readiness(mocker: MockerFixture) -> MagicMock: def test_root_help_does_not_construct_asgi_app(runner: CliRunner, mocker: MockerFixture) -> None: """The public Pulse stays closed and usable before the server can boot.""" + from lychd.cli.tether import tether_group + create_app = mocker.patch("lychd.app.create_app") result = runner.invoke(cli, ["--help"]) @@ -325,17 +327,19 @@ def test_root_help_does_not_construct_asgi_app(runner: CliRunner, mocker: Mocker "logs", "access", "del", + "tether", } + assert cli.commands["tether"] is tether_group assert {name for name, command in cli.commands.items() if command.hidden} == { "database", "database-bootstrap", "reactor", } - for command in ("init", "bind", "start", "stop", "status", "logs", "access", "del"): + for command in ("init", "bind", "start", "stop", "status", "logs", "access", "del", "tether"): assert command in result.output positions = [ result.output.index(f"\n {command}") - for command in ("init", "bind", "start", "stop", "status", "logs", "access", "del") + for command in ("init", "bind", "start", "stop", "status", "logs", "access", "del", "tether") ] assert positions == sorted(positions) for internal in ("destroy", "doctor", "animators", "runs", "workers", "reactor", "serve", "database"): @@ -343,7 +347,7 @@ def test_root_help_does_not_construct_asgi_app(runner: CliRunner, mocker: Mocker create_app.assert_not_called() -def test_status_lookup_alias_does_not_become_a_ninth_public_root(runner: CliRunner) -> None: +def test_status_lookup_alias_does_not_become_an_additional_public_root(runner: CliRunner) -> None: result = runner.invoke(cli, ["st", "--help"]) assert result.exit_code == 0 diff --git a/tests/unit/cli/test_tether.py b/tests/unit/cli/test_tether.py new file mode 100644 index 00000000..50e287d4 --- /dev/null +++ b/tests/unit/cli/test_tether.py @@ -0,0 +1,210 @@ +"""Native CLI dispatch and original-profile selection boundaries.""" + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest +from click.testing import CliRunner + +from lychd.cli.commands import _initial_profile +from lychd.cli.tether import tether_group +from lychd.config.settings import SettingsSnapshot + +if TYPE_CHECKING: + from lychd.config.settings import Settings + + +@pytest.mark.parametrize( + ("arguments", "method"), + [ + (["prepare", "--intent", "/public.json", "--dry-run"], "prepare"), + (["bind", "--dry-run"], "bind"), + (["activate", "--dry-run"], "activate"), + (["inspect"], "inspect"), + (["revoke", "--network", "network", "--generation", "generation", "--dry-run"], "revoke"), + (["import-key", "--secret", "reference", "--file", "/private", "--dry-run"], "import_key"), + (["runtime-dir"], "runtime_dir"), + (["resume"], "resume"), + (["admit", "--attachment", "road", "--generation", "2", "--dry-run"], "admit"), + ], +) +def test_tether_commands_use_native_service_only( + arguments: list[str], method: str, monkeypatch: pytest.MonkeyPatch +) -> None: + service = MagicMock() + getattr(service, method).return_value = {"ok": True} + monkeypatch.setattr("lychd.system.services.tether.service.NativeTetherService", lambda: service) + result = CliRunner().invoke(tether_group, arguments) + assert result.exit_code == 0, result.output + assert result.output.strip() == '{"ok": true}' + assert getattr(service, method).call_count == 1 + + +def test_native_errors_do_not_render_private_values(monkeypatch: pytest.MonkeyPatch) -> None: + service = MagicMock() + service.inspect.side_effect = ValueError("PRIVATE material") + monkeypatch.setattr("lychd.system.services.tether.service.NativeTetherService", lambda: service) + result = CliRunner().invoke(tether_group, ["inspect"]) + assert result.exit_code != 0 + assert "PRIVATE" not in result.output + + +def test_tether_profile_prepares_settings_without_mutating_cached_values(monkeypatch: pytest.MonkeyPatch) -> None: + settings = SettingsSnapshot(payload="{}").materialize() + original = settings.model_dump() + rune = object() + assembly = object() + + def load_intent(_path: Path) -> SimpleNamespace: + return SimpleNamespace(rune=rune, web_port=9123) + + def assemble_extensions(_prepared: Settings) -> object: + return assembly + + monkeypatch.setattr("lychd.system.services.tether.models.load_intent", load_intent) + monkeypatch.setattr("lychd.extensions.host.assemble_extensions", assemble_extensions) + prepared, runes, extensions = _initial_profile(settings, profile="tether-container-v1", intent=Path("/public.json")) + assert settings.model_dump() == original + assert prepared.server.port == 9123 + assert prepared.extensions.builtins == ("tether",) + assert prepared.extensions.crypt == () + assert runes == (rune,) + assert extensions is assembly + + +def test_ordinary_bind_keeps_native_helpers_and_attestor(monkeypatch: pytest.MonkeyPatch) -> None: + from lychd.cli.binding import BindingCommandSession + from lychd.system.services.bind import BindRequest + + settings = SettingsSnapshot(payload="{}").materialize() + settings.extensions.builtins = ("tether",) + native = MagicMock() + request = BindRequest.compile( + manifests=(), + plain_units={"lychd-tether-runtime.service": "owned runtime helper"}, + core_secret_factories={}, + required_secret_names=(), + ) + native.bind_request.return_value = request + monkeypatch.setattr("lychd.system.services.tether.service.NativeTetherService", lambda: native) + assemble = MagicMock() + compile_direct = MagicMock(side_effect=AssertionError("Native helper units would be lost")) + monkeypatch.setattr("lychd.cli.binding.assemble_bind_use_case", assemble) + monkeypatch.setattr("lychd.cli.binding.compile_bind_request", compile_direct) + session = BindingCommandSession( + settings_snapshot=SettingsSnapshot.capture(settings), + extensions=MagicMock(), + runes=MagicMock(), + soulstones=(), + portals=(), + preflight_service=MagicMock(), + preflight=MagicMock(), + ) + prepared = session.prepare() + assert prepared.request is request + assert dict(prepared.request.plain_units) == {"lychd-tether-runtime.service": "owned runtime helper"} + assert assemble.call_args.kwargs["request_attestor"] == native.attest_bind_request + assemble.return_value.plan.assert_called_once_with(request) + compile_direct.assert_not_called() + + +def _attachment_document() -> dict[str, object]: + import base64 + + return { + "id": "received", + "remote_lich_id": "remote", + "interface": "wgimport", + "address": "10.77.0.2/24", + "private_key_ref": "lychd-tether-client", + "remote_public_key": base64.b64encode(b"\x01" * 32).decode(), + "endpoint_address": "192.0.2.50", + "endpoint_port": 51820, + "services": [ + { + "id": "api", + "address": "10.77.0.1", + "port": 8443, + "hostname": "peer.test", + "application_identity_ref": "remote-app", + } + ], + "allowed_callers": ["intercom"], + } + + +def test_register_dispatches_closed_attachment_without_other_lifecycle( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + + from lychd.domain.tether.models import ImportedAttachment + + path = tmp_path / "attachment.json" + path.write_text(json.dumps(_attachment_document())) + path.chmod(0o600) + service = MagicMock() + service.register_attachment.return_value = {"state": "registered", "changed": True, "attachment_id": "received"} + monkeypatch.setattr("lychd.system.services.tether.service.NativeTetherService", lambda: service) + result = CliRunner().invoke(tether_group, ["register", "--attachment-file", str(path), "--dry-run"]) + assert result.exit_code == 0, result.output + expected = ImportedAttachment.model_validate_json(path.read_text(), strict=True) + service.register_attachment.assert_called_once_with(expected, dry_run=True) + assert [call[0] for call in service.method_calls] == ["register_attachment"] + + +@pytest.mark.parametrize("case", ["mode", "symlink", "parent_symlink", "hardlink", "oversize", "duplicate", "hook"]) +def test_register_refuses_unsafe_or_unclosed_public_input( + case: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + import os + + path = tmp_path / "attachment.json" + path.write_text(json.dumps(_attachment_document())) + path.chmod(0o600) + if case == "mode": + path.chmod(0o644) + elif case == "symlink": + alias = tmp_path / "alias.json" + alias.symlink_to(path) + path = alias + elif case == "parent_symlink": + alias = tmp_path / "alias" + alias.symlink_to(tmp_path, target_is_directory=True) + path = alias / path.name + elif case == "hardlink": + os.link(path, tmp_path / "alias.json") + elif case == "oversize": + path.write_bytes(b" " * (64 * 1024 + 1)) + elif case == "duplicate": + path.write_text('{"id":"received","id":"PRIVATE"}') + else: + path.write_text(json.dumps({**_attachment_document(), "PostUp": "PRIVATE"})) + service = MagicMock() + monkeypatch.setattr("lychd.system.services.tether.service.NativeTetherService", lambda: service) + result = CliRunner().invoke(tether_group, ["register", "--attachment-file", str(path)]) + assert result.exit_code != 0 + assert "Public attachment declaration refused" in result.output + assert "PRIVATE" not in result.output + service.register_attachment.assert_not_called() + + +def test_register_native_failure_does_not_echo_declaration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import json + + path = tmp_path / "attachment.json" + path.write_text(json.dumps(_attachment_document())) + path.chmod(0o600) + service = MagicMock() + service.register_attachment.side_effect = ValueError("PRIVATE") + monkeypatch.setattr("lychd.system.services.tether.service.NativeTetherService", lambda: service) + result = CliRunner().invoke(tether_group, ["register", "--attachment-file", str(path)]) + assert result.exit_code != 0 + assert "PRIVATE" not in result.output diff --git a/tests/unit/domain/tether/__init__.py b/tests/unit/domain/tether/__init__.py new file mode 100644 index 00000000..2fe4717e --- /dev/null +++ b/tests/unit/domain/tether/__init__.py @@ -0,0 +1 @@ +"""Pure Tether authority and projection contracts.""" diff --git a/tests/unit/domain/tether/test_compiler.py b/tests/unit/domain/tether/test_compiler.py new file mode 100644 index 00000000..7c6032a9 --- /dev/null +++ b/tests/unit/domain/tether/test_compiler.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from lychd.config.quadlet import QuadletConfig +from lychd.domain.animation.transmute import QuadletContribution, Transmuter +from lychd.domain.tether.compiler import TetherDeployment, compile_tether +from lychd.domain.tether.models import ( + AttachmentAdmission, + ImportedAttachment, + ImportedService, + OfferedNetwork, + OfferedPeer, + OfferedService, + OwnerAccess, + TetherRune, + TetherSnapshot, +) +from lychd.system.schemas import QuadletContainer, QuadletPod, TetherPodPolicy + +EPOCH = "ab" * 16 +KEY = base64.b64encode(bytes(range(32))).decode() +KEY2 = base64.b64encode(bytes(range(1, 33))).decode() +SSH_KEY = "ssh-ed25519 " + base64.b64encode(b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + bytes(range(32))).decode() + + +class _TransmuterProbe(Transmuter): + @classmethod + def normalize(cls, contribution: QuadletContribution) -> QuadletContribution: + return cls._normalize_contribution(contribution) + + +def attachment(name: str) -> ImportedAttachment: + return ImportedAttachment( + id=name, + remote_lich_id=name, + interface="wg0", + address="10.77.0.1/32", + private_key_ref=f"lychd-tether-{name}-private", + remote_public_key=KEY, + endpoint_address="198.51.100.10", + endpoint_port=51820, + services=( + ImportedService( + id="intercom", address="10.77.0.2", port=443, hostname=f"{name}.example", application_identity_ref=name + ), + ), + allowed_callers=("intercom",), + ) + + +def project(rune: TetherRune, snapshot: TetherSnapshot) -> TetherDeployment: + return compile_tether( + rune=rune, + snapshot=snapshot, + runtime_root=Path("/run/user/1000/lychd-tether"), + config_root=Path("/var/lib/example/tether-config"), + core_web_port=8123, + consumer_uid=1000, + ) + + +def test_overlapping_imported_addresses_have_distinct_selection_and_socket() -> None: + rune = TetherRune(quadlet=QuadletConfig(image="example/tether:1"), attachments=(attachment("b"), attachment("c"))) + snapshot = TetherSnapshot( + installation_epoch=EPOCH, + attachments=( + AttachmentAdmission(attachment_id="b", generation=3), + AttachmentAdmission(attachment_id="c", generation=2), + ), + ) + result = project(rune, snapshot) + assert len(result.routes) == 2 + assert {row.address for row in result.routes} == {"10.77.0.2"} + assert len({row.socket_path for row in result.routes}) == 2 + assert len({network.pod_unit for network in result.networks}) == 2 + assert result.consumer_mount is not None + assert result.consumer_mount.options == ["ro", "z"] + assert all(manifest.pod != "lychd.pod" for manifest in result.manifests if isinstance(manifest, QuadletContainer)) + assert project(rune, snapshot) == result + + +def test_revoking_one_attachment_preserves_the_other_exact_projection() -> None: + rune = TetherRune(quadlet=QuadletConfig(image="example/tether:1"), attachments=(attachment("b"), attachment("c"))) + before = TetherSnapshot( + installation_epoch=EPOCH, + attachments=( + AttachmentAdmission(attachment_id="b", generation=1), + AttachmentAdmission(attachment_id="c", generation=2), + ), + ) + after = before.model_copy( + update={ + "attachments": (AttachmentAdmission(attachment_id="b", generation=2, enabled=False), before.attachments[1]) + } + ) + old = project(rune, before) + new = project(rune, after) + assert new.routes == (old.routes[1],) + assert new.networks == (old.networks[1],) + assert new.runtime_configs == tuple(config for config in old.runtime_configs if config.network_id == "c") + assert "lychd-tether-b-private" not in new.required_secrets + + +def test_generation_and_installation_epoch_change_socket_identity() -> None: + rune = TetherRune(quadlet=QuadletConfig(image="example/tether:1"), attachments=(attachment("b"),)) + snapshot = TetherSnapshot( + installation_epoch=EPOCH, attachments=(AttachmentAdmission(attachment_id="b", generation=1),) + ) + old = project(rune, snapshot).routes[0] + changed_generation = snapshot.model_copy( + update={"attachments": (AttachmentAdmission(attachment_id="b", generation=2),)} + ) + changed_epoch = snapshot.model_copy(update={"installation_epoch": "cd" * 16}) + assert old.socket_path != project(rune, changed_generation).routes[0].socket_path + assert old.socket_path != project(rune, changed_epoch).routes[0].socket_path + + +def test_one_offered_interface_many_independent_owner_adapters() -> None: + offered = OfferedNetwork( + id="home", + interface="wg0", + address="10.55.0.1/24", + listen_address="192.0.2.10", + listen_port=51820, + private_key_ref="lychd-tether-home-private", + ) + rune = TetherRune(quadlet=QuadletConfig(image="example/tether:1"), offered=offered) + peers = tuple( + OfferedPeer( + id=name, + generation=1, + public_key=key, + address=address, + owner_access=OwnerAccess( + ssh_public_key=SSH_KEY, ssh_host_key_ref=f"lychd-tether-ssh-{name}", listen_port=port + ), + ) + for name, key, address, port in (("a", KEY, "10.55.0.2", 2222), ("b", KEY2, "10.55.0.3", 2223)) + ) + result = project(rune, TetherSnapshot(installation_epoch=EPOCH, offered_peers=peers)) + configs = result.runtime_configs + assert [config.role for config in configs].count("wireguard") == 1 + assert [config.role for config in configs].count("owner-ssh") == 2 + assert len({config.name for config in configs}) == 3 + for config in configs: + assert "private_key" not in json.dumps(config.payload).replace("private_key_path", "") + if config.role == "owner-ssh": + assert config.payload["target_port"] == 8123 + assert config.payload["target_address"] == "127.0.0.1" + pod = next(manifest for manifest in result.manifests if isinstance(manifest, QuadletPod)) + assert pod.tether_policy is not None + assert pod.tether_policy.owner_loopback_port == 8123 + assert pod.publish_ports == [] + revoked = peers[0].model_copy(update={"generation": 2, "enabled": False}) + changed = project(rune, TetherSnapshot(installation_epoch=EPOCH, offered_peers=(revoked, peers[1]))) + old_owner = next(config for config in configs if config.role == "owner-ssh" and config.payload["peer_id"] == "b") + new_owner = next(config for config in changed.runtime_configs if config.role == "owner-ssh") + assert old_owner.payload == new_owner.payload + old_unit = next( + manifest + for manifest in result.manifests + if isinstance(manifest, QuadletContainer) and manifest.container_name == old_owner.name + ) + new_unit = next( + manifest + for manifest in changed.manifests + if isinstance(manifest, QuadletContainer) and manifest.container_name == new_owner.name + ) + assert old_unit == new_unit + + +def test_last_owner_revocation_preserves_pod_capacity_without_live_admission() -> None: + offered = OfferedNetwork( + id="home", + interface="wg0", + address="10.55.0.1/24", + listen_address="192.0.2.10", + listen_port=51820, + private_key_ref="lychd-tether-home-private", + ) + rune = TetherRune(quadlet=QuadletConfig(image="example/tether:1"), offered=offered) + owner = OfferedPeer( + id="owner", + generation=1, + public_key=KEY, + address="10.55.0.2", + owner_access=OwnerAccess(ssh_public_key=SSH_KEY, ssh_host_key_ref="lychd-tether-owner-ssh", listen_port=2222), + ) + initial = TetherSnapshot(installation_epoch=EPOCH) + admitted = initial.model_copy(update={"offered_peers": (owner,)}) + revoked = admitted.model_copy(update={"offered_peers": (owner.model_copy(update={"enabled": False}),)}) + empty, before, after = (project(rune, snapshot) for snapshot in (initial, admitted, revoked)) + empty_pod, old_pod, new_pod = ( + next(row for row in deployment.manifests if isinstance(row, QuadletPod)) + for deployment in (empty, before, after) + ) + assert empty_pod.tether_policy is not None + assert empty_pod.tether_policy.owner_loopback_port is None + assert empty_pod.model_dump_json() != old_pod.model_dump_json() + assert old_pod.model_dump_json() == new_pod.model_dump_json() + assert new_pod.tether_policy is not None + assert new_pod.tether_policy.owner_loopback_port == 8123 + assert [row.role for row in after.runtime_configs] == ["wireguard"] + wireguard = after.runtime_configs[0] + assert wireguard.payload["peers"] == [] + assert wireguard.payload["ingress"] == [] + assert wireguard.payload["owner_loopback_port"] == 8123 + assert wireguard.fences["peers"] == [{"id": "owner", "generation": 1, "enabled": False}] + assert after.required_secrets == (offered.private_key_ref,) + assert all(row.tether_role != "owner-ssh" for row in after.manifests if isinstance(row, QuadletContainer)) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("endpoint_address", "example.com"), + ("endpoint_address", "127.0.0.1"), + ("address", "10.77.0.1/0"), + ("PostUp", "touch /tmp/owned"), + ("dns_policy", "host"), + ("endpoint_port", True), + ], +) +def test_imported_intent_rejects_ambient_or_executable_input(field: str, value: object) -> None: + payload = attachment("b").model_dump() + payload[field] = value + with pytest.raises(ValidationError): + ImportedAttachment.model_validate(payload) + + +def test_snapshot_cannot_duplicate_peer_address_or_key() -> None: + peer = OfferedPeer(id="a", generation=1, public_key=KEY, address="10.55.0.2") + with pytest.raises(ValidationError, match="unique"): + TetherSnapshot(installation_epoch=EPOCH, offered_peers=(peer, peer.model_copy(update={"id": "b"}))) + + +def test_generic_contributor_cannot_claim_tether_capabilities() -> None: + body = QuadletContainer( + description="Tether", + image="example/tether:1", + container_name="lychd-tether-test", + pod="lychd-tether-test.pod", + user="0", + read_only=True, + tether_role="wireguard", + run_init=False, + wanted_by=[], + ) + with pytest.raises(ValueError, match="host admission"): + _TransmuterProbe.normalize(QuadletContribution(containers=(body,))) + + +def test_tether_profile_refuses_core_pod_and_generic_port_widening() -> None: + with pytest.raises(ValidationError, match="own explicitly"): + QuadletPod(tether_policy=TetherPodPolicy(kind="imported"), wanted_by=[]) + with pytest.raises(ValidationError, match=r"127\.0\.0\.1"): + QuadletPod(publish_ports=["0.0.0.0:51820:51820/udp"]) + with pytest.raises(ValidationError, match="standalone"): + QuadletContainer( + description="bad", + image="example/tether:1", + container_name="lychd-tether-test", + pod="lychd.pod", + user="0", + read_only=True, + tether_role="wireguard", + run_init=False, + wanted_by=[], + ) + + +def test_secret_reference_cannot_cross_credential_purposes() -> None: + imported = attachment("b") + imported = imported.model_copy(update={"preshared_key_ref": imported.private_key_ref}) + rune = TetherRune(quadlet=QuadletConfig(image="example/tether:1"), attachments=(imported,)) + snapshot = TetherSnapshot( + installation_epoch=EPOCH, attachments=(AttachmentAdmission(attachment_id="b", generation=1),) + ) + with pytest.raises(ValueError, match="credential-purpose"): + project(rune, snapshot) + + +def test_network_policy_cannot_smuggle_raw_pasta_arguments() -> None: + with pytest.raises(ValidationError, match="closed pasta"): + TetherPodPolicy(kind="imported", network="host") + + +def test_current_snapshot_must_reference_declared_attachment() -> None: + rune = TetherRune(quadlet=QuadletConfig(image="example/tether:1")) + snapshot = TetherSnapshot( + installation_epoch=EPOCH, attachments=(AttachmentAdmission(attachment_id="unknown", generation=1),) + ) + with pytest.raises(ValueError, match="undeclared"): + project(rune, snapshot) + + +@pytest.mark.parametrize( + ("address", "port"), + [ + ("192.0.2.10", 443), + ("169.254.1.2", 443), + ("10.55.0.2", 443), + ("192.0.2.20", 8000), + ("192.0.2.20", 5432), + ("192.0.2.20", 8123), + ], +) +def test_external_backend_refuses_host_or_core_destinations(address: str, port: int) -> None: + rune = TetherRune( + quadlet=QuadletConfig(image="example/tether:1"), + offered=OfferedNetwork( + id="home", + interface="wg0", + address="10.55.0.1/24", + listen_address="192.0.2.10", + listen_port=51820, + private_key_ref="lychd-tether-home-private", + services=(OfferedService(id="external", listen_port=9443, backend_address=address, backend_port=port),), + ), + ) + snapshot = TetherSnapshot(installation_epoch=EPOCH) + with pytest.raises(ValueError, match="admission context"): + project(rune, snapshot) + with pytest.raises(ValueError, match=r"backend|SSH profile"): + compile_tether( + rune=rune, + snapshot=snapshot, + runtime_root=Path("/run/user/1000/lychd-tether"), + config_root=Path("/var/lib/example/tether-config"), + core_web_port=8123, + consumer_uid=1000, + forbidden_backend_addresses=("192.0.2.10",), + ) + + +@pytest.mark.parametrize("interface", ["lo", "eth0", "tap0"]) +def test_imported_interface_cannot_replace_namespace_device(interface: str) -> None: + payload = attachment("b").model_dump() + payload["interface"] = interface + with pytest.raises(ValidationError, match="reserved"): + ImportedAttachment.model_validate(payload) + + +def test_outer_endpoint_cannot_enter_tunnel_connected_route() -> None: + payload = attachment("b").model_dump() + payload.update(address="10.77.0.1/24", endpoint_address="10.77.0.254") + with pytest.raises(ValidationError, match="local tunnel prefix"): + ImportedAttachment.model_validate(payload) + + +def test_offered_only_core_manifest_stays_identical_when_first_import_is_admitted() -> None: + from lychd.config.runes.registry import RuneRegistry + from lychd.config.settings import SettingsSnapshot + from lychd.extensions.host import assemble_extensions + from lychd.system.services.bind_compilation import compile_bind_request + + settings = SettingsSnapshot(payload="{}").materialize() + settings.extensions.builtins = ("tether",) + extensions = assemble_extensions(settings) + offered = OfferedNetwork( + id="home", + interface="wg0", + address="10.55.0.1/24", + listen_address="192.0.2.10", + listen_port=51820, + private_key_ref="lychd-tether-home-private", + ) + initial = TetherRune(quadlet=QuadletConfig(image="example/tether:1"), offered=offered) + added = initial.model_copy(update={"attachments": (attachment("b"),)}) + before = TetherSnapshot(installation_epoch=EPOCH) + after = before.model_copy(update={"attachments": (AttachmentAdmission(attachment_id="b", generation=1),)}) + results: list[QuadletContainer] = [] + for rune, snapshot in ((initial, before), (added, after)): + request = compile_bind_request( + settings=settings, + extensions=extensions, + runes=RuneRegistry((rune,)), + soulstones=(), + portals=(), + tether_snapshot=snapshot, + tether_runtime_root=Path("/run/user/1000/lychd-tether"), + tether_config_root=Path("/var/lib/example/tether-config"), + tether_consumer_uid=1000, + ) + core = next( + row + for row in request.manifests + if isinstance(row, QuadletContainer) and row.container_name == "lychd-vessel" + ) + results.append(core) + assert results[0].model_dump(mode="json") == results[1].model_dump(mode="json") + assert any( + volume.container_path == Path("/run/lychd-tether") and volume.options == ["ro", "z"] + for volume in results[0].volumes + ) diff --git a/tests/unit/extensions/test_catalog.py b/tests/unit/extensions/test_catalog.py index d4d975bd..cddb72b8 100644 --- a/tests/unit/extensions/test_catalog.py +++ b/tests/unit/extensions/test_catalog.py @@ -25,7 +25,10 @@ def test_catalog_has_only_explicitly_supported_builtin_ids() -> None: "animator/sglang", "observability/phoenix", "delegation", + "tether", } + assert builtin_register_module("tether") == "lychd.extensions.builtin.tether.register" + assert builtin_registration_order(("tether", "tether")) == ("tether",) def test_unknown_builtin_is_rejected_before_import() -> None: diff --git a/tests/unit/system/operator/test_control.py b/tests/unit/system/operator/test_control.py index 08604029..f843f3f9 100644 --- a/tests/unit/system/operator/test_control.py +++ b/tests/unit/system/operator/test_control.py @@ -22,6 +22,7 @@ VesselAuthority, ) from lychd.system.operator.inventory import OperatorInventoryService +from lychd.system.operator.process import ProcessInvocationError from lychd.system.services.scribe import OwnedBindings, ScribeService if TYPE_CHECKING: @@ -88,6 +89,23 @@ def run(self, argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: return result +class _TimedStartRunner(_StartRunner): + def __init__(self, duration_s: float) -> None: + super().__init__() + self.duration_s = duration_s + self.job_pending = False + + def run(self, argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: + result = super().run(argv, timeout_s=timeout_s) + if argv[2] == "start": + self.job_pending = True + if self.duration_s > timeout_s: + message = "Synthetic systemctl client observation timed out" + raise ProcessInvocationError(message) + self.job_pending = False + return result + + class _RetiringRunner(_Runner): def __init__(self, inventory: _Inventory) -> None: super().__init__() @@ -166,11 +184,59 @@ def test_start_down_system_uses_exact_owned_vessel_argv() -> None: "start", "lychd-vessel.service", ), - 30.0, + 360.0, ), ] +def test_cold_start_can_complete_after_the_short_command_budget() -> None: + runner = _TimedStartRunner(duration_s=75.0) + control = _control(_Inventory(_catalog(ObservationState.INACTIVE)), runner) + + result = control.execute(OperatorAction.START) + + assert result.authority is VesselAuthority.DIRECT + assert result.units == ("lychd-vessel.service",) + assert not runner.job_pending + assert runner.calls == [ + (_COUNTERPART_SHOW, 3.0), + (("/usr/bin/systemctl", "--user", "start", "lychd-vessel.service"), 360.0), + ] + + +def test_start_observation_timeout_preserves_uncertainty_without_second_action(mocker: MockerFixture) -> None: + events: list[str] = [] + + @contextmanager + def lock() -> Generator[None]: + events.append("locked") + try: + yield + finally: + events.append("unlocked") + + audit = mocker.patch("lychd.system.operator.control.logger") + runner = _TimedStartRunner(duration_s=361.0) + control = _control(_Inventory(_catalog(ObservationState.INACTIVE)), runner, lock_factory=lock) + + with pytest.raises(OperatorError, match="outcome is unconfirmed; the manager job may continue"): + control.execute(OperatorAction.START) + + assert runner.job_pending + assert events == ["locked", "unlocked"] + assert runner.calls == [ + (_COUNTERPART_SHOW, 3.0), + (("/usr/bin/systemctl", "--user", "start", "lychd-vessel.service"), 360.0), + ] + audit.info.assert_called_once_with( + "operator_actuation_started", + action="start", + target="system", + authority="direct", + units=("lychd-vessel.service",), + ) + + @pytest.mark.parametrize("other_state", [ObservationState.ACTIVE, ObservationState.UNKNOWN]) def test_start_down_system_refuses_split_active_runtime(other_state: ObservationState) -> None: inventory = _Inventory( @@ -346,7 +412,10 @@ def test_storage_actuation_ignores_unchanged_unknown_unselected_unit( assert result.authority is VesselAuthority.DIRECT assert result.units == ("lychd-phylactery.service",) assert runner.calls == [ - (("/usr/bin/systemctl", "--user", action.value, "lychd-phylactery.service"), 30.0), + ( + ("/usr/bin/systemctl", "--user", action.value, "lychd-phylactery.service"), + 360.0 if action is OperatorAction.START else 30.0, + ), ] @@ -771,7 +840,12 @@ def test_direct_selected_control_keeps_bad_setting_activity_policy( assert result.authority is VesselAuthority.DIRECT assert result.units == ("lychd-phylactery.service",) - assert runner.calls == [(("/usr/bin/systemctl", "--user", action.value, "lychd-phylactery.service"), 30.0)] + assert runner.calls == [ + ( + ("/usr/bin/systemctl", "--user", action.value, "lychd-phylactery.service"), + 360.0 if action is OperatorAction.START else 30.0, + ) + ] def test_retiring_runner_preserves_observed_load_state_and_detail() -> None: diff --git a/tests/unit/system/operator/test_vessel_counterpart.py b/tests/unit/system/operator/test_vessel_counterpart.py index f6967439..cff037b9 100644 --- a/tests/unit/system/operator/test_vessel_counterpart.py +++ b/tests/unit/system/operator/test_vessel_counterpart.py @@ -94,7 +94,7 @@ def run(self, argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: stdout = "".join(f"{key}={value}\n" for key, value in self.fields.items()) return ProcessResult(argv, self.returncode, stdout, "private untrusted stderr" * 1000) assert argv == ("/trusted/systemctl", "--user", "start", self.vessel) - assert timeout_s == 30.0 + assert timeout_s == 360.0 return ProcessResult(argv, 0) def control(self, *, systemctl_bin: str | None = "/trusted/systemctl") -> OperatorControlService: @@ -123,7 +123,7 @@ def test_cold_counterpart_allows_only_exact_owned_start(vessel: str, counterpart assert tuple(unit.name for unit in world.catalog.units) == (vessel, "lychd-phylactery.service") assert world.calls == [ (world.show_argv(), 3.0), - (("/trusted/systemctl", "--user", "start", vessel), 30.0), + (("/trusted/systemctl", "--user", "start", vessel), 360.0), ] assert world.events == ["lock", "owned-1", "owned-2", "show", "start", "unlock"] diff --git a/tests/unit/system/services/test_bind.py b/tests/unit/system/services/test_bind.py index 370b1655..e8f288b0 100644 --- a/tests/unit/system/services/test_bind.py +++ b/tests/unit/system/services/test_bind.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import nullcontext +from collections.abc import Callable, Generator +from contextlib import contextmanager, nullcontext from pathlib import Path from unittest.mock import MagicMock @@ -82,6 +82,7 @@ def _use_case( systemd: MagicMock, foundation: BindingFoundation | None = None, observed_foundation: BindingFoundation | None = None, + request_attestor: Callable[[BindRequest], None] | None = None, ) -> BindUseCase: expected = foundation or _foundation() return BindUseCase( @@ -91,6 +92,7 @@ def _use_case( foundation=expected, foundation_probe=lambda: observed_foundation or expected, lock_factory=nullcontext, + request_attestor=request_attestor, ) @@ -720,3 +722,127 @@ def test_apply_preserves_scribe_failure_state_without_claiming_generation( assert failure.value.progress.binding_commit_state is binding_state assert failure.value.progress.binding_generation is None systemd.daemon_reload.assert_not_called() + + +def test_owner_identity_drift_is_refused_under_lock_before_any_effect() -> None: + events: list[str] = [] + locked = False + + @contextmanager + def lock() -> Generator[None]: + nonlocal locked + locked = True + events.append("lock") + try: + yield + finally: + locked = False + events.append("unlock") + + def attest(request: BindRequest) -> None: + assert locked + assert request.required_secret_names == ("operator-token",) + events.append("attest") + message = "owner_identity_changed" + raise BindingPlanDriftError(message) + + scribe, secrets, systemd = MagicMock(), MagicMock(), MagicMock() + scribe.plan_reconcile_all.return_value = BindingReconcilePlan( + changes=(), + observed_generation="before", + desired_generation="desired", + ) + secrets.exists.return_value = True + foundation = _foundation() + use_case = BindUseCase( + scribe=scribe, + secrets=secrets, + systemd_factory=lambda: systemd, + foundation=foundation, + foundation_probe=lambda: foundation, + lock_factory=lock, + request_attestor=attest, + ) + request = _request() + approved = use_case.plan(request) + with pytest.raises(BindingPlanDriftError, match="owner_identity_changed"): + use_case.apply(request, approved) + assert events == ["lock", "attest", "unlock"] + secrets.ensure_present.assert_not_called() + scribe.reconcile_all.assert_not_called() + systemd.daemon_reload.assert_not_called() + + +def test_second_owner_attestation_preserves_confirmed_secret_creation() -> None: + scribe, secrets, systemd = MagicMock(), MagicMock(), MagicMock() + scribe.plan_reconcile_all.return_value = BindingReconcilePlan( + changes=(), + observed_generation="before", + desired_generation="desired", + ) + present = {"operator-token"} + secrets.exists.side_effect = present.__contains__ + + def create(name: str, value: str) -> bool: + assert value == "generated" + present.add(name) + return True + + secrets.ensure_present.side_effect = create + attestations = 0 + + def attest(request: BindRequest) -> None: + nonlocal attestations + assert request.required_secret_names == ("operator-token",) + attestations += 1 + if attestations == 2: + message = "owner_identity_changed_after_creation" + raise BindingPlanDriftError(message) + + use_case = _use_case(scribe=scribe, secrets=secrets, systemd=systemd, request_attestor=attest) + request = _request() + approved = use_case.plan(request) + with pytest.raises(BindApplyError, match="attestation failed") as caught: + use_case.apply(request, approved) + assert attestations == 2 + assert caught.value.progress.created_secrets == ("lychd-core",) + assert caught.value.progress.binding_commit_state is BindingCommitState.NOT_ATTEMPTED + scribe.reconcile_all.assert_not_called() + systemd.daemon_reload.assert_not_called() + + +def test_host_assembly_forwards_owner_attestation(monkeypatch: pytest.MonkeyPatch) -> None: + from lychd.config.settings import SettingsSnapshot + from lychd.system.services.bind_assembly import assemble_bind_use_case + + secrets, scribe = MagicMock(), MagicMock() + secrets.exists.return_value = True + scribe.plan_reconcile_all.return_value = BindingReconcilePlan( + changes=(), + observed_generation="before", + desired_generation="desired", + ) + + def secret_store(_binary: str) -> MagicMock: + return secrets + + def scribe_service(**_kwargs: object) -> MagicMock: + return scribe + + monkeypatch.setattr("lychd.system.services.secrets.PodmanSecretStore", secret_store) + monkeypatch.setattr("lychd.system.services.scribe.facade.ScribeService", scribe_service) + monkeypatch.setattr("lychd.system.services.lifecycle.lock.LifecycleLock", nullcontext) + attestor = MagicMock(side_effect=BindingPlanDriftError("owner_identity_changed")) + use_case = assemble_bind_use_case( + foundation=_foundation(), + preflight_service=MagicMock(), + settings_snapshot=SettingsSnapshot(payload="{}"), + request_attestor=attestor, + ) + request = _request() + approved = use_case.plan(request) + with pytest.raises(BindingPlanDriftError, match="owner_identity_changed"): + use_case.apply(request, approved) + attestor.assert_called_once_with(request) + secrets.ensure_present.assert_not_called() + scribe.reconcile_all.assert_not_called() diff --git a/tests/unit/system/services/test_codex.py b/tests/unit/system/services/test_codex.py index 4e87f135..f444582c 100644 --- a/tests/unit/system/services/test_codex.py +++ b/tests/unit/system/services/test_codex.py @@ -150,3 +150,38 @@ def test_prepared_image_is_part_of_original_inscription_and_never_rewrites_exist assert not repeated.files assert digest_file(path, expected=path.stat()) == original_digest assert path.stat().st_ino == before.st_ino + + +@pytest.mark.usefixtures("persistent_identity_observer") +def test_active_profile_rune_is_part_of_original_journal(codex_paths: dict[str, Path]) -> None: + from pathlib import Path + from typing import ClassVar + + from lychd.config.runes import RuneConfig + + class InitialRune(RuneConfig): + path_fragment: ClassVar[Path] = Path("initial") + name: str + + rune = InitialRune(name="admitted") + service = CodexService( + toml_path=codex_paths["toml"], + runes_path=codex_paths["runes"], + postgres_root_path=codex_paths["postgres"], + rune_schemas=[InitialRune], + settings=Settings(), + initial_runes=(rune,), + ) + planned = service.initial_files() + assert service.sample_schemas == [] + journal: list[CreatedResources] = [] + created = service.inscribe(on_created=journal.append) + assert created == CreatedResources.combine(*journal) + assert set(planned).issubset(created.files) + path = next(iter(planned)) + assert path.name == "default.toml" + assert tomllib.loads(path.read_text()) == {"name": "admitted"} + assert list(path.parent.glob("*.toml")) == [path] + before = path.stat().st_ino + assert service.inscribe() == CreatedResources() + assert path.stat().st_ino == before diff --git a/tests/unit/system/services/test_deployment.py b/tests/unit/system/services/test_deployment.py index d73b59a8..1289a8f2 100644 --- a/tests/unit/system/services/test_deployment.py +++ b/tests/unit/system/services/test_deployment.py @@ -1,6 +1,8 @@ from __future__ import annotations import hashlib +import json +from dataclasses import dataclass, field, replace from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -8,7 +10,8 @@ import pytest from lychd.config.settings import SettingsSnapshot -from lychd.system.schemas import QuadletContainer +from lychd.system.operator.process import ProcessResult +from lychd.system.schemas import QuadletContainer, QuadletPod from lychd.system.services.deployment import ( DeploymentInspectionService, DeploymentObservationError, @@ -20,6 +23,8 @@ if TYPE_CHECKING: from pytest_mock import MockerFixture + from lychd.system.schemas import QuadletBase + REVISION = "a" * 40 IMAGE = "b" * 64 @@ -305,6 +310,71 @@ def test_old_default_image_configuration_refuses(mocker: MockerFixture, tmp_path service._configuration(DeploymentRequest(REVISION)) +@pytest.mark.parametrize("change", [None, "image", "listen_port", "private_key_ref"]) +def test_tether_configuration_compares_public_fields_independently_of_source_binding( + mocker: MockerFixture, tmp_path: Path, change: str | None +) -> None: + from tomlkit import dumps + + from lychd.config.runes.loader import ConfigLoader + from lychd.domain.tether.models import TetherRune + from lychd.system.services.tether.models import TetherIntent + + runes_dir = tmp_path / "runes" + rune_path = TetherRune.anchor_dir(runes_dir) / "default.toml" + rune_path.parent.mkdir(parents=True) + public: dict[str, dict[str, str | int]] = { + "quadlet": {"image": "example/tether@sha256:" + "c" * 64}, + "offered": { + "id": "home", + "interface": "wg0", + "address": "10.55.0.1/24", + "listen_address": "192.0.2.10", + "listen_port": 51820, + "private_key_ref": "lychd-tether-home-private", + }, + } + rune_path.write_text(dumps(public)) + loaded = ConfigLoader(runes_dir=runes_dir).load_all([TetherRune])[0] + assert isinstance(loaded, TetherRune) + assert loaded.source_file == rune_path + intent = TetherIntent(rune=loaded) + if change == "image": + public["quadlet"]["image"] = "example/changed@sha256:" + "d" * 64 + elif change == "listen_port": + public["offered"]["listen_port"] = 51821 + elif change == "private_key_ref": + public["offered"]["private_key_ref"] = "lychd-tether-changed-private" + settings = SettingsSnapshot(payload="{}").materialize() + settings.extensions.builtins = ("tether",) + settings.server.web.image = DeploymentRequest(REVISION).image_ref + settings.server.port = intent.web_port + service: Any = object.__new__(DeploymentInspectionService) + original_files = mocker.patch.object(service, "_require_original_files") + service.receipt = mocker.Mock() + service.receipt.read_original_file.side_effect = [ + dumps(settings.model_dump(mode="json", exclude_none=True)).encode(), + dumps(public).encode(), + ] + mocker.patch("lychd.system.services.deployment.constants.PATH_RUNES_DIR", runes_dir) + mocker.patch("lychd.system.services.deployment.get_settings", return_value=settings) + mocker.patch("lychd.system.services.tether.models.load_intent", return_value=intent) + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=tmp_path / "intent.json", + tether_image_id="c" * 64, + ) + if change is None: + assert service._configuration(request).model_dump() == settings.model_dump() + else: + with pytest.raises(DeploymentObservationError, match="tether_original_intent_mismatch"): + service._configuration(request) + original_files.assert_called_once_with() + service.receipt.read_original_file.assert_any_call(rune_path, expected_mode=0o600, max_bytes=1024 * 1024) + + def test_one_active_owned_unit_with_other_inactive_refuses(mocker: MockerFixture, tmp_path: Path) -> None: service = world(mocker, tmp_path) original = service.observe_unit.side_effect @@ -549,3 +619,535 @@ def precheck() -> None: with pytest.raises(DeploymentObservationError, match="configuration_changed_during_observation"): service._configuration(DeploymentRequest(REVISION)) assert store.path.read_bytes() == original + + +def test_tether_protocol_requires_explicit_pinned_inputs() -> None: + with pytest.raises(ValueError, match="requires absolute intent"): + DeploymentRequest(REVISION, IMAGE, profile="tether-container-v1") + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=Path("/secure/intent.json"), + tether_image_id="c" * 64, + ) + document = DeploymentReport(request, state="initialized").as_dict() + assert document["version"] == 2 + assert document["profile"] == "tether-container-v1" + assert document["next_action"] == "bind" + assert DeploymentReport(DeploymentRequest(REVISION, IMAGE)).as_dict()["version"] == 1 + + +def test_public_owned_unit_observer_refuses_unowned_subset(mocker: MockerFixture) -> None: + service = DeploymentInspectionService.__new__(DeploymentInspectionService) + observe = mocker.patch.object(service, "observe_unit") + owned = OwnedBindings(receipt_present=True, runtime_units=("lychd-vessel.service",)) + with pytest.raises(DeploymentObservationError, match="unowned_unit_selection"): + service.observe_owned_units(owned, frozenset({"foreign.service"})) + observe.assert_not_called() + + +def test_public_owned_unit_observer_attests_only_exact_sources(mocker: MockerFixture) -> None: + service = DeploymentInspectionService.__new__(DeploymentInspectionService) + owned = OwnedBindings( + receipt_present=True, + generation="generation", + systemd_sources=(Path("/owned/lychd-one.service"), Path("/owned/lychd-two.service")), + runtime_units=("lychd-one.service", "lychd-two.service"), + ) + observe = mocker.patch.object(service, "observe_unit", return_value=unit(name="lychd-one.service")) + attest = mocker.patch.object(service, "_require_effective_bindings") + result = service.observe_owned_units(owned, frozenset({"lychd-one.service"})) + observe.assert_called_once_with("lychd-one.service") + assert set(result) == {"lychd-one.service"} + subset = attest.call_args.args[0] + assert subset.systemd_sources == (Path("/owned/lychd-one.service"),) + assert subset.runtime_units == ("lychd-one.service",) + + +def test_tether_profile_refuses_wrong_runtime_image_before_binding(mocker: MockerFixture, tmp_path: Path) -> None: + import json + + service = world(mocker, tmp_path, bound=False) + mocker.patch.object(service, "_roots", return_value=(tmp_path,)) + native = mocker.patch("lychd.system.services.tether.service.NativeTetherService").return_value + native.preparation_status.return_value = { + "state": "prepared", + "missing_secrets": [], + "known_paths": [], + "required_secrets": [], + "owned_secrets": [], + } + native.inspect.return_value = { + "schema": "lychd.tether.inspection", + "version": 1, + "state": "prepared", + "image": "localhost/lychd-tether:" + REVISION, + } + mocker.patch.object( + service, + "run", + return_value=json.dumps( + [ + { + "Id": "d" * 64, + "Labels": {"org.opencontainers.image.revision": REVISION}, + } + ] + ), + ) + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=Path("/secure/intent.json"), + tether_image_id="c" * 64, + ) + with pytest.raises(DeploymentObservationError, match="tether_image_identity_mismatch"): + service.inspect(request) + native.bind.assert_not_called() + native.activate.assert_not_called() + + +@pytest.mark.parametrize(("state", "expected"), [("absent", "initialized"), ("waiting_keys", "prepared_waiting_keys")]) +def test_original_tether_preparation_recovery_without_binding( + mocker: MockerFixture, tmp_path: Path, state: str, expected: str +) -> None: + service = world(mocker, tmp_path, bound=False) + mocker.patch.object(service, "_roots", return_value=(tmp_path,)) + native = mocker.patch("lychd.system.services.tether.service.NativeTetherService").return_value + native.preparation_status.return_value = { + "state": state, + "missing_secrets": ["client"] if state == "waiting_keys" else [], + "known_paths": [], + "required_secrets": [], + "owned_secrets": [], + } + mocker.patch( + "lychd.system.services.tether.models.load_intent", + return_value=SimpleNamespace(rune=SimpleNamespace(quadlet=SimpleNamespace(image="pinned"))), + ) + mocker.patch.object(service, "_require_tether_image") + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=Path("/secure/intent.json"), + tether_image_id="c" * 64, + ) + report = service.inspect(request) + assert report.state == expected + assert report.as_dict()["next_action"] == ("bind" if state == "absent" else "import-key") + native.inspect.assert_not_called() + native.bind_request.assert_not_called() + service.desired_bindings.assert_not_called() + + +def test_tether_preparation_does_not_hide_replaced_key(mocker: MockerFixture, tmp_path: Path) -> None: + from lychd.system.services.tether.models import TetherError + + service = world(mocker, tmp_path, bound=False) + mocker.patch.object(service, "_roots", return_value=(tmp_path,)) + native = mocker.patch("lychd.system.services.tether.service.NativeTetherService").return_value + native.preparation_status.side_effect = TetherError("refused") + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=Path("/secure/intent.json"), + tether_image_id="c" * 64, + ) + with pytest.raises(TetherError): + service.inspect(request) + native.inspect.assert_not_called() + + +@pytest.mark.parametrize("foreign", ["containers", "pods", "secrets", "foreign_sources"]) +def test_preparation_recovery_refuses_foreign_runtime(mocker: MockerFixture, tmp_path: Path, foreign: str) -> None: + service = world(mocker, tmp_path, bound=False) + mocker.patch.object(service, "_roots", return_value=(tmp_path,)) + native = mocker.patch("lychd.system.services.tether.service.NativeTetherService").return_value + native.preparation_status.return_value = { + "state": "absent", + "missing_secrets": [], + "known_paths": [], + "required_secrets": [], + "owned_secrets": [], + } + mocker.patch( + "lychd.system.services.tether.models.load_intent", + return_value=SimpleNamespace(rune=SimpleNamespace(quadlet=SimpleNamespace(image="pinned"))), + ) + mocker.patch.object(service, "_require_tether_image") + service._footprint.return_value[foreign] = ["lychd-foreign"] + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=Path("/secure/intent.json"), + tether_image_id="c" * 64, + ) + report = service.inspect(request) + assert report.state == "unknown" + assert report.reasons == ["runtime_material_before_tether_preparation"] + native.inspect.assert_not_called() + + +@pytest.mark.parametrize("owned", [True, False]) +def test_retired_receipted_tether_secret_is_owned_but_unknown_secret_refuses( + mocker: MockerFixture, tmp_path: Path, *, owned: bool +) -> None: + service = world(mocker, tmp_path, bound=True) + native = mocker.patch("lychd.system.services.tether.service.NativeTetherService").return_value + native.preparation_status.return_value = {"state": "prepared"} + native.inspect.return_value = { + "schema": "lychd.tether.inspection", + "version": 1, + "state": "bound", + "known_paths": [], + "required_secrets": [], + "owned_secrets": ["lychd-tether-retired"] if owned else [], + } + service._footprint.return_value["secrets"] = ["lychd-tether-retired"] + mocker.patch.object(service, "_require_tether_image") + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=Path("/secure/intent.json"), + tether_image_id="c" * 64, + ) + report = service.inspect(request) + assert report.state == ("bound_inactive" if owned else "unknown") + if not owned: + assert report.reasons == ["foreign_runtime_material"] + + +def test_tether_desired_bindings_use_effective_native_request(mocker: MockerFixture) -> None: + from lychd.system.services.bind import BindRequest + + settings = SettingsSnapshot(payload="{}").materialize() + settings.extensions.builtins = ("tether",) + native = mocker.patch("lychd.system.services.tether.service.NativeTetherService").return_value + request = BindRequest.compile( + manifests=(), + plain_units={"managed.service": "current topology"}, + core_secret_factories={}, + required_secret_names=(), + ) + native.bind_request.return_value = request + manifests, units = DeploymentInspectionService.desired_bindings(settings) + assert manifests == [] + assert units == {"managed.service": "current topology"} + native.bind_request.assert_called_once_with() + + +@dataclass +class InfraEngine: + pods: dict[str, list[object]] = field(default_factory=dict) + containers: dict[str, list[object]] = field(default_factory=dict) + invocations: dict[str, str] = field(default_factory=dict) + calls: list[tuple[str, ...]] = field(default_factory=list) + + def run(self, tool: str, *arguments: str) -> str: + self.calls.append((tool, *arguments)) + if tool == "systemctl": + assert arguments[:2] == ("--user", "show") + assert arguments[3:] == ("--property=InvocationID", "--value") + return self.invocations[arguments[2]] + assert tool == "podman" + kind, verb, identity, flag, projection = arguments + assert verb == "inspect" + assert flag == "--format" + assert "Env" not in projection + assert "Secret" not in projection + if identity == "lychd-vessel": + assert projection == "{{.Image}}" + return IMAGE + if kind == "pod": + assert projection == "[{{json .ID}},{{json .Name}},{{json .CreateInfra}},{{json .InfraContainerID}}]" + else: + assert projection == ( + "[{{json .ID}},{{json .Name}},{{json .Pod}},{{json .IsInfra}}," + "{{json .State.Running}},{{json .State.ConmonPid}}]" + ) + rows = self.pods if kind == "pod" else self.containers + row = next((row for name, row in rows.items() if identity in (name, row[0])), None) + if row is None: + message = "Synthetic runtime object unavailable" + raise DeploymentObservationError(message) + return json.dumps(row) + + +@dataclass +class InfraWorld: + service: DeploymentInspectionService + engine: InfraEngine + bindings: OwnedBindings + observed: dict[str, dict[str, str]] + footprint: dict[str, list[str]] + request: DeploymentRequest + + +def infra_sources(tmp_path: Path, manifests: list[QuadletBase]) -> tuple[tuple[Path, ...], dict[str, dict[str, str]]]: + sources: list[Path] = [] + observed: dict[str, dict[str, str]] = {} + for index, manifest in enumerate(manifests): + if isinstance(manifest, QuadletPod): + stem, suffix = manifest.pod_name, ".pod" + name = stem + "-pod.service" + else: + assert isinstance(manifest, QuadletContainer) + stem, suffix = manifest.container_name, ".container" + name = stem + ".service" + source = tmp_path / "quadlet" / (stem + suffix) + fragment = tmp_path / "runtime/systemd/generator" / name + for path in (source, fragment): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("exact") + path.chmod(0o600) + sources.append(source) + observed[name] = dict( + unit("active", sub="running", name=name), + FragmentPath=str(fragment), + SourcePath=str(source), + MainPID=str(100 + index), + ) + return tuple(sources), observed + + +def infra_world(mocker: MockerFixture, tmp_path: Path, *, tether: bool = False) -> InfraWorld: + service: DeploymentInspectionService = world(mocker, tmp_path) + engine = InfraEngine() + pod_names = ["lychd", *(["lychd-tether-" + "d" * 16] if tether else [])] + manifests: list[QuadletBase] = [ + QuadletContainer(description="Core", image="example/core", container_name="lychd-vessel", pod="lychd.pod"), + ] + manifests.extend(QuadletPod(pod_name=name) for name in pod_names) + if tether: + manifests.append( + QuadletContainer( + description="Tether", + image="example/tether", + container_name=pod_names[1] + "-wg", + pod=pod_names[1] + ".pod", + ) + ) + sources, observed = infra_sources(tmp_path, manifests) + reactor = tmp_path / "user/lychd-reactor.service" + observed[reactor.name] = dict(unit(name=reactor.name), FragmentPath=str(reactor)) + bindings = OwnedBindings( + receipt_present=True, + generation="exact", + systemd_sources=(reactor,), + quadlet_sources=sources, + runtime_units=tuple(sorted(observed)), + ) + for index, name in enumerate(pod_names): + pod_id, infra_id = str(index + 1) * 64, str(index + 3) * 64 + # The arbitrary observed name deliberately does not use an infra suffix. + infra_name = f"lychd-observed-{index}" + engine.pods[name] = [pod_id, name, True, infra_id] + unit_name = name + "-pod.service" + engine.containers[infra_id] = [infra_id, infra_name, pod_id, True, True, int(observed[unit_name]["MainPID"])] + engine.invocations[unit_name] = "a" * 32 + containers = [ + *(row.container_name for row in manifests if isinstance(row, QuadletContainer)), + *(str(row[1]) for row in engine.containers.values()), + ] + footprint = { + "foreign_sources": [], + "containers": containers, + "pods": pod_names, + "secrets": [], + "running_containers": list(containers), + } + mocker.patch.object(service.scribe, "inspect_owned_bindings", return_value=bindings) + mocker.patch.object(service, "_unit_names", return_value=set(observed)) + + def observe(name: str) -> dict[str, str]: + return dict(observed[name]) + + mocker.patch.object(service, "observe_unit", side_effect=observe) + mocker.patch.object(service, "_footprint", return_value=footprint) + mocker.patch.object(service, "desired_bindings", return_value=(manifests, {reactor.name: "exact"})) + mocker.patch.object(service, "run", side_effect=engine.run) + request = DeploymentRequest(REVISION, IMAGE) + if tether: + native = mocker.patch("lychd.system.services.tether.service.NativeTetherService").return_value + native.preparation_status.return_value = {"state": "prepared"} + native.inspect.return_value = {"schema": "lychd.tether.inspection", "version": 1, "state": "active"} + mocker.patch.object(service, "_require_tether_image") + request = DeploymentRequest( + REVISION, + IMAGE, + profile="tether-container-v1", + intent_path=tmp_path / "intent.json", + tether_image_id="c" * 64, + ) + return InfraWorld(service, engine, bindings, observed, footprint, request) + + +@pytest.mark.parametrize("tether", [False, True]) +def test_deployment_admits_only_proved_implicit_pod_infrastructure( + mocker: MockerFixture, tmp_path: Path, *, tether: bool +) -> None: + current = infra_world(mocker, tmp_path, tether=tether) + effects = mocker.patch.object(current.service.scribe, "reconcile_all") + for _ in range(2): + report = current.service.inspect(current.request) + assert report.state == "running" + assert report.as_dict()["next_action"] == "observe" + assert report.facts["footprint"] == current.footprint + assert len([call for call in current.engine.calls if call[0:2] == ("podman", "pod")]) == 4 * (1 + tether) + effects.assert_not_called() + + +@pytest.mark.parametrize( + ("kind", "index", "value"), + [ + ("pod", 0, "f" * 64), + ("pod", 2, False), + ("pod", 2, "true"), + ("pod", 3, "short"), + ("pod", 3, "f" * 64), + ("container", 0, "f" * 64), + ("container", 2, "f" * 64), + ("container", 3, False), + ("container", 3, "true"), + ("container", 3, None), + ("container", 4, False), + ("container", 5, 999), + ("container", 5, True), + ("container", 5, "101"), + ], +) +def test_deployment_refuses_wrong_infrastructure_identity_or_manager( + mocker: MockerFixture, tmp_path: Path, kind: str, index: int, value: object +) -> None: + current = infra_world(mocker, tmp_path) + rows = current.engine.pods if kind == "pod" else current.engine.containers + next(iter(rows.values()))[index] = value + report = current.service.inspect(current.request) + assert report.state == "unknown" + assert report.reasons == ["foreign_runtime_material"] + + +@pytest.mark.parametrize("category", ["containers", "pods", "secrets"]) +def test_infrastructure_exception_never_hides_foreign_material( + mocker: MockerFixture, tmp_path: Path, category: str +) -> None: + current = infra_world(mocker, tmp_path) + current.footprint[category].append("lychd-foreign-infra") + report = current.service.inspect(current.request) + assert report.state == "unknown" + assert report.reasons == ["foreign_runtime_material"] + + +@pytest.mark.parametrize("payload", ["not-json synthetic-sensitive-input", "{}", "[]", "[null]", "[]\n[]"]) +def test_infrastructure_malformed_projection_is_a_content_free_refusal( + mocker: MockerFixture, tmp_path: Path, payload: str +) -> None: + current = infra_world(mocker, tmp_path) + + def malformed(tool: str, *arguments: str) -> str: + if tool == "podman" and arguments[0] == "pod": + return payload + return current.engine.run(tool, *arguments) + + mocker.patch.object(current.service, "run", side_effect=malformed) + report = current.service.inspect(current.request) + assert report.state == "unknown" + assert report.reasons == ["foreign_runtime_material"] + assert "synthetic-sensitive-input" not in json.dumps(report.as_dict()) + + +@pytest.mark.parametrize("drift", ["pod", "container", "invocation", "generation"]) +def test_infrastructure_observation_refuses_replacement_between_reads( + mocker: MockerFixture, tmp_path: Path, drift: str +) -> None: + current = infra_world(mocker, tmp_path) + reads = 0 + + def changing(tool: str, *arguments: str) -> str: + nonlocal reads + result = current.engine.run(tool, *arguments) + if tool == "podman" and arguments[0] == "container" and arguments[2] != "lychd-vessel": + reads += 1 + if reads == 1: + if drift == "pod": + current.engine.pods["lychd"][3] = "f" * 64 + elif drift == "container": + next(iter(current.engine.containers.values()))[1] = "lychd-replaced" + elif drift == "invocation": + current.engine.invocations["lychd-pod.service"] = "b" * 32 + else: + mocker.patch.object( + current.service.scribe, + "inspect_owned_bindings", + return_value=replace(current.bindings, generation="changed"), + ) + return result + + mocker.patch.object(current.service, "run", side_effect=changing) + report = current.service.inspect(current.request) + assert report.state == "unknown" + assert report.reasons == ["foreign_runtime_material"] + + +@pytest.mark.parametrize("authority", ["receipt", "source", "inactive", "shadow", "generation"]) +def test_infrastructure_requires_complete_current_binding_authority( + mocker: MockerFixture, tmp_path: Path, authority: str +) -> None: + current = infra_world(mocker, tmp_path) + if authority in {"receipt", "source"}: + bindings = ( + replace(current.bindings, receipt_present=False) + if authority == "receipt" + else replace( + current.bindings, + quadlet_sources=tuple(path for path in current.bindings.quadlet_sources if path.suffix != ".pod"), + ) + ) + mocker.patch.object(current.service.scribe, "inspect_owned_bindings", return_value=bindings) + elif authority == "inactive": + current.observed["lychd-pod.service"].update(ActiveState="inactive", SubState="dead", MainPID="0") + elif authority == "shadow": + current.observed["lychd-pod.service"]["DropInPaths"] = "/foreign/override.conf" + else: + mocker.patch.object(current.service.scribe, "plan_reconcile_all", return_value=SimpleNamespace(mutates=True)) + if authority == "shadow": + with pytest.raises(DeploymentObservationError, match="effective_binding_override"): + current.service.inspect(current.request) + else: + report = current.service.inspect(current.request) + assert report.state == "unknown" + assert report.as_dict()["next_action"] == "refuse" + assert not current.engine.calls or all(call[0] == "systemctl" for call in current.engine.calls) + + +def test_deployment_observations_pin_local_podman_despite_ambient_remote( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CONTAINER_HOST", "ssh://foreign.invalid/run/podman.sock") + monkeypatch.setenv("CONTAINER_CONNECTION", "foreign-engine") + service = DeploymentInspectionService.__new__(DeploymentInspectionService) + runner = mocker.Mock() + service.runner = runner + runner.run.return_value = ProcessResult(("/tools/podman",), 0, "observed") + + def trusted(name: str) -> str: + return "/tools/" + name + + mocker.patch("lychd.system.services.deployment.trusted_host_tool", side_effect=trusted) + for arguments in ( + ("ps", "--all"), + ("pod", "inspect", "1" * 64), + ("container", "inspect", "2" * 64), + ("secret", "ls"), + ("image", "inspect", "pinned"), + ): + assert service.run("podman", *arguments) == "observed" + runner.run.assert_called_with(("/tools/podman", "--remote=false", *arguments), timeout_s=20.0) + service.run("systemctl", "--user", "show", "lychd-pod.service") + runner.run.assert_called_with(("/tools/systemctl", "--user", "show", "lychd-pod.service"), timeout_s=20.0) diff --git a/tests/unit/system/services/test_intercom_http.py b/tests/unit/system/services/test_intercom_http.py index d0a4ffd8..a4074719 100644 --- a/tests/unit/system/services/test_intercom_http.py +++ b/tests/unit/system/services/test_intercom_http.py @@ -439,3 +439,41 @@ async def opened(*args: object, **kwargs: object) -> _OctetStream: assert calls == 1 assert stream.sent == [prepared.wire] assert stream.closed + + +@pytest.mark.asyncio +async def test_explicit_tether_without_catalog_never_uses_direct( + keys: tuple[_Key, _Key], monkeypatch: pytest.MonkeyPatch +) -> None: + from lychd.domain.scout.transport import FetchCode, FetchError + from lychd.domain.tether.models import TetherSelection + + def missing_catalog(**kwargs: object) -> None: + del kwargs + raise FetchError(FetchCode.CONFIG) + + monkeypatch.setattr(intercom_http.TetherConnector, "from_catalog", missing_catalog) + contacted: list[bool] = [] + + async def direct(*args: object, **kwargs: object) -> None: + del args, kwargs + contacted.append(True) + pytest.fail("Explicit Tether attempted Direct") + + monkeypatch.setattr(intercom_http, "open_pinned_tls", direct) + endpoint = IntercomEndpoint( + "10.44.0.1", + "peer.test", + 8443, + "/a2a", + Path("/explicit-ca.pem"), + tether=TetherSelection(remote_lich_id="remote", attachment_id="road", generation=1, service_id="api"), + ) + with pytest.raises(IntercomError): + await BoundedIntercomHTTP().exchange( + endpoint, + _prepared(keys[0]), + gate=_Gate(), + deadline=time.monotonic() + 10, + ) + assert contacted == [] diff --git a/tests/unit/system/services/test_secrets.py b/tests/unit/system/services/test_secrets.py index f4b2ce84..9bbb9f9f 100644 --- a/tests/unit/system/services/test_secrets.py +++ b/tests/unit/system/services/test_secrets.py @@ -27,7 +27,7 @@ def test_secret_store_exists_uses_bounded_podman_probe( ) -> None: runner = _runner(mocker) runner.run.return_value = ProcessResult( - argv=("/usr/bin/podman", "secret", "exists", "alpha"), + argv=("/usr/bin/podman", "--remote=false", "secret", "exists", "alpha"), returncode=0, ) @@ -38,7 +38,7 @@ def test_secret_store_exists_uses_bounded_podman_probe( assert store.exists("alpha") is True runner.run.assert_called_once_with( - ("/usr/bin/podman", "secret", "exists", "alpha"), + ("/usr/bin/podman", "--remote=false", "secret", "exists", "alpha"), timeout_s=5.0, ) @@ -82,7 +82,7 @@ def test_secret_store_ensure_present_creates_only_when_missing( ) -> None: runner = _runner(mocker) runner.run_with_input.return_value = ProcessResult( - argv=("/usr/bin/podman", "secret", "create", "alpha", "-"), + argv=("/usr/bin/podman", "--remote=false", "secret", "create", "alpha", "-"), returncode=0, ) store = PodmanSecretStore( @@ -95,7 +95,7 @@ def test_secret_store_ensure_present_creates_only_when_missing( assert created is True runner.run_with_input.assert_called_once_with( - ("/usr/bin/podman", "secret", "create", "alpha", "-"), + ("/usr/bin/podman", "--remote=false", "secret", "create", "alpha", "-"), timeout_s=30.0, input_text="value", ) @@ -107,7 +107,7 @@ def test_secret_store_ensure_present_preserves_a_raced_secret( """Create-if-absent never replaces an operator secret that wins the race.""" runner = _runner(mocker) runner.run_with_input.return_value = ProcessResult( - argv=("/usr/bin/podman", "secret", "create", "alpha", "-"), + argv=("/usr/bin/podman", "--remote=false", "secret", "create", "alpha", "-"), returncode=125, stderr="secret already exists", ) @@ -130,7 +130,7 @@ def test_secret_store_create_uses_bounded_non_echoed_stdin( ) -> None: runner = _runner(mocker) runner.run_with_input.return_value = ProcessResult( - argv=("/usr/bin/podman", "secret", "create"), + argv=("/usr/bin/podman", "--remote=false", "secret", "create"), returncode=125, stderr="boom", ) @@ -145,6 +145,7 @@ def test_secret_store_create_uses_bounded_non_echoed_stdin( runner.run_with_input.assert_called_once_with( ( "/usr/bin/podman", + "--remote=false", "secret", "create", "--replace", @@ -154,3 +155,40 @@ def test_secret_store_create_uses_bounded_non_echoed_stdin( timeout_s=30.0, input_text="value", ) + + +def test_secret_store_ignores_ambient_remote_selection_and_keeps_values_on_stdin( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + + monkeypatch.setenv("CONTAINER_HOST", "ssh://wrong-engine.invalid/run/podman.sock") + monkeypatch.setenv("CONTAINER_CONNECTION", "untrusted-connection") + value = "private-test-value" + completed = mocker.Mock( + returncode=0, + stdout=json.dumps([{"Spec": {"Name": "alpha"}, "SecretData": value}]), + stderr="", + ) + process = mocker.patch("lychd.system.operator.process.subprocess.run", return_value=completed) + store = PodmanSecretStore("/usr/bin/podman") + assert store.read("alpha").get_secret_value() == value + assert store.exists("alpha") is True + store.create("alpha", value) + process.side_effect = (mocker.Mock(returncode=1, stdout="", stderr=""), completed) + assert store.ensure_present("alpha", value) is True + commands = [call.args[0] for call in process.call_args_list] + assert commands == [ + ("/usr/bin/podman", "--remote=false", "secret", "inspect", "--showsecret", "alpha"), + ("/usr/bin/podman", "--remote=false", "secret", "exists", "alpha"), + ("/usr/bin/podman", "--remote=false", "secret", "create", "--replace", "alpha", "-"), + ("/usr/bin/podman", "--remote=false", "secret", "exists", "alpha"), + ("/usr/bin/podman", "--remote=false", "secret", "create", "alpha", "-"), + ] + for call in process.call_args_list: + assert value not in call.args[0] + if "create" in call.args[0]: + assert call.kwargs["input"] == value + else: + assert "input" not in call.kwargs + assert "env" not in call.kwargs diff --git a/tests/unit/system/services/test_tether_broker_tls.py b/tests/unit/system/services/test_tether_broker_tls.py new file mode 100644 index 00000000..4d14396e --- /dev/null +++ b/tests/unit/system/services/test_tether_broker_tls.py @@ -0,0 +1,136 @@ +"""Controlled broker TLS assembly and cleanup, not live peer qualification.""" + +from __future__ import annotations + +import asyncio +import socket +import ssl +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import cast +from unittest.mock import Mock + +import pytest + +from lychd.domain.scout.transport import FetchCode, FetchError, NetworkHandoff, NetworkTarget +from lychd.system.services import scout_socket + +# This focused assembly test constructs the internal carrier without a broker socket. +from lychd.system.services.tether.connector import _BrokerStream as BrokerStream # pyright: ignore[reportPrivateUsage] + +TARGET = NetworkTarget("10.44.0.1", 8443, "peer.test", "origin") + + +class Gate: + def handoff(self, handoff: NetworkHandoff, action: Callable[[], int]) -> int: + del handoff + return action() + + +@dataclass +class TLSHarness: + """Substitute the raw socket and OpenSSL while retaining real TLS assembly.""" + + raw: Mock + context: Mock + tls: Mock + ca_file: Path + + +@pytest.fixture +def tls_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TLSHarness: + ca_file = tmp_path / "ca.pem" + ca_file.write_text("fixture trust roots") + context = Mock(spec=ssl.SSLContext) + context.options = 0 + tls = Mock(spec=ssl.SSLObject) + tls.selected_alpn_protocol.return_value = "http/1.1" + context.wrap_bio.return_value = tls + monkeypatch.setattr(scout_socket.ssl, "SSLContext", Mock(return_value=context)) + return TLSHarness(Mock(spec=socket.socket), context, tls, ca_file) + + +def carrier(raw: Mock, *, target: NetworkTarget = TARGET, lifetime: float = 5) -> BrokerStream: + return BrokerStream(cast("socket.socket", raw), target, Gate(), time.monotonic() + lifetime) + + +@pytest.mark.asyncio +async def test_broker_tls_retains_application_hostname_and_exact_trust(tls_harness: TLSHarness) -> None: + h = tls_harness + stream = await carrier(h.raw).wrap_tls(ca_file=h.ca_file) + assert isinstance(stream, scout_socket.PinnedTlsStream) + h.context.load_verify_locations.assert_called_once_with(cafile=str(h.ca_file)) + h.context.load_default_certs.assert_not_called() + h.context.load_cert_chain.assert_not_called() + h.context.set_alpn_protocols.assert_called_once_with(["http/1.1"]) + assert h.context.verify_mode == ssl.CERT_REQUIRED + assert h.context.check_hostname is True + assert h.context.hostname_checks_common_name is False + assert h.context.minimum_version == ssl.TLSVersion.TLSv1_2 + assert h.context.post_handshake_auth is False + assert h.context.wrap_bio.call_args.kwargs["server_hostname"] == TARGET.hostname + h.tls.do_handshake.assert_called_once() + h.raw.connect_ex.assert_not_called() + h.raw.close.assert_not_called() + stream.close() + h.raw.close.assert_called_once() + h.tls.unwrap.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("target", "lifetime", "code"), + [ + (NetworkTarget("010.44.0.1", 8443, "peer.test", "origin"), 5, FetchCode.DESTINATION), + (NetworkTarget("10.44.0.1", 8443, "peer.test", "resolver"), 5, FetchCode.CONFIG), + (TARGET, -1, FetchCode.TIMEOUT), + ], +) +async def test_broker_tls_invalid_target_or_deadline_closes_before_tls( + tls_harness: TLSHarness, target: NetworkTarget, lifetime: float, code: FetchCode +) -> None: + h = tls_harness + with pytest.raises(FetchError) as caught: + await carrier(h.raw, target=target, lifetime=lifetime).wrap_tls(ca_file=h.ca_file) + assert caught.value.code is code + h.context.wrap_bio.assert_not_called() + h.raw.send.assert_not_called() + h.raw.close.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("stage", "failure", "expected", "code"), + [ + ("context", OSError("private trust failure"), FetchError, FetchCode.CONFIG), + ("handshake", ssl.SSLCertVerificationError("private identity failure"), FetchError, FetchCode.TLS), + ("handshake", RuntimeError("unexpected failure"), RuntimeError, None), + ("handshake", asyncio.CancelledError(), asyncio.CancelledError, None), + ], +) +async def test_broker_tls_failure_closes_carrier_without_application_or_reconnect( + tls_harness: TLSHarness, + stage: str, + failure: BaseException, + expected: type[BaseException], + code: FetchCode | None, +) -> None: + h = tls_harness + if stage == "context": + h.context.load_verify_locations.side_effect = failure + else: + h.tls.do_handshake.side_effect = failure + with pytest.raises(expected) as caught: + await carrier(h.raw).wrap_tls(ca_file=h.ca_file) + if code is not None: + assert isinstance(caught.value, FetchError) + assert caught.value.code is code + assert "private" not in str(caught.value) + else: + assert caught.value is failure + h.raw.send.assert_not_called() + h.raw.connect_ex.assert_not_called() + h.raw.close.assert_called_once() + h.tls.unwrap.assert_not_called() diff --git a/tests/unit/system/services/test_tether_consumer.py b/tests/unit/system/services/test_tether_consumer.py new file mode 100644 index 00000000..84d2ba63 --- /dev/null +++ b/tests/unit/system/services/test_tether_consumer.py @@ -0,0 +1,413 @@ +"""Explicit Tether selection and failure-closed consumer tests.""" + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import asyncio +import json +import os +import stat +import time +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace + +import httpx2 +import pytest + +from lychd.domain.scout.transport import FetchError, NetworkHandoff, NetworkTarget +from lychd.domain.tether.models import TetherRoute, TetherSelection +from lychd.system.services.tether.connector import TetherConnector +from lychd.system.services.tether.transport import TetherHTTPTransport + + +class _Gate: + def handoff(self, handoff: NetworkHandoff, action: Callable[[], int]) -> int: + del handoff + return action() + + +def _route(**updates: object) -> TetherRoute: + return TetherRoute.model_validate( + { + "selection": {"remote_lich_id": "remote", "attachment_id": "road", "generation": 1, "service_id": "api"}, + "installation_epoch": "a" * 32, + "policy_digest": "b" * 64, + "socket_path": "/run/lychd-tether/" + "c" * 16 + "/" + "d" * 32 + ".sock", + "hostname": "peer.test", + "address": "10.44.0.1", + "port": 8443, + "application_identity_ref": "peer-1", + "allowed_callers": ["intercom"], + "unit_name": "lychd-tether-road.service", + **updates, + } + ) + + +def test_catalog_never_selects_overlapping_address() -> None: + route = _route() + other = _route( + selection={"remote_lich_id": "other", "attachment_id": "other", "generation": 1, "service_id": "api"} + ) + connector = TetherConnector(catalog=lambda: (route, other), installation_epoch="a" * 32, caller="intercom") + assert connector.resolve(route.selection) == route + missing = TetherSelection(remote_lich_id="missing", attachment_id="road", generation=1, service_id="api") + with pytest.raises(FetchError): + connector.resolve(missing) + + +@pytest.mark.parametrize(("epoch", "caller"), [("e" * 32, "intercom"), ("a" * 32, "unadmitted")]) +def test_catalog_requires_current_epoch_and_caller(epoch: str, caller: str) -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch=epoch, caller=caller) + with pytest.raises(FetchError): + connector.resolve(route.selection) + + +def test_catalog_revocation_and_policy_change_fence_existing_route() -> None: + route = _route() + rows = [route] + connector = TetherConnector(catalog=lambda: tuple(rows), installation_epoch="a" * 32, caller="intercom") + connector.current(route) + rows[:] = [_route(policy_digest="f" * 64)] + with pytest.raises(FetchError): + connector.current(route) + rows.clear() + with pytest.raises(FetchError): + connector.current(route) + + +@pytest.mark.asyncio +async def test_application_identity_refused_before_socket() -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + with pytest.raises(FetchError): + await connector.open_tls( + route.selection, + target=NetworkTarget(route.address, route.port, route.hostname, "origin"), + application_identity_ref="wrong-peer", + gate=_Gate(), + deadline=time.monotonic() + 5, + ca_file=Path("/unused"), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("url", ["https://other.test:8443/", "http://peer.test:8443/", "https://peer.test:443/"]) +async def test_http_origin_cannot_redirect_selected_service(url: str) -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + transport = TetherHTTPTransport( + connector=connector, selection=route.selection, application_identity_ref="peer-1", ca_file=Path("/unused") + ) + with pytest.raises(httpx2.TransportError): + await transport.handle_async_request(httpx2.Request("GET", url)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "chunks", + [ + (b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",), + (b"HTTP/1.1 200 OK\r\nContent-Le", b"ngth: 2\r\n\r\nok"), + ], +) +async def test_http_uses_selected_stream_and_closes_it( + chunks: tuple[bytes, ...], monkeypatch: pytest.MonkeyPatch +) -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + sent: list[bytes] = [] + closed: list[bool] = [] + received = iter(chunks) + + class Stream: + async def send(self, data: bytes) -> None: + sent.append(data) + + async def receive(self) -> bytes: + return next(received) + + def close(self) -> None: + closed.append(True) + + async def open_tls(selection: TetherSelection, **kwargs: object) -> Stream: + assert selection == route.selection + assert kwargs["application_identity_ref"] == "peer-1" + assert kwargs["target"] == NetworkTarget(route.address, route.port, route.hostname, "origin") + return Stream() + + monkeypatch.setattr(connector, "open_tls", open_tls) + transport = TetherHTTPTransport( + connector=connector, selection=route.selection, application_identity_ref="peer-1", ca_file=Path("/unused") + ) + response = await transport.handle_async_request(httpx2.Request("GET", "https://peer.test:8443/health")) + assert response.content == b"ok" + assert sent[0].startswith(b"GET /health HTTP/1.1\r\n") + assert closed == [True] + await transport.aclose() + with pytest.raises(httpx2.TransportError): + await transport.handle_async_request(httpx2.Request("GET", "https://peer.test:8443/health")) + + +def test_intercom_endpoint_old_records_direct_explicit_routes_closed() -> None: + from lychd.domain.codex.peer import PeerAuthorityError + from lychd.system.services.intercom import _endpoint + + record = {"address": "10.44.0.1", "hostname": "peer.test", "port": 8443, "path": "/a2a", "ca_file": "/ca.pem"} + endpoint, _ = _endpoint(record) + assert endpoint.tether is None + endpoint, _ = _endpoint({**record, "tether": _route().selection.model_dump()}) + assert endpoint.tether == _route().selection + for malformed in (None, {}, {**_route().selection.model_dump(), "socket_path": "/arbitrary"}): + with pytest.raises(PeerAuthorityError): + _endpoint({**record, "tether": malformed}) + + +def _catalog(path: Path, route: TetherRoute, *, epoch: str = "a" * 32) -> None: + path.write_text( + json.dumps( + { + "schema": "lychd.tether.catalog", + "version": 1, + "installation_epoch": epoch, + "routes": [route.model_dump(mode="json")], + } + ) + ) + path.chmod(0o600) + + +def test_catalog_epoch_is_pinned_and_truncation_is_closed(tmp_path: Path) -> None: + path = tmp_path / "catalog.json" + route = _route() + _catalog(path, route) + connector = TetherConnector.from_catalog(caller="intercom", catalog_path=path) + assert connector.resolve(route.selection) == route + _catalog(path, _route(installation_epoch="e" * 32), epoch="e" * 32) + with pytest.raises(FetchError): + connector.resolve(route.selection) + path.write_bytes(b'{"schema":') + with pytest.raises(FetchError): + connector.resolve(route.selection) + + +def test_catalog_refuses_symlink_and_loose_permissions(tmp_path: Path) -> None: + path = tmp_path / "catalog.json" + _catalog(path, _route()) + alias = tmp_path / "alias.json" + alias.symlink_to(path) + with pytest.raises(FetchError): + TetherConnector.from_catalog(caller="intercom", catalog_path=alias) + path.chmod(0o644) + with pytest.raises(FetchError): + TetherConnector.from_catalog(caller="intercom", catalog_path=path) + + +@pytest.mark.asyncio +async def test_http_cancellation_closes_active_stream(monkeypatch: pytest.MonkeyPatch) -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + receiving = asyncio.Event() + closed: list[bool] = [] + + class Stream: + async def send(self, data: bytes) -> None: + del data + + async def receive(self) -> bytes: + receiving.set() + await asyncio.Future() + return b"" + + def close(self) -> None: + closed.append(True) + + async def open_tls(*args: object, **kwargs: object) -> Stream: + del args, kwargs + return Stream() + + monkeypatch.setattr(connector, "open_tls", open_tls) + transport = TetherHTTPTransport( + connector=connector, selection=route.selection, application_identity_ref="peer-1", ca_file=Path("/unused") + ) + task = asyncio.create_task(transport.handle_async_request(httpx2.Request("GET", "https://peer.test:8443/"))) + await receiving.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert closed == [True] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "wire", + [ + b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort", + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nContent-Length: 2\r\n\r\nok", + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nextra", + b"HTTP/1.1 302 Found\r\nLocation: https://peer.test:8443/other\r\nContent-Length: 0\r\n\r\n", + ], +) +async def test_http_refuses_ambiguous_truncated_and_redirect_response( + wire: bytes, monkeypatch: pytest.MonkeyPatch +) -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + chunks = iter((wire, b"")) + closed: list[bool] = [] + + class Stream: + async def send(self, data: bytes) -> None: + del data + + async def receive(self) -> bytes: + return next(chunks) + + def close(self) -> None: + closed.append(True) + + async def open_tls(*args: object, **kwargs: object) -> Stream: + del args, kwargs + return Stream() + + monkeypatch.setattr(connector, "open_tls", open_tls) + transport = TetherHTTPTransport( + connector=connector, selection=route.selection, application_identity_ref="peer-1", ca_file=Path("/unused") + ) + with pytest.raises(httpx2.TransportError): + await transport.handle_async_request(httpx2.Request("GET", "https://peer.test:8443/")) + assert closed == [True] + + +@pytest.mark.parametrize( + ("level", "mode", "owner_delta", "kind"), + [ + ("root", 0o755, 0, stat.S_IFDIR), + ("network", 0o770, 0, stat.S_IFDIR), + ("socket", 0o660, 0, stat.S_IFSOCK), + ("socket", 0o600, 1, stat.S_IFSOCK), + ("network", 0o700, 0, stat.S_IFLNK), + ("socket", 0o600, 0, stat.S_IFREG), + ], +) +def test_broker_socket_requires_exact_owned_private_metadata( + level: str, mode: int, owner_delta: int, kind: int, monkeypatch: pytest.MonkeyPatch +) -> None: + path = Path(_route().socket_path) + uid = os.getuid() + paths = {"root": path.parent.parent, "network": path.parent, "socket": path} + metadata = { + paths["root"]: SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=uid), + paths["network"]: SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=uid), + paths["socket"]: SimpleNamespace(st_mode=stat.S_IFSOCK | 0o600, st_uid=uid), + } + + def statvfs(_path: Path) -> SimpleNamespace: + return SimpleNamespace(f_flag=os.ST_RDONLY) + + def lstat(observed: Path) -> SimpleNamespace: + return metadata[observed] + + monkeypatch.setattr(os, "statvfs", statvfs) + monkeypatch.setattr(Path, "lstat", lstat) + TetherConnector._check_socket(path) + metadata[paths[level]] = SimpleNamespace(st_mode=kind | mode, st_uid=uid + owner_delta) + with pytest.raises(FetchError): + TetherConnector._check_socket(path) + + +@pytest.mark.asyncio +async def test_shared_http_factory_sends_only_identity_encoding(monkeypatch: pytest.MonkeyPatch) -> None: + from lychd.lib.http import create_async_http_client + + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + sent: list[bytes] = [] + + class Stream: + async def send(self, data: bytes) -> None: + sent.append(data) + + async def receive(self) -> bytes: + return b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok" + + def close(self) -> None: + pass + + async def open_tls(*args: object, **kwargs: object) -> Stream: + del args, kwargs + return Stream() + + monkeypatch.setattr(connector, "open_tls", open_tls) + transport = TetherHTTPTransport( + connector=connector, + selection=route.selection, + application_identity_ref="peer-1", + ca_file=Path("/unused"), + ) + async with create_async_http_client(timeout=5, transport=transport) as client: + response = await client.get("https://peer.test:8443/health") + assert response.content == b"ok" + headers = sent[0].split(b"\r\n\r\n", 1)[0].lower().split(b"\r\n") + assert [line for line in headers if line.startswith(b"accept-encoding:")] == [b"accept-encoding: identity"] + + +def test_broker_socket_refuses_a_writable_consumer_mount(monkeypatch: pytest.MonkeyPatch) -> None: + def statvfs(_path: Path) -> SimpleNamespace: + return SimpleNamespace(f_flag=0) + + monkeypatch.setattr(os, "statvfs", statvfs) + with pytest.raises(FetchError): + TetherConnector._check_socket(Path(_route().socket_path)) + + +def test_catalog_duplicate_selection_remains_refused(tmp_path: Path) -> None: + path = tmp_path / "catalog.json" + _catalog(path, _route()) + document = json.loads(path.read_text()) + document["routes"].append(document["routes"][0]) + path.write_text(json.dumps(document)) + with pytest.raises(FetchError): + TetherConnector.from_catalog(caller="intercom", catalog_path=path) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout_value", [[], {"read": True}, {"read": "5"}, {"read": float("nan")}, {"read": 0}]) +async def test_http_malformed_timeout_is_refused_before_open( + timeout_value: object, monkeypatch: pytest.MonkeyPatch +) -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + + async def forbidden(*args: object, **kwargs: object) -> None: + del args, kwargs + pytest.fail("Malformed timeout must refuse before connection") + + monkeypatch.setattr(connector, "open_tls", forbidden) + transport = TetherHTTPTransport( + connector=connector, selection=route.selection, application_identity_ref="peer-1", ca_file=Path("/unused") + ) + request = httpx2.Request("GET", "https://peer.test:8443/", extensions={"timeout": timeout_value}) + with pytest.raises(httpx2.TransportError): + await transport.handle_async_request(request) + + +@pytest.mark.asyncio +async def test_http_sync_only_body_is_refused_before_open(monkeypatch: pytest.MonkeyPatch) -> None: + route = _route() + connector = TetherConnector(catalog=lambda: (route,), installation_epoch="a" * 32, caller="intercom") + + async def forbidden(*args: object, **kwargs: object) -> None: + del args, kwargs + pytest.fail("Sync request body must refuse before connection") + + monkeypatch.setattr(connector, "open_tls", forbidden) + transport = TetherHTTPTransport( + connector=connector, selection=route.selection, application_identity_ref="peer-1", ca_file=Path("/unused") + ) + request = httpx2.Request("POST", "https://peer.test:8443/", stream=httpx2.SyncByteStream()) + with pytest.raises(httpx2.TransportError): + await transport.handle_async_request(request) diff --git a/tests/unit/system/services/test_tether_custody.py b/tests/unit/system/services/test_tether_custody.py new file mode 100644 index 00000000..5edd05ac --- /dev/null +++ b/tests/unit/system/services/test_tether_custody.py @@ -0,0 +1,361 @@ +"""Real filesystem custody operations with an explicit persistent-identity test observer. + +The production ioctl/subprocess observer has its own focused tests. These tests +exercise no-follow paths, actual modes, replacements and crash residue inside OCI. +""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from lychd.system.path_identity import PathIdentityError, PersistentPathIdentity +from lychd.system.services.tether import custody +from lychd.system.services.tether.models import TetherError, TetherIntent, digest + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +@pytest.mark.parametrize("count", [1, 4]) +def test_record_snapshot_scans_twice_and_expires( + store: custody.TetherCustody, mocker: MockerFixture, count: int +) -> None: + store.initialize() + for index in range(count): + store.write(f"key-{index}.json", {"index": index, "nested": {"enabled": True}}) + scans = mocker.spy(store, "_attest") + with store.records_snapshot() as view: + assert view.names == tuple(f"key-{index}.json" for index in range(count)) + for index, name in enumerate(view.names): + assert view.read(name) == {"index": index, "nested": {"enabled": True}} + assert scans.call_count == 1 + detached = view.read("key-0.json") + assert detached is not None + detached["nested"] = {} + assert view.read("key-0.json") == {"index": 0, "nested": {"enabled": True}} + assert scans.call_count == 2 + with pytest.raises(TetherError, match="ownership"): + view.read("key-0.json") + with pytest.raises(TetherError, match="ownership"): + _ = view.names + + +@pytest.mark.parametrize("tamper", ["foreign", "replacement", "receipt", "receipt_identity", "mode", "directory"]) +def test_record_snapshot_exit_rejects_drift_after_read(store: custody.TetherCustody, tamper: str) -> None: + store.initialize() + store.write("state.json", {"generation": 1}) + store.directory("runtime") + views: list[custody.CustodyRecords] = [] + + def observe_with_tamper() -> None: + with store.records_snapshot() as view: + views.append(view) + assert view.read("state.json") == {"generation": 1} + path = store.root / "state.json" + if tamper == "foreign": + (store.root / "foreign.json").write_text("{}") + elif tamper == "replacement": + replacement = store.root.parent / "replacement.json" + replacement.write_bytes(path.read_bytes()) + replacement.chmod(0o600) + replacement.replace(path) + elif tamper == "receipt": + receipt_path = store.root / ".custody.json" + receipt = json.loads(receipt_path.read_bytes()) + receipt["revision"] += 1 + receipt_path.write_bytes(custody.canonical_bytes(receipt)) + elif tamper == "receipt_identity": + receipt_path = store.root / ".custody.json" + replacement = store.root.parent / "replacement-receipt.json" + replacement.write_bytes(receipt_path.read_bytes()) + replacement.chmod(0o600) + replacement.replace(receipt_path) + elif tamper == "mode": + path.chmod(0o644) + else: + directory = store.root / "runtime" + retired = store.root.parent / "retired-runtime" + directory.rename(retired) + directory.symlink_to(retired, target_is_directory=True) + + with pytest.raises(TetherError, match="ownership"): + observe_with_tamper() + with pytest.raises(TetherError, match="ownership"): + views[0].read("state.json") + + +def test_record_snapshot_bounds_reads_and_bytes(store: custody.TetherCustody, monkeypatch: pytest.MonkeyPatch) -> None: + store.initialize() + store.write("state.json", {"generation": 1}) + monkeypatch.setattr(custody, "_MAX_PATHS", 1) + with store.records_snapshot() as view: + assert view.read("state.json") == {"generation": 1} + with pytest.raises(TetherError, match="ownership"): + view.read("state.json") + monkeypatch.setattr(custody, "_MAX_SNAPSHOT_BYTES", 1) + with store.records_snapshot() as view, pytest.raises(TetherError, match="ownership"): + view.read("state.json") + + +def test_record_snapshot_exception_expires_view(store: custody.TetherCustody, mocker: MockerFixture) -> None: + store.initialize() + scans = mocker.spy(store, "_attest") + views: list[custody.CustodyRecords] = [] + + def interrupt() -> None: + with store.records_snapshot() as view: + views.append(view) + message = "interrupted" + raise RuntimeError(message) + + with pytest.raises(RuntimeError, match="interrupted"): + interrupt() + assert scans.call_count == 2 + with pytest.raises(TetherError, match="ownership"): + _ = views[0].names + + +@pytest.fixture +def identity_observer(monkeypatch: pytest.MonkeyPatch) -> None: + def observe(descriptor: int) -> PersistentPathIdentity: + return PersistentPathIdentity( + filesystem="ext4", + filesystem_uuid="12345678-1234-5678-9234-567812345678", + inode=os.fstat(descriptor).st_ino, + generation=17, + ) + + monkeypatch.setattr(custody, "observe_identity_fd", observe) + + +@pytest.fixture +def store(tmp_path: Path, identity_observer: None) -> custody.TetherCustody: + parent = tmp_path / "crypt" + parent.mkdir(mode=0o755) + parent.chmod(0o755) + return custody.TetherCustody(parent / "tether") + + +def test_initialize_preserves_real_lifecycle_755_parent(store: custody.TetherCustody) -> None: + before = store.root.parent.stat() + assert store.initialize() + assert not store.initialize() + after = store.root.parent.stat() + assert stat.S_IMODE(after.st_mode) == 0o755 + assert (before.st_dev, before.st_ino) == (after.st_dev, after.st_ino) + assert stat.S_IMODE(store.root.stat().st_mode) == 0o700 + assert stat.S_IMODE((store.root / ".custody.json").stat().st_mode) == 0o600 + store.attest() + + +def test_existing_unreceipted_root_is_never_adopted_or_chmodded(store: custody.TetherCustody) -> None: + store.root.mkdir(mode=0o755) + store.root.chmod(0o755) + with pytest.raises(TetherError, match="ownership"): + store.initialize() + assert stat.S_IMODE(store.root.stat().st_mode) == 0o755 + assert list(store.root.iterdir()) == [] + + +def test_private_record_repeat_is_noop_and_changed_write_is_owned(store: custody.TetherCustody) -> None: + store.initialize() + assert store.read("state.json") is None + assert store.write("state.json", {"generation": 1}) + original = (store.root / "state.json").stat().st_ino + assert not store.write("state.json", {"generation": 1}) + assert (store.root / "state.json").stat().st_ino == original + assert store.write("state.json", {"generation": 2}) + assert store.read("state.json") == {"generation": 2} + assert set(store.known_paths()) == {store.root, store.root / ".custody.json", store.root / "state.json"} + + +def test_immutable_record_repetition_is_allowed_but_replacement_is_not(store: custody.TetherCustody) -> None: + store.initialize() + assert store.write("key-abc.json", {"purpose": "wg-private"}, immutable=True) + assert not store.write("key-abc.json", {"purpose": "wg-private"}, immutable=True) + with pytest.raises(TetherError, match="ownership"): + store.write("key-abc.json", {"purpose": "ssh-host"}) + assert store.read("key-abc.json") == {"purpose": "wg-private"} + + +def test_public_role_projection_modes_and_exact_inventory(store: custody.TetherCustody) -> None: + store.initialize() + role = store.directory("runtime/lychd-tether-wg", public=True) + assert stat.S_IMODE(role.stat().st_mode) == 0o755 + assert store.publish(Path("runtime/lychd-tether-wg/config.json"), {"version": 1}, public=True) + assert stat.S_IMODE((role / "config.json").stat().st_mode) == 0o444 + assert not store.publish(Path("runtime/lychd-tether-wg/config.json"), {"version": 1}, public=True) + assert store.publish(Path("runtime/lychd-tether-wg/config.json"), {"version": 2}, public=True) + assert role / "config.json" in store.known_paths() + with pytest.raises(TetherError, match="ownership"): + store.publish(Path("runtime/unknown/config.json"), {}, public=True) + + +@pytest.mark.parametrize("name", ["../outside.json", "/outside.json", "runtime/../state.json", ".custody.json"]) +def test_traversal_and_receipt_names_are_refused(store: custody.TetherCustody, name: str) -> None: + store.initialize() + with pytest.raises(TetherError, match="ownership"): + store.write(name, {}) + + +def test_foreign_file_prevents_all_adoption(store: custody.TetherCustody) -> None: + store.initialize() + unknown = store.root / "foreign.json" + unknown.write_text("{}") + unknown.chmod(0o600) + with pytest.raises(TetherError, match="ownership"): + store.write("foreign.json", {}) + with pytest.raises(TetherError, match="ownership"): + store.known_paths() + + +def test_same_bytes_with_different_inode_are_refused(store: custody.TetherCustody, tmp_path: Path) -> None: + store.initialize() + store.write("state.json", {"generation": 1}) + replacement = tmp_path / "replacement.json" + replacement.write_bytes((store.root / "state.json").read_bytes()) + replacement.chmod(0o600) + replacement.replace(store.root / "state.json") + with pytest.raises(TetherError, match="ownership"): + store.read("state.json") + + +def test_hardlinked_record_is_refused(store: custody.TetherCustody, tmp_path: Path) -> None: + store.initialize() + store.write("state.json", {}) + os.link(store.root / "state.json", tmp_path / "alias") + with pytest.raises(TetherError, match="ownership"): + store.attest() + + +def test_symlinked_owned_directory_is_refused(store: custody.TetherCustody, tmp_path: Path) -> None: + store.initialize() + directory = store.directory("runtime") + directory.rename(tmp_path / "old-runtime") + directory.symlink_to(tmp_path / "old-runtime", target_is_directory=True) + with pytest.raises(TetherError, match="ownership"): + store.publish(Path("runtime/config.json"), {}) + + +def test_parent_permission_drift_is_refused(store: custody.TetherCustody) -> None: + store.initialize() + store.root.parent.chmod(0o775) + with pytest.raises(TetherError, match="ownership"): + store.attest() + assert stat.S_IMODE(store.root.parent.stat().st_mode) == 0o775 + + +def test_interrupted_record_receipt_commit_is_not_adopted( + store: custody.TetherCustody, monkeypatch: pytest.MonkeyPatch +) -> None: + store.initialize() + store.write("state.json", {"generation": 1}) + + def fail(*_arguments: object) -> None: + message = "injected commit failure" + raise OSError(message) + + monkeypatch.setattr(store, "_commit", fail) + with pytest.raises(TetherError, match="ownership"): + store.write("state.json", {"generation": 2}) + with pytest.raises(TetherError, match="ownership"): + store.attest() + + +def test_unsupported_persistent_identity_refuses_without_adoption( + store: custody.TetherCustody, monkeypatch: pytest.MonkeyPatch +) -> None: + def fail(_descriptor: int) -> PersistentPathIdentity: + message = "unsupported filesystem" + raise PathIdentityError(message) + + monkeypatch.setattr(custody, "observe_identity_fd", fail) + with pytest.raises(TetherError, match="ownership"): + store.initialize() + assert store.root.is_dir() + assert not (store.root / ".custody.json").exists() + + +def intent_bytes() -> bytes: + return json.dumps( + { + "schema": "lychd.tether.intent", + "version": 1, + "rune": {"quadlet": {"image": "example/tether@sha256:" + "a" * 64}}, + "offered_peers": [], + "attachments": [], + "web_port": 7134, + } + ).encode() + + +def test_load_intent_uses_private_file_without_persistent_filesystem_requirement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "intent.json" + path.write_bytes(intent_bytes()) + path.chmod(0o600) + + def fail(_descriptor: int) -> PersistentPathIdentity: + message = "ordinary input must not invoke persistent observer" + raise AssertionError(message) + + monkeypatch.setattr(custody, "observe_identity_fd", fail) + intent = custody.load_intent(path) + assert isinstance(intent, TetherIntent) + assert intent.web_port == 7134 + assert digest(intent) == digest(intent.model_dump(mode="json", by_alias=True)) + path.chmod(0o644) + with pytest.raises(TetherError, match="intent was refused"): + custody.load_intent(path) + + +@pytest.mark.parametrize("payload", [b'{"version":1,"version":1}', b'{"secret":"DO_NOT_RENDER",', b'{"number":NaN}']) +def test_malformed_intent_is_bounded_and_redacted(tmp_path: Path, payload: bytes) -> None: + path = tmp_path / "intent.json" + path.write_bytes(payload) + path.chmod(0o600) + with pytest.raises(TetherError, match="intent was refused") as caught: + custody.load_intent(path) + assert "DO_NOT_RENDER" not in str(caught.value) + + +def test_intent_rejects_boolean_protocol_and_private_key_fields(tmp_path: Path) -> None: + path = tmp_path / "intent.json" + value = json.loads(intent_bytes()) + value["version"] = True + path.write_text(json.dumps(value)) + path.chmod(0o600) + with pytest.raises(TetherError, match="intent was refused"): + custody.load_intent(path) + value = json.loads(intent_bytes()) + value["private_key"] = "DO_NOT_RENDER" + path.write_text(json.dumps(value)) + with pytest.raises(TetherError, match="intent was refused"): + custody.load_intent(path) + + +def test_load_intent_rejects_cross_object_admission_before_initialization(tmp_path: Path) -> None: + path = tmp_path / "intent.json" + value = json.loads(intent_bytes()) + value["attachments"] = [{"attachment_id": "undeclared", "generation": 1, "enabled": True}] + path.write_text(json.dumps(value)) + path.chmod(0o600) + with pytest.raises(TetherError, match="intent was refused"): + custody.load_intent(path) + + +def test_load_intent_refuses_a_moving_image_before_initialization(tmp_path: Path) -> None: + path = tmp_path / "intent.json" + value = json.loads(intent_bytes()) + value["rune"]["quadlet"]["image"] = "example/tether:latest" + path.write_text(json.dumps(value)) + path.chmod(0o600) + with pytest.raises(TetherError, match="intent was refused"): + custody.load_intent(path) diff --git a/tests/unit/system/services/test_tether_host.py b/tests/unit/system/services/test_tether_host.py new file mode 100644 index 00000000..1605fee1 --- /dev/null +++ b/tests/unit/system/services/test_tether_host.py @@ -0,0 +1,934 @@ +"""Native host effect ports reject foreign incarnations and prove subtree closure.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from lychd.system.operator.process import ProcessResult +from lychd.system.services.tether import host +from lychd.system.services.tether.models import TetherError + +if TYPE_CHECKING: + from lychd.domain.tether.compiler import RuntimeConfig + +IDENTITY = "a" * 64 +OTHER = "b" * 64 +IMAGE = "c" * 64 +POD = "d" * 64 +NAME = "lychd-tether-1234567890abcdef-ssh-123456789abc" +UNIT_GROUP = f"/user.slice/user-{os.getuid()}.slice/user@{os.getuid()}.service/app.slice/{NAME}.service" +INVOCATION = "f" * 32 +DIGEST = "sha256:" + "e" * 64 + + +def retained() -> dict[str, Any]: + return { + "container_id": IDENTITY, + "container_name": NAME, + "unit_name": NAME + ".service", + "pod_id": POD, + "image_id": IMAGE, + "netns": 20, + "invocation_id": INVOCATION, + "unit_cgroup": UNIT_GROUP, + "cgroup": f"/user.slice/user-{os.getuid()}.slice/user@{os.getuid()}.service/app.slice/libpod-{IDENTITY}.scope", + } + + +def container() -> dict[str, Any]: + return { + "Id": IDENTITY, + "Name": NAME, + "Image": IMAGE, + "ImageDigest": DIGEST, + "Pod": POD, + "State": {"Running": True, "Pid": 1234}, + } + + +class Runner: + def __init__(self) -> None: + """Observe argv without executing any host process.""" + self.calls: list[tuple[str, ...]] = [] + self.outcomes: dict[tuple[str, ...], ProcessResult] = {} + + def run(self, argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: + assert timeout_s > 0 + self.calls.append(argv) + command = argv[1:] + if argv[0] == "/tools/podman": + assert command[0] == "--remote=false" + command = command[1:] + return self.outcomes.get(command, ProcessResult(argv, 0)) + + def run_with_input( + self, + argv: tuple[str, ...], + *, + timeout_s: float, + input_text: str, + ) -> ProcessResult: + del argv, timeout_s, input_text + pytest.fail("Host control must not send secret input") + + +@pytest.fixture +def port(monkeypatch: pytest.MonkeyPatch) -> tuple[host.TetherHost, Runner]: + runner = Runner() + + def fake_trusted_host_tool(name: str) -> str: + return "/tools/" + name + + monkeypatch.setattr(host, "trusted_host_tool", fake_trusted_host_tool) + service = host.TetherHost(runner) + + def fake_owned_unit(_name: str) -> dict[str, str]: + return { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "MainPID": "0", + "ControlPID": "0", + "Job": "0", + } + + monkeypatch.setattr( + service, + "owned_unit", + fake_owned_unit, + ) + + def fake_unit_incarnation(name: str) -> dict[str, str]: + return { + "InvocationID": INVOCATION, + "ControlGroup": UNIT_GROUP if name == NAME + ".service" else UNIT_GROUP.rsplit("/", 1)[0] + "/" + name, + } + + monkeypatch.setattr( + service, + "unit_incarnation", + fake_unit_incarnation, + ) + + def fake_unit_closed(_name: str) -> bool: + return False + + monkeypatch.setattr(service, "_unit_closed", fake_unit_closed) + return service, runner + + +@pytest.mark.parametrize( + ("variable", "value"), + [("CONTAINER_HOST", "ssh://foreign.invalid/run/podman.sock"), ("CONTAINER_CONNECTION", "foreign-engine")], +) +def test_host_observations_force_local_engine_despite_ambient_connections( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, variable: str, value: str +) -> None: + service, runner = port + monkeypatch.setenv(variable, value) + runner.outcomes[("container", "inspect", IDENTITY)] = ProcessResult((), 0, json.dumps([container()])) + runner.outcomes[("pod", "exists", POD)] = ProcessResult((), 1) + assert service.row("container", IDENTITY) == container() + assert service.exists("container", IDENTITY) + assert not service.exists("pod", POD) + service.command("podman", "exec", IDENTITY, "true") + service.command("systemctl", "--user", "show", NAME + ".service") + service.command("ip", "-j", "-4", "address", "show") + assert runner.calls == [ + ("/tools/podman", "--remote=false", "container", "inspect", IDENTITY), + ("/tools/podman", "--remote=false", "container", "exists", IDENTITY), + ("/tools/podman", "--remote=false", "pod", "exists", POD), + ("/tools/podman", "--remote=false", "exec", IDENTITY, "true"), + ("/tools/systemctl", "--user", "show", NAME + ".service"), + ("/tools/ip", "-j", "-4", "address", "show"), + ] + + +def test_absence_distinguishes_uncertainty( + port: tuple[host.TetherHost, Runner], +) -> None: + service, runner = port + command = ("container", "exists", IDENTITY) + runner.outcomes[command] = ProcessResult((), 1) + assert not service.exists("container", IDENTITY) + runner.outcomes[command] = ProcessResult((), 125, stderr="private diagnostic") + with pytest.raises(TetherError, match="uncertain"): + service.exists("container", IDENTITY) + + +def test_command_refuses_failure_without_reflecting_private_diagnostics( + port: tuple[host.TetherHost, Runner], +) -> None: + service, runner = port + runner.outcomes[("container", "inspect", IDENTITY)] = ProcessResult((), 125, "private stdout", "private stderr") + with pytest.raises(TetherError, match="suppressed") as failure: + service.command("podman", "container", "inspect", IDENTITY) + assert "private stdout" not in str(failure.value) + assert "private stderr" not in str(failure.value) + + +def test_stop_refuses_named_replacement_when_retained_id_is_absent( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + + def fake_exists(_kind: str, identity: str) -> bool: + return identity == NAME + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_row(_kind: str, _identity: str) -> dict[str, Any]: + return {**container(), "Id": OTHER} + + monkeypatch.setattr(service, "row", fake_row) + + def fake_cgroup_empty(_group: str) -> bool: + return True + + monkeypatch.setattr(service, "cgroup_empty", fake_cgroup_empty) + with pytest.raises(TetherError): + service.stop(retained()) + assert not runner.calls + + +def test_stop_refuses_foreign_unit_before_any_effect( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return True + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_row(_kind: str, _identity: str) -> dict[str, Any]: + return container() + + monkeypatch.setattr(service, "row", fake_row) + + def fake_cgroup_empty(_group: str) -> bool: + return True + + monkeypatch.setattr(service, "cgroup_empty", fake_cgroup_empty) + record = retained() + record["unit_name"] = "lychd-vessel.service" + with pytest.raises(TetherError): + service.stop(record) + assert not runner.calls + + +@pytest.mark.parametrize("field", ["Id", "Image", "Pod", "Name"]) +def test_stop_refuses_changed_incarnation_before_effect( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + service, runner = port + row = container() + row[field] = OTHER + + def fake_exists(_kind: str, _identity: str) -> bool: + return True + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_row(_kind: str, _identity: str) -> dict[str, Any]: + return row + + monkeypatch.setattr(service, "row", fake_row) + with pytest.raises(TetherError): + service.stop(retained()) + assert not runner.calls + + +def test_stop_refuses_name_rebound_to_other_container( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return True + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_row(_kind: str, identity: str) -> dict[str, Any]: + return container() if identity == IDENTITY else {**container(), "Id": OTHER} + + monkeypatch.setattr( + service, + "row", + fake_row, + ) + with pytest.raises(TetherError): + service.stop(retained()) + assert not runner.calls + + +def test_repeated_stop_of_absent_incarnation_cancels_restart( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return False + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_cgroup_empty(_group: str) -> bool: + return True + + monkeypatch.setattr(service, "cgroup_empty", fake_cgroup_empty) + service.stop(retained()) + service.stop(retained()) + assert runner.calls == [("/tools/systemctl", "--user", "stop", NAME + ".service")] * 2 + + +def test_stop_waits_for_entire_retained_cgroup( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + observations: list[str] = [] + + def fake_exists(_kind: str, _identity: str) -> bool: + return not runner.calls + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_row(_kind: str, _identity: str) -> dict[str, Any]: + return container() + + monkeypatch.setattr(service, "row", fake_row) + + def empty(group: str) -> bool: + observations.append(group) + return len(observations) > 1 + + monkeypatch.setattr(service, "cgroup_empty", empty) + + def fake_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr(host.time, "sleep", fake_sleep) + service.stop(retained()) + assert observations == [retained()["cgroup"], retained()["cgroup"]] + assert runner.calls == [("/tools/systemctl", "--user", "stop", NAME + ".service")] + + +def test_stop_does_not_claim_closure_while_descendants_survive( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return True + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_row(_kind: str, _identity: str) -> dict[str, Any]: + return container() + + monkeypatch.setattr(service, "row", fake_row) + + def fake_cgroup_empty(_group: str) -> bool: + return False + + monkeypatch.setattr(service, "cgroup_empty", fake_cgroup_empty) + times = iter((0.0, 11.0)) + monkeypatch.setattr(host.time, "monotonic", lambda: next(times)) + with pytest.raises(TetherError, match="closure"): + service.stop(retained()) + assert runner.calls == [("/tools/systemctl", "--user", "stop", NAME + ".service")] + + +def test_cgroup_observer_includes_descendant_tasks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + group = f"/user.slice/user-{os.getuid()}.slice/user@{os.getuid()}.service/app.slice/libpod-{IDENTITY}.scope" + root = tmp_path / group.lstrip("/") + child = root / "nested" + child.mkdir(parents=True) + (root / "cgroup.procs").write_text("") + (child / "cgroup.procs").write_text("1234\n") + original = Path + + def fake_path(value: str) -> Path: + return tmp_path if str(value) == "/sys/fs/cgroup" else original(value) + + monkeypatch.setattr( + host, + "Path", + fake_path, + ) + assert not host.TetherHost.cgroup_empty(group) + (child / "cgroup.procs").write_text("") + assert host.TetherHost.cgroup_empty(group) + + +def test_capture_rejects_host_or_core_namespace( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, _runner = port + config = cast("RuntimeConfig", SimpleNamespace(name=NAME)) + image = {"id": IMAGE, "digest": DIGEST} + rows = { + ("container", NAME): container(), + ("pod", "lychd-tether-1234567890abcdef"): { + "Id": POD, + "Name": "lychd-tether-1234567890abcdef", + }, + ("container", IDENTITY): container(), + ("container", "lychd-vessel"): {"State": {"Pid": 4321}}, + } + + def fake_row(kind: str, identity: str) -> dict[str, Any]: + return rows[kind, identity] + + monkeypatch.setattr(service, "row", fake_row) + + def fake_exists(_kind: str, _identity: str) -> bool: + return True + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_process_cgroup(_pid: int) -> str: + return retained()["cgroup"] + + monkeypatch.setattr(service, "process_cgroup", fake_process_cgroup) + + def fake_unit_incarnation(_name: str) -> dict[str, str]: + return {"InvocationID": INVOCATION, "ControlGroup": UNIT_GROUP} + + monkeypatch.setattr( + service, + "unit_incarnation", + fake_unit_incarnation, + ) + namespaces = { + "/proc/self/ns/net": 10, + "/proc/1234/ns/net": 10, + "/proc/4321/ns/net": 30, + } + + def fake_path(*values: str) -> Path | SimpleNamespace: + return ( + SimpleNamespace(stat=lambda: SimpleNamespace(st_ino=namespaces[values[0]])) + if len(values) == 1 and values[0] in namespaces + else Path(*values) + ) + + monkeypatch.setattr( + host, + "Path", + fake_path, + ) + with pytest.raises(TetherError, match="host network"): + service.capture(config, image) + namespaces["/proc/1234/ns/net"] = 30 + with pytest.raises(TetherError, match="Core network"): + service.capture(config, image) + namespaces["/proc/1234/ns/net"] = 20 + result = service.capture(config, image) + assert result == retained() + + +def test_observe_requires_exact_generation_and_policy( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, _runner = port + payload = { + "role": "imported-relay", + "network_id": "remote", + "installation_epoch": "f" * 32, + "generation": 3, + "policy_digest": "e" * 64, + } + config = cast("RuntimeConfig", SimpleNamespace(name=NAME, payload=payload)) + response = {**payload, "enabled": True} + + def fake_command(*_args: str) -> str: + return json.dumps(response) + + monkeypatch.setattr(service, "command", fake_command) + assert service.observe(config) == response + response["generation"] = 2 + with pytest.raises(TetherError, match="acknowledged"): + service.observe(config) + response["generation"] = 3 + response["policy_digest"] = "d" * 64 + with pytest.raises(TetherError, match="acknowledged"): + service.observe(config) + + +@pytest.mark.parametrize("suffix", [f"libpod-{IDENTITY}.scope", f"{NAME}.service/libpod-payload-{IDENTITY}"]) +def test_exact_payload_subtree_includes_nested_children(suffix: str) -> None: + prefix = UNIT_GROUP.rsplit("/", 1)[0] + expected = prefix + "/" + suffix + assert host._container_group(expected + "/nested", IDENTITY, UNIT_GROUP) == expected # pyright: ignore[reportPrivateUsage] - exact subtree boundary probe + + +@pytest.mark.parametrize( + "group", + [ + "/system.slice/foreign.service", + UNIT_GROUP + "/libpod-payload-" + OTHER, + UNIT_GROUP, + ], +) +def test_foreign_or_broad_cgroup_refused(group: str) -> None: + with pytest.raises(TetherError): + host._container_group(group, IDENTITY, UNIT_GROUP) # pyright: ignore[reportPrivateUsage] - direct negative boundary probe + + +def test_stop_refuses_new_unit_invocation( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return False + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_cgroup_empty(_group: str) -> bool: + return True + + monkeypatch.setattr(service, "cgroup_empty", fake_cgroup_empty) + + def fake_unit_incarnation(_name: str) -> dict[str, str]: + return {"InvocationID": "1" * 32, "ControlGroup": UNIT_GROUP} + + monkeypatch.setattr( + service, + "unit_incarnation", + fake_unit_incarnation, + ) + with pytest.raises(TetherError): + service.stop(retained()) + assert not runner.calls + + +@pytest.mark.parametrize("extra", ["InvocationID=duplicate\n", "Unknown=field\n", "Id=foreign.service\n"]) +def test_unit_incarnation_refuses_ambiguous_fields( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, extra: str +) -> None: + service, _runner = port + content = f"Id={NAME}.service\nInvocationID={INVOCATION}\nControlGroup={UNIT_GROUP}\n" + extra + + def fake_command(*_args: str) -> str: + return content + + monkeypatch.setattr(service, "command", fake_command) + with pytest.raises(TetherError): + host.TetherHost.unit_incarnation(service, NAME + ".service") + + +def test_unit_incarnation_retains_exact_values( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, _runner = port + content = f"Id={NAME}.service\nInvocationID={INVOCATION}\nControlGroup={UNIT_GROUP}\n" + + def fake_command(*_args: str) -> str: + return content + + monkeypatch.setattr(service, "command", fake_command) + assert host.TetherHost.unit_incarnation(service, NAME + ".service") == { + "InvocationID": INVOCATION, + "ControlGroup": UNIT_GROUP, + } + + +def pod_retained() -> dict[str, Any]: + name = "lychd-tether-1234567890abcdef" + return { + "pod_id": POD, + "pod_name": name, + "unit_name": name + "-pod.service", + "infra_id": IDENTITY, + "infra_cgroup": retained()["cgroup"], + "netns": 20, + "invocation_id": INVOCATION, + "unit_cgroup": UNIT_GROUP.rsplit("/", 1)[0] + "/" + name + "-pod.service", + } + + +def test_pod_stop_refuses_foreign_members( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + record = pod_retained() + + def fake_exists(_kind: str, _identity: str) -> bool: + return True + + monkeypatch.setattr(service, "exists", fake_exists) + row = { + "Id": POD, + "Name": record["pod_name"], + "InfraContainerID": IDENTITY, + "Containers": [{"Id": IDENTITY}, {"Id": OTHER}], + } + + def fake_row(_kind: str, _identity: str) -> dict[str, Any]: + return row + + monkeypatch.setattr(service, "row", fake_row) + with pytest.raises(TetherError): + service.stop_pod(record) + assert not runner.calls + + +def test_pod_stop_refuses_name_replacement( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + record = pod_retained() + + def fake_exists(_kind: str, identity: str) -> bool: + return identity == record["pod_name"] + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_row(_kind: str, _identity: str) -> dict[str, Any]: + return {"Id": OTHER} + + monkeypatch.setattr(service, "row", fake_row) + with pytest.raises(TetherError): + service.stop_pod(record) + assert not runner.calls + + +@pytest.mark.parametrize("pod", [False, True]) +def test_repeated_stop_after_binding_retired_has_no_effect( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, *, pod: bool +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return False + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_cgroup_empty(_group: str) -> bool: + return True + + monkeypatch.setattr(service, "cgroup_empty", fake_cgroup_empty) + + def fake_unit_closed(_name: str) -> bool: + return True + + monkeypatch.setattr(service, "_unit_closed", fake_unit_closed) + + def fake_owned_unit(_name: str) -> dict[str, str]: + return pytest.fail("Retired binding must not need ownership") + + monkeypatch.setattr(service, "owned_unit", fake_owned_unit) + if pod: + assert service.pod_closed(pod_retained()) + service.stop_pod(pod_retained()) + else: + assert service.closed(retained()) + service.stop(retained()) + assert not runner.calls + + +@pytest.mark.parametrize( + ("load", "active", "job", "code", "expected"), + [ + ("not-found", "inactive", "0", 4, True), + ("loaded", "inactive", "0", 0, True), + ("loaded", "active", "0", 0, False), + ("loaded", "inactive", "42", 0, False), + ], +) +def test_closed_unit_observation_requires_no_pending_restart( + port: tuple[host.TetherHost, Runner], load: str, active: str, job: str, code: int, *, expected: bool +) -> None: + service, runner = port + props = ("Id", "LoadState", "ActiveState", "SubState", "MainPID", "ControlPID", "Job") + argv = ("--user", "show", NAME + ".service", *("--property=" + key for key in props)) + values = (NAME + ".service", load, active, "dead", "0", "0", job) + runner.outcomes[argv] = ProcessResult( + (), code, "".join(key + "=" + value + "\n" for key, value in zip(props, values, strict=True)) + ) + assert host.TetherHost._unit_closed(service, NAME + ".service") is expected # pyright: ignore[reportPrivateUsage] - bypass fixture replacement to exercise observation + + +@pytest.mark.parametrize("body_exists", [True, False]) +def test_unretained_stop_never_adopts_a_body( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, *, body_exists: bool +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return body_exists + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_cgroup_empty(_group: str) -> bool: + return True + + monkeypatch.setattr(service, "cgroup_empty", fake_cgroup_empty) + if body_exists: + with pytest.raises(TetherError): + service.stop_unretained(NAME) + assert not runner.calls + else: + service.stop_unretained(NAME) + assert runner.calls == [("/tools/systemctl", "--user", "stop", NAME + ".service")] + + +def test_unretained_stop_quiescent_retired_unit_has_no_effect( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, runner = port + + def fake_exists(_kind: str, _identity: str) -> bool: + return False + + monkeypatch.setattr(service, "exists", fake_exists) + + def fake_unit_closed(_name: str) -> bool: + return True + + monkeypatch.setattr(service, "_unit_closed", fake_unit_closed) + + def fake_unit_incarnation(_name: str) -> dict[str, str]: + return pytest.fail("No authority required for no effect") + + monkeypatch.setattr(service, "unit_incarnation", fake_unit_incarnation) + service.stop_unretained(NAME) + assert not runner.calls + + +@pytest.mark.parametrize("fault", ["none", "foreign-image", "foreign-cgroup", "changed-invocation", "inactive"]) +def test_core_requires_image_and_current_owned_payload( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, fault: str +) -> None: + service, runner = port + core_group = UNIT_GROUP.rsplit("/", 1)[0] + "/lychd-vessel.service" + row = {**container(), "Name": "lychd-vessel", "Image": OTHER if fault == "foreign-image" else IMAGE} + + def fake_row(kind: str, _identity: str) -> dict[str, Any]: + return {"Id": IMAGE} if kind == "image" else row + + monkeypatch.setattr(service, "row", fake_row) + + def fake_owned_unit(_name: str) -> dict[str, str]: + return {"LoadState": "loaded", "ActiveState": "inactive" if fault == "inactive" else "active"} + + monkeypatch.setattr( + service, + "owned_unit", + fake_owned_unit, + ) + calls: list[str] = [] + + def incarnation(_name: str) -> dict[str, str]: + calls.append(_name) + return { + "InvocationID": "1" * 32 if fault == "changed-invocation" and len(calls) > 1 else INVOCATION, + "ControlGroup": core_group, + } + + monkeypatch.setattr(service, "unit_incarnation", incarnation) + + def fake_process_cgroup(_pid: int) -> str: + return (UNIT_GROUP if fault == "foreign-cgroup" else core_group) + "/libpod-payload-" + IDENTITY + + monkeypatch.setattr( + service, + "process_cgroup", + fake_process_cgroup, + ) + if fault == "none": + service.require_core("example/core:admitted") + else: + with pytest.raises(TetherError): + service.require_core("example/core:admitted") + assert not runner.calls + + +class FailedRoleObservation(Runner): + """Model terminal manager observations separately from body and cgroup absence.""" + + def __init__(self, group: str = "") -> None: + super().__init__() + self.fields: dict[str, str] = { + "Id": NAME + ".service", + "LoadState": "loaded", + "ActiveState": "failed", + "SubState": "failed", + "MainPID": "0", + "ControlPID": "0", + "Job": "", + "NRestarts": "0", + "InvocationID": INVOCATION, + "ControlGroup": group, + } + self.owned_calls = 0 + self.groups: list[str] = [] + self.bodies: list[str] = [] + self.present: set[str] = set() + self.nonempty: set[str] = set() + self.drift: dict[str, str] = {} + self.foreign_at = 0 + self.appear = "" + + def run(self, argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: + assert timeout_s > 0 + assert argv[:4] == ("/tools/systemctl", "--user", "show", NAME + ".service") + self.calls.append(argv) + properties = tuple(argument.removeprefix("--property=") for argument in argv[4:]) + assert set(properties) == set(self.fields) + return ProcessResult(argv, 0, "".join(key + "=" + self.fields[key] + "\n" for key in properties)) + + def owned_unit(self, name: str) -> dict[str, str]: + assert name == NAME + ".service" + self.owned_calls += 1 + if self.owned_calls == self.foreign_at: + message = "Effective owned unit replaced" + raise TetherError(message) + if self.owned_calls == 1 and self.appear: + self.present.add(self.appear) + if self.owned_calls == 2: + self.fields.update(self.drift) + return dict(self.fields) + + def exists(self, kind: str, identity: str) -> bool: + assert kind == "container" + assert identity in {IDENTITY, NAME} + self.bodies.append(identity) + return identity in self.present + + def cgroup_empty(self, group: str) -> bool: + self.groups.append(group) + return group not in self.nonempty + + +def failed_role_port( + service: host.TetherHost, monkeypatch: pytest.MonkeyPatch, group: str = "" +) -> FailedRoleObservation: + observed = FailedRoleObservation(group) + service.runner = observed + monkeypatch.setattr(service, "owned_unit", observed.owned_unit) + monkeypatch.setattr(service, "exists", observed.exists) + monkeypatch.setattr(service, "cgroup_empty", observed.cgroup_empty) + return observed + + +@pytest.mark.parametrize("group", ["", UNIT_GROUP]) +@pytest.mark.parametrize("job", ["", "0"]) +def test_retained_failed_role_with_vanished_body_closes_without_effect( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, group: str, job: str +) -> None: + service, _runner = port + observed = failed_role_port(service, monkeypatch, group) + observed.fields["Job"] = job + service.stop(retained()) + service.stop(retained()) + assert observed.owned_calls == 4 + assert len(observed.calls) == 4 + assert set(observed.groups) == {retained()["cgroup"], UNIT_GROUP} + assert observed.bodies.count(IDENTITY) == 4 + assert observed.bodies.count(NAME) == 4 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("Id", "foreign.service"), + ("LoadState", "not-found"), + ("MainPID", "5"), + ("ControlPID", "5"), + ("Job", "42"), + ("NRestarts", "1"), + ("NRestarts", ""), + ("InvocationID", "e" * 32), + ("InvocationID", ""), + ("ControlGroup", UNIT_GROUP + "/foreign"), + ], +) +def test_failed_role_closure_refuses_unproved_terminal_state( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, field: str, value: str +) -> None: + service, _runner = port + observed = failed_role_port(service, monkeypatch) + observed.fields[field] = value + with pytest.raises(TetherError): + service.closed(retained()) + + +@pytest.mark.parametrize( + ("active", "substate"), [("active", "running"), ("activating", "auto-restart"), ("failed", "auto-restart")] +) +def test_restarting_role_does_not_acquire_terminal_failure_closure( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, active: str, substate: str +) -> None: + service, _runner = port + observed = failed_role_port(service, monkeypatch) + observed.fields.update(ActiveState=active, SubState=substate) + assert not service.closed(retained()) + assert not observed.calls + + +@pytest.mark.parametrize("identity", [IDENTITY, NAME]) +def test_failed_role_closure_refuses_body_appearing_during_proof( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, identity: str +) -> None: + service, _runner = port + observed = failed_role_port(service, monkeypatch) + observed.appear = identity + with pytest.raises(TetherError): + service.closed(retained()) + assert observed.owned_calls == 1 + + +@pytest.mark.parametrize("group", [retained()["cgroup"], UNIT_GROUP]) +def test_failed_role_closure_requires_both_retained_subtrees_empty( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, group: str +) -> None: + service, _runner = port + observed = failed_role_port(service, monkeypatch) + observed.nonempty.add(group) + if group == UNIT_GROUP: + with pytest.raises(TetherError): + service.closed(retained()) + else: + assert not service.closed(retained()) + + +@pytest.mark.parametrize("observation", [1, 2]) +def test_failed_role_closure_requires_effective_ownership_on_both_observations( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, observation: int +) -> None: + service, _runner = port + observed = failed_role_port(service, monkeypatch) + observed.foreign_at = observation + with pytest.raises(TetherError, match="Effective owned unit replaced"): + service.closed(retained()) + assert observed.owned_calls == observation + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("InvocationID", "e" * 32), + ("ControlGroup", UNIT_GROUP), + ("Job", "42"), + ("MainPID", "5"), + ("NRestarts", "1"), + ("ActiveState", "active"), + ], +) +def test_failed_role_closure_refuses_manager_drift_between_observations( + port: tuple[host.TetherHost, Runner], monkeypatch: pytest.MonkeyPatch, field: str, value: str +) -> None: + service, _runner = port + observed = failed_role_port(service, monkeypatch) + observed.drift[field] = value + with pytest.raises(TetherError): + service.closed(retained()) + assert observed.owned_calls == 2 diff --git a/tests/unit/system/services/test_tether_keys.py b/tests/unit/system/services/test_tether_keys.py new file mode 100644 index 00000000..34f8ffe2 --- /dev/null +++ b/tests/unit/system/services/test_tether_keys.py @@ -0,0 +1,905 @@ +"""Absent-only key creation, crash recovery, import custody and secret quarantine.""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import os +import struct +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +import pytest + +from lychd.system.operator.process import ProcessResult +from lychd.system.services.tether import keys +from lychd.system.services.tether.models import TetherError + +if TYPE_CHECKING: + from lychd.system.services.tether.custody import CustodyRecords + +EPOCH = "e" * 32 +REFERENCE = "lychd-tether-local" +PRIVATE = bytes([8]) + b"a" * 30 + bytes([64]) + + +def encoded(value: bytes) -> str: + return base64.b64encode(value).decode() + "\n" + + +def ssh_fixture(seed: bytes) -> tuple[str, str]: + def field(value: bytes) -> bytes: + return struct.pack(">I", len(value)) + value + + public = hashlib.sha256(seed).digest() + public_blob = field(b"ssh-ed25519") + field(public) + private_blob = struct.pack(">II", 7, 7) + field(b"ssh-ed25519") + field(public) + field(seed + public) + field(b"") + padding = 8 - len(private_blob) % 8 + private_blob += bytes(range(1, padding + 1)) + wire = ( + b"openssh-key-v1\0" + + field(b"none") + + field(b"none") + + field(b"") + + struct.pack(">I", 1) + + field(public_blob) + + field(private_blob) + ) + private = ( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + base64.b64encode(wire).decode() + + "\n-----END OPENSSH PRIVATE KEY-----\n" + ) + return private, "ssh-ed25519 " + base64.b64encode(public_blob).decode() + + +class Custody: + def __init__(self, root: Path) -> None: + """Keep test receipts separate from private input files.""" + self.root = root + root.mkdir(mode=0o700) + self.records: dict[str, dict[str, Any]] = {} + self.directories: set[str] = set() + self.scans = 0 + self.snapshot_reads = 0 + + def attest(self) -> None: + self.scans += 1 + for name in self.directories: + assert list((self.root / name).iterdir()) == [] + + def read(self, name: str) -> dict[str, Any] | None: + self.attest() + return copy.deepcopy(self.records.get(name)) + + def write(self, name: str, value: dict[str, object], *, immutable: bool = False) -> bool: + self.attest() + assert immutable + if name in self.records: + assert self.records[name] == value + return False + self.records[name] = copy.deepcopy(value) + return True + + def directory(self, name: str, *, public: bool = False) -> Path: + assert not public + path = self.root / name + path.mkdir(mode=0o700, exist_ok=True) + self.directories.add(name) + return path + + def known_paths(self) -> tuple[Path, ...]: + return tuple(self.root / name for name in sorted(self.records)) + + @contextmanager + def records_snapshot(self) -> Generator[CustodyRecords]: + self.attest() + before = copy.deepcopy(self.records) + view = RecordView(self) + try: + yield view + finally: + view.active = False + self.attest() + if self.records != before: + message = "Synthetic custody drift" + raise TetherError(message) + + +class RecordView: + def __init__(self, custody: Custody) -> None: + self.custody = custody + self.active = True + + @property + def names(self) -> tuple[str, ...]: + if not self.active: + message = "Expired synthetic custody view" + raise TetherError(message) + return tuple(sorted(self.custody.records)) + + def read(self, name: str) -> dict[str, object] | None: + if not self.active: + message = "Expired synthetic custody view" + raise TetherError(message) + self.custody.snapshot_reads += 1 + return copy.deepcopy(self.custody.records.get(name)) + + +class Runner: + def __init__(self, graph_root: Path) -> None: + """Model an exact secret store and tools without running host commands.""" + self.secrets: dict[str, dict[str, Any]] = {} + self.commands: list[tuple[str, ...]] = [] + self.inputs: list[tuple[tuple[str, ...], str]] = [] + self.generated = 0 + self.fail_create: str | None = None + self.private = PRIVATE + self.ssh_private, self.ssh_public = ssh_fixture(b"s" * 32) + self.graph_root = str(graph_root) + self.driver_options: Any = {"path": str(graph_root / "secrets" / "filedriver")} + self.fail_info = False + + def run(self, argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: + return self._run(argv, timeout_s=timeout_s, value=None) + + def run_with_input(self, argv: tuple[str, ...], *, timeout_s: float, input_text: str) -> ProcessResult: + self.inputs.append((argv, input_text)) + return self._run(argv, timeout_s=timeout_s, value=input_text) + + def _run(self, argv: tuple[str, ...], *, timeout_s: float, value: str | None) -> ProcessResult: + assert timeout_s > 0 + self.commands.append(argv) + command = argv[1:] + if argv[0] == "/tools/wg": + if command == ("genkey",): + self.generated += 1 + return ProcessResult(argv, 0, encoded(self.private)) + assert command == ("pubkey",) + assert value is not None + return ProcessResult(argv, 0, encoded(hashlib.sha256(base64.b64decode(value)).digest())) + if argv[0] == "/tools/ssh-keygen": + if command[0] == "-q": + target = Path(command[-1]) + target.write_text(self.ssh_private) + target.chmod(0o600) + target.with_suffix(".pub").write_text(self.ssh_public) + self.generated += 1 + return ProcessResult(argv, 0) + assert command == ("-y", "-P", "", "-f", "/dev/stdin") + assert value == self.ssh_private + return ProcessResult(argv, 0, self.ssh_public + "\n") + assert argv[0] == "/tools/podman" + return self._podman(argv, value) + + def _podman(self, argv: tuple[str, ...], value: str | None) -> ProcessResult: + assert argv[1] == "--remote=false" + command = argv[2:] + if command == ("info", "--format", "{{.Store.GraphRoot}}"): + return ProcessResult(argv, 1) if self.fail_info else ProcessResult(argv, 0, self.graph_root + "\n") + assert command[0] == "secret" + if command[1] == "exists": + return ProcessResult(argv, 0 if command[2] in self.secrets else 1) + if command[1] == "inspect": + assert command[2] == "--showsecret" + target = command[3] + row = next((row for name, row in self.secrets.items() if target in {name, row["ID"]}), None) + return ProcessResult(argv, 1) if row is None else ProcessResult(argv, 0, json.dumps([row])) + assert command[1] == "create" + assert command[2:4] == ("--driver", "file") + assert command[-1] == "-" + assert "--replace" not in command + assert "--ignore" not in command + if self.fail_create == "before": + raise RuntimeError("secret diagnostic " + str(value)) + reference = command[-2] + labels = dict(command[index + 1].split("=", 1) for index, item in enumerate(command) if item == "--label") + if reference in self.secrets: + return ProcessResult(argv, 125, stderr="secret conflict " + str(value)) + secret_id = f"{len(self.secrets) + 1:032x}" + self.secrets[reference] = { + "ID": secret_id, + "Spec": { + "Name": reference, + "Labels": labels, + "Driver": {"Name": "file", "Options": copy.deepcopy(self.driver_options)}, + }, + "SecretData": value, + } + if self.fail_create == "after": + raise RuntimeError("secret diagnostic " + str(value)) + return ProcessResult(argv, 0, secret_id + "\n") + + +@pytest.fixture +def gateway(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[keys.TetherKeys, Custody, Runner]: + graph_root = tmp_path / "storage" + graph_root.mkdir(mode=0o755) + (graph_root / "secrets").mkdir(mode=0o700) + (graph_root / "secrets" / "filedriver").mkdir(mode=0o700) + custody, runner = Custody(tmp_path / "custody"), Runner(graph_root) + + def fake_trusted_host_tool(name: str, **_kwargs: object) -> str: + return "/tools/" + name + + monkeypatch.setattr(keys, "trusted_host_tool", fake_trusted_host_tool) + return keys.TetherKeys(custody, EPOCH, runner), custody, runner + + +def test_ensure_is_absent_only_and_public_receipts_exclude_private( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, custody, runner = gateway + first = service.ensure(REFERENCE, "wireguard") + assert service.ensure(REFERENCE, "wireguard") == first + assert service.attest(REFERENCE) == first + assert runner.generated == 1 + assert service.known_references() == (REFERENCE,) + private = encoded(PRIVATE).strip() + assert private not in json.dumps(custody.records) + assert private not in json.dumps(first) + assert all(private not in " ".join(argv) for argv in runner.commands) + + +def test_default_file_descriptor_is_proved_for_both_key_purposes( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, _custody, runner = gateway + references: dict[str, Literal["wireguard", "ssh-host"]] = {REFERENCE: "wireguard", "lychd-tether-ssh": "ssh-host"} + for reference, purpose in references.items(): + identity = service.ensure(reference, purpose) + assert service.attest(reference) == identity + assert service.ensure(reference, purpose) == identity + assert service.owned_references() == tuple(sorted(references)) + assert ("/tools/podman", "--remote=false", "info", "--format", "{{.Store.GraphRoot}}") in runner.commands + assert runner.generated == len(references) + + +def test_key_operations_pin_local_engine_before_sending_private_material( + gateway: tuple[keys.TetherKeys, Custody, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, _custody, runner = gateway + monkeypatch.setenv("CONTAINER_HOST", "ssh://foreign.invalid/run/podman.sock") + monkeypatch.setenv("CONTAINER_CONNECTION", "foreign-qualification") + service.ensure(REFERENCE, "wireguard") + service.ensure("lychd-tether-ssh", "ssh-host") + service.attest(REFERENCE) + service.owned_references() + podman = [argv for argv in runner.commands if argv[0] == "/tools/podman"] + assert podman + assert all(argv[1] == "--remote=false" for argv in podman) + assert all("--remote=false" not in argv for argv in runner.commands if argv[0] != "/tools/podman") + for private in (encoded(PRIVATE).strip(), runner.ssh_private): + assert all(private not in " ".join(argv) for argv in runner.commands) + created = [ + value for argv, value in runner.inputs if argv[:4] == ("/tools/podman", "--remote=false", "secret", "create") + ] + assert created == [encoded(PRIVATE), runner.ssh_private] + + +@pytest.mark.parametrize("options", [None, {}, {"path": None}, {"path": "/foreign"}, {"path": "relative"}]) +def test_file_driver_refuses_unproved_descriptor_shapes( + gateway: tuple[keys.TetherKeys, Custody, Runner], options: Any +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + runner.secrets[REFERENCE]["Spec"]["Driver"]["Options"] = options + with pytest.raises(TetherError, match="refused"): + service.attest(REFERENCE) + + +def test_file_driver_refuses_additional_options(gateway: tuple[keys.TetherKeys, Custody, Runner]) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + runner.secrets[REFERENCE]["Spec"]["Driver"]["Options"]["foreign"] = "unsupported" + with pytest.raises(TetherError, match="refused"): + service.owned_references() + + +@pytest.mark.parametrize("suffix", ["/", "/.", "/../storage"]) +def test_file_driver_refuses_noncanonical_graph_root( + gateway: tuple[keys.TetherKeys, Custody, Runner], suffix: str +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + runner.graph_root += suffix + with pytest.raises(TetherError, match="refused"): + service.attest(REFERENCE) + + +@pytest.mark.parametrize("component", [".", "secrets", "secrets/filedriver"]) +@pytest.mark.parametrize("tamper", ["symlink", "writable"]) +def test_file_driver_refuses_unsafe_default_directory( + gateway: tuple[keys.TetherKeys, Custody, Runner], component: str, tamper: str +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + path = Path(runner.graph_root) / component + if tamper == "symlink": + renamed = path.with_name(path.name + "-original") + path.rename(renamed) + path.symlink_to(renamed, target_is_directory=True) + else: + path.chmod(0o777) + with pytest.raises(TetherError, match="refused"): + service.attest(REFERENCE) + + +@pytest.mark.parametrize("component", ["secrets", "secrets/filedriver"]) +def test_file_driver_requires_private_secret_directories( + gateway: tuple[keys.TetherKeys, Custody, Runner], component: str +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + (Path(runner.graph_root) / component).chmod(0o755) + with pytest.raises(TetherError, match="refused"): + service.attest(REFERENCE) + + +def test_file_driver_refuses_different_runtime_owner( + gateway: tuple[keys.TetherKeys, Custody, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, _custody, _runner = gateway + service.ensure(REFERENCE, "wireguard") + foreign_uid = os.getuid() + 1 + monkeypatch.setattr(keys.os, "getuid", lambda: foreign_uid) + with pytest.raises(TetherError, match="refused"): + service.attest(REFERENCE) + + +@pytest.mark.parametrize("failure", ["unavailable", "changed"]) +def test_file_driver_requires_current_backend_information( + gateway: tuple[keys.TetherKeys, Custody, Runner], failure: str +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + if failure == "unavailable": + runner.fail_info = True + else: + runner.graph_root = str(Path(runner.graph_root).with_name("other-storage")) + with pytest.raises(TetherError, match="refused"): + service.attest(REFERENCE) + + +def test_foreign_existing_name_is_never_adopted(gateway: tuple[keys.TetherKeys, Custody, Runner]) -> None: + service, _custody, runner = gateway + runner.secrets[REFERENCE] = {"ID": "f" * 32, "SecretData": "foreign"} + with pytest.raises(TetherError, match="refused"): + service.ensure(REFERENCE, "wireguard") + assert runner.generated == 0 + assert not any("inspect" in command for command in runner.commands) + + +def test_pending_exact_labeled_creation_recovers_without_regeneration( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, custody, runner = gateway + runner.fail_create = "after" + with pytest.raises(TetherError, match="refused") as failure: + service.ensure(REFERENCE, "wireguard") + assert encoded(PRIVATE).strip() not in str(failure.value) + assert failure.value.__cause__ is None + assert len(custody.records) == 1 + runner.fail_create = None + recovered = service.ensure(REFERENCE, "wireguard") + assert recovered["secret_id"] == runner.secrets[REFERENCE]["ID"] + assert runner.generated == 1 + assert len(custody.records) == 2 + + +def test_pending_without_secret_refuses_uncertain_regeneration( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, _custody, runner = gateway + runner.fail_create = "before" + with pytest.raises(TetherError, match="refused"): + service.ensure(REFERENCE, "wireguard") + runner.fail_create = None + with pytest.raises(TetherError, match="refused"): + service.ensure(REFERENCE, "wireguard") + assert runner.generated == 1 + assert not runner.secrets + + +@pytest.mark.parametrize("tamper", ["id", "labels", "value", "driver"]) +def test_attestation_refuses_replacement_or_policy_drift( + gateway: tuple[keys.TetherKeys, Custody, Runner], tamper: str +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + secret = runner.secrets[REFERENCE] + if tamper == "id": + secret["ID"] = "f" * 32 + elif tamper == "labels": + secret["Spec"]["Labels"]["io.lychd.tether.epoch"] = "f" * 32 + elif tamper == "driver": + secret["Spec"]["Driver"] = {"Name": "shell", "Options": {"lookup": "foreign"}} + else: + secret["SecretData"] = encoded(b"z" * 32) + with pytest.raises(TetherError, match="refused"): + service.attest(REFERENCE) + assert runner.generated == 1 + + +def test_pending_wrong_operation_is_not_adopted(gateway: tuple[keys.TetherKeys, Custody, Runner]) -> None: + service, _custody, runner = gateway + runner.fail_create = "after" + with pytest.raises(TetherError, match="refused"): + service.ensure(REFERENCE, "wireguard") + runner.fail_create = None + runner.secrets[REFERENCE]["Spec"]["Labels"]["io.lychd.tether.operation"] = "f" * 32 + with pytest.raises(TetherError, match="refused"): + service.ensure(REFERENCE, "wireguard") + assert runner.generated == 1 + + +def private_input(tmp_path: Path, value: bytes) -> Path: + directory = tmp_path / "import" + directory.mkdir(mode=0o700) + path = directory / "key" + path.write_text(encoded(value)) + path.chmod(0o600) + return path + + +def test_psk_import_is_idempotent_but_cannot_reuse_tunnel_private_material( + gateway: tuple[keys.TetherKeys, Custody, Runner], tmp_path: Path +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + path = private_input(tmp_path, PRIVATE) + with pytest.raises(TetherError, match="refused"): + service.import_key("lychd-tether-psk", path, "preshared") + assert len(runner.secrets) == 1 + path.write_text(encoded(b"p" * 32)) + first = service.import_key("lychd-tether-psk", path, "preshared") + assert first["public_key"] is None + assert service.import_key("lychd-tether-psk", path, "preshared") == first + path.write_text(encoded(b"q" * 32)) + with pytest.raises(TetherError, match="refused"): + service.import_key("lychd-tether-psk", path, "preshared") + + +@pytest.mark.parametrize("unsafe", ["mode", "parent", "symlink", "hardlink"]) +def test_import_requires_private_owned_single_link_file( + gateway: tuple[keys.TetherKeys, Custody, Runner], tmp_path: Path, unsafe: str +) -> None: + service, _custody, runner = gateway + path = private_input(tmp_path, PRIVATE) + if unsafe == "mode": + path.chmod(0o644) + elif unsafe == "parent": + path.parent.chmod(0o755) + elif unsafe == "symlink": + link = path.parent / "alias" + link.symlink_to(path) + path = link + else: + os.link(path, path.parent / "alias") + with pytest.raises(TetherError, match="refused"): + service.import_key(REFERENCE, path, "wireguard") + assert not runner.secrets + assert not runner.commands + + +def test_ssh_generation_cleans_private_staging_before_receipt(gateway: tuple[keys.TetherKeys, Custody, Runner]) -> None: + service, custody, runner = gateway + result = service.ensure("lychd-tether-ssh", "ssh-host") + assert result["public_key"] == runner.ssh_public + custody.attest() + assert list((custody.root / "key-staging").iterdir()) == [] + assert runner.ssh_private not in json.dumps(custody.records) + assert service.attest("lychd-tether-ssh") == result + + +def test_ssh_seed_cannot_be_reused_as_preshared_material( + gateway: tuple[keys.TetherKeys, Custody, Runner], tmp_path: Path +) -> None: + service, _custody, runner = gateway + service.ensure("lychd-tether-ssh", "ssh-host") + path = private_input(tmp_path, b"s" * 32) + with pytest.raises(TetherError, match="refused"): + service.import_key("lychd-tether-psk", path, "preshared") + assert len(runner.secrets) == 1 + + +def test_inventory_never_discovers_unreceipted_podman_names( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, _custody, runner = gateway + runner.secrets[REFERENCE] = {"ID": "f" * 32, "SecretData": "foreign"} + assert service.known_references() == () + assert not runner.commands + + +def test_pending_inventory_is_known_but_not_attested( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, _custody, runner = gateway + runner.fail_create = "before" + with pytest.raises(TetherError): + service.ensure(REFERENCE, "wireguard") + previous_commands = list(runner.commands) + assert service.known_references() == (REFERENCE,) + assert runner.commands == previous_commands + with pytest.raises(TetherError): + service.attest(REFERENCE) + + +@pytest.mark.parametrize("tamper", ["reference", "epoch", "filename"]) +def test_inventory_refuses_malformed_receipted_identity( + gateway: tuple[keys.TetherKeys, Custody, Runner], + tamper: str, +) -> None: + service, custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + name = next(iter(custody.records)) + if tamper == "filename": + custody.records["key-" + "0" * 64 + "-pending.json"] = custody.records.pop(name) + elif tamper == "epoch": + custody.records[name]["installation_epoch"] = "0" * 32 + else: + custody.records[name]["reference"] = "lychd-tether-other" + previous_commands = list(runner.commands) + with pytest.raises(TetherError): + service.known_references() + assert runner.commands == previous_commands + + +def test_preparation_state_is_receipt_only_and_distinguishes_pending( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, _custody, runner = gateway + assert service.preparation_state(REFERENCE) == "absent" + assert not runner.commands + runner.fail_create = "after" + with pytest.raises(TetherError): + service.ensure(REFERENCE, "wireguard") + commands = list(runner.commands) + assert service.preparation_state(REFERENCE) == "pending" + assert runner.commands == commands + runner.fail_create = None + service.ensure(REFERENCE, "wireguard") + commands = list(runner.commands) + assert service.preparation_state(REFERENCE) == "ready" + assert runner.commands == commands + runner.secrets.clear() + assert service.preparation_state(REFERENCE) == "ready" + with pytest.raises(TetherError): + service.attest(REFERENCE) + + +@pytest.mark.parametrize("tamper", ["missing-pending", "mismatched-operation", "epoch", "filename"]) +def test_preparation_state_refuses_inconsistent_receipts( + gateway: tuple[keys.TetherKeys, Custody, Runner], + tamper: str, +) -> None: + service, custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + pending = next(name for name in custody.records if name.endswith("-pending.json")) + if tamper == "missing-pending": + del custody.records[pending] + elif tamper == "filename": + custody.records["key-" + "0" * 64 + "-pending.json"] = custody.records.pop(pending) + elif tamper == "epoch": + custody.records[pending]["installation_epoch"] = "0" * 32 + else: + custody.records[pending]["operation_id"] = "0" * 32 + commands = list(runner.commands) + with pytest.raises(TetherError): + service.preparation_state(REFERENCE) + assert runner.commands == commands + + +@pytest.mark.parametrize("failure", ["before", "after"]) +def test_owned_inventory_handles_pending_without_effects( + gateway: tuple[keys.TetherKeys, Custody, Runner], + failure: str, +) -> None: + service, custody, runner = gateway + runner.fail_create = failure + with pytest.raises(TetherError): + service.ensure(REFERENCE, "wireguard") + records = copy.deepcopy(custody.records) + generated = runner.generated + assert service.owned_references() == ((REFERENCE,) if failure == "after" else ()) + assert custody.records == records + assert runner.generated == generated + + +def test_owned_inventory_rejects_replaced_final_and_pending( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + assert service.owned_references() == (REFERENCE,) + runner.secrets[REFERENCE]["Spec"]["Labels"] = {} + with pytest.raises(TetherError): + service.owned_references() + final = next(name for name in custody.records if not name.endswith("-pending.json")) + del custody.records[final] + with pytest.raises(TetherError): + service.owned_references() + + +def test_owned_inventory_refuses_missing_final_backend( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + runner.secrets.clear() + with pytest.raises(TetherError): + service.owned_references() + + +def test_failed_unrelated_creation_does_not_block_existing_key_attestation( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, custody, runner = gateway + identity = service.ensure(REFERENCE, "wireguard") + runner.fail_create = "before" + other = "lychd-tether-next-owner" + with pytest.raises(TetherError): + service.ensure(other, "ssh-host") + assert service.preparation_state(other) == "pending" + assert other not in runner.secrets + records = copy.deepcopy(custody.records) + generated = runner.generated + runner.fail_create = None + assert service.attest(REFERENCE) == identity + assert service.ensure(REFERENCE, "wireguard") == identity + assert service.owned_references() == (REFERENCE,) + assert custody.records == records + assert runner.generated == generated + with pytest.raises(TetherError): + service.ensure(other, "ssh-host") + assert runner.generated == generated + + +@pytest.mark.parametrize("phase", ["pending", "final"]) +@pytest.mark.parametrize("tamper", ["missing", "labels"]) +def test_unrelated_existing_custody_still_blocks_attestation_on_drift( + gateway: tuple[keys.TetherKeys, Custody, Runner], phase: str, tamper: str +) -> None: + service, _custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + other = "lychd-tether-next-owner" + if phase == "pending": + runner.fail_create = "after" + with pytest.raises(TetherError): + service.ensure(other, "ssh-host") + else: + service.ensure(other, "ssh-host") + if tamper == "missing": + del runner.secrets[other] + else: + runner.secrets[other]["Spec"]["Labels"] = {} + if phase == "pending" and tamper == "missing": + assert service.attest(REFERENCE)["reference"] == REFERENCE + else: + with pytest.raises(TetherError): + service.attest(REFERENCE) + + +@pytest.mark.parametrize("count", [1, 4]) +def test_batch_attestation_has_constant_custody_scans_and_one_inspection_per_key( + gateway: tuple[keys.TetherKeys, Custody, Runner], count: int +) -> None: + service, custody, runner = gateway + expected: dict[str, dict[str, Any]] = {} + private_values: list[str] = [] + for index in range(count): + runner.private = bytes([8]) + bytes([97 + index]) * 30 + bytes([64]) + private_values.append(encoded(runner.private)) + reference = f"lychd-tether-key-{index}" + expected[reference] = service.ensure(reference, "wireguard") + custody.scans = custody.snapshot_reads = 0 + runner.commands.clear() + observed = service.attest_many(tuple(expected)) + assert observed == expected + assert custody.scans == 2 + assert custody.snapshot_reads == 2 * count + inspections = [argv for argv in runner.commands if argv[2:4] == ("secret", "inspect")] + assert len(inspections) == count + assert {argv[-1] for argv in inspections} == {row["secret_id"] for row in expected.values()} + assert all(value.strip() not in repr(observed) for value in private_values) + assert all(argv[1] == "--remote=false" for argv in runner.commands if argv[0] == "/tools/podman") + + +def test_batch_results_are_public_detached_and_next_call_is_fresh( + gateway: tuple[keys.TetherKeys, Custody, Runner], +) -> None: + service, custody, runner = gateway + expected = service.ensure(REFERENCE, "wireguard") + observation = service.observe() + assert observation.require((REFERENCE,)) == {REFERENCE: expected} + output = observation.require((REFERENCE,)) + output[REFERENCE]["public_key"] = "changed" + assert observation.require((REFERENCE,)) == {REFERENCE: expected} + assert encoded(PRIVATE).strip() not in repr(observation) + retained = copy.deepcopy(custody.records) + runner.secrets[REFERENCE]["Spec"]["Labels"] = {} + with pytest.raises(TetherError, match="refused"): + service.attest_many((REFERENCE,)) + assert custody.records == retained + + +def test_batch_custody_drift_during_backend_observation_refuses_return( + gateway: tuple[keys.TetherKeys, Custody, Runner], monkeypatch: pytest.MonkeyPatch +) -> None: + service, custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + original = runner.run + + def replace_receipt(argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: + result = original(argv, timeout_s=timeout_s) + if argv[2:4] == ("secret", "inspect"): + custody.records["foreign.json"] = {} + return result + + monkeypatch.setattr(runner, "run", replace_receipt) + with pytest.raises(TetherError, match="refused"): + service.attest_many((REFERENCE,)) + + +@pytest.mark.parametrize("clamped", [False, True]) +def test_batch_rejects_material_aliases_even_with_consistent_public_receipts( + gateway: tuple[keys.TetherKeys, Custody, Runner], *, clamped: bool +) -> None: + service, custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + other = "lychd-tether-other" + runner.private = b"z" * 32 + service.ensure(other, "wireguard") + material = bytes([PRIVATE[0] | 1]) + PRIVATE[1:] if clamped else PRIVATE + public_raw = hashlib.sha256(material).digest() + public = base64.b64encode(public_raw).decode() + fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(public_raw).digest()).decode().rstrip("=") + for record in custody.records.values(): + if record["reference"] == other: + record.update(public_key=public, fingerprint=fingerprint) + runner.secrets[other]["SecretData"] = encoded(material) + with pytest.raises(TetherError, match="refused"): + service.attest_many((REFERENCE, other)) + + +def create_or_import( + service: keys.TetherKeys, reference: str, path: Path, operation: Literal["ensure", "import"] +) -> dict[str, Any]: + if operation == "ensure": + return service.ensure(reference, "wireguard") + return service.import_key(reference, path, "wireguard") + + +@pytest.mark.parametrize("operation", ["ensure", "import"]) +@pytest.mark.parametrize("count", [1, 4]) +def test_creation_uniqueness_has_one_snapshot_independent_of_existing_key_count( + gateway: tuple[keys.TetherKeys, Custody, Runner], + tmp_path: Path, + count: int, + operation: Literal["ensure", "import"], +) -> None: + service, custody, runner = gateway + for index in range(count): + runner.private = bytes([8]) + bytes([97 + index]) * 30 + bytes([64]) + service.ensure(f"lychd-tether-existing-{index}", "wireguard") + runner.private = b"z" * 32 + path = private_input(tmp_path, runner.private) + custody.scans = custody.snapshot_reads = 0 + runner.commands.clear() + identity = create_or_import(service, REFERENCE, path, operation) + # Entry attestation + two target reads + two snapshot scans + two receipt writes. + assert custody.scans == 7 + assert custody.snapshot_reads == 2 * count + inspections = [argv for argv in runner.commands if argv[2:4] == ("secret", "inspect")] + assert len(inspections) == count + 1 + assert len({argv[-1] for argv in inspections}) == count + 1 + custody.scans = custody.snapshot_reads = 0 + runner.commands.clear() + assert create_or_import(service, REFERENCE, path, operation) == identity + assert custody.scans == 5 + assert custody.snapshot_reads == 2 * (count + 1) + assert not any(argv[2:4] == ("secret", "create") for argv in runner.commands) + + +@pytest.mark.parametrize("operation", ["ensure", "import"]) +@pytest.mark.parametrize("phase", ["pending", "ready"]) +@pytest.mark.parametrize("tamper", ["missing", "labels"]) +def test_creation_batch_preserves_pending_absence_and_live_backend_refusal( + gateway: tuple[keys.TetherKeys, Custody, Runner], + tmp_path: Path, + operation: Literal["ensure", "import"], + phase: str, + tamper: str, +) -> None: + service, custody, runner = gateway + if phase == "pending": + runner.fail_create = "after" + with pytest.raises(TetherError): + service.ensure(REFERENCE, "wireguard") + runner.fail_create = None + else: + service.ensure(REFERENCE, "wireguard") + if tamper == "missing": + del runner.secrets[REFERENCE] + else: + runner.secrets[REFERENCE]["Spec"]["Labels"] = {} + runner.private = b"z" * 32 + path = private_input(tmp_path, runner.private) + retained = copy.deepcopy(custody.records) + runner.commands.clear() + if phase == "pending" and tamper == "missing": + assert create_or_import(service, "lychd-tether-next", path, operation)["reference"] == "lychd-tether-next" + assert all(custody.records[name] == record for name, record in retained.items()) + assert REFERENCE not in runner.secrets + else: + with pytest.raises(TetherError, match="refused"): + create_or_import(service, "lychd-tether-next", path, operation) + assert custody.records == retained + assert not any(argv[2:4] == ("secret", "create") for argv in runner.commands) + + +@pytest.mark.parametrize("operation", ["ensure", "import"]) +def test_creation_batch_exit_drift_refuses_before_any_new_receipt_or_backend_create( + gateway: tuple[keys.TetherKeys, Custody, Runner], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + operation: Literal["ensure", "import"], +) -> None: + service, custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + runner.private = b"z" * 32 + path = private_input(tmp_path, runner.private) + retained = copy.deepcopy(custody.records) + original = runner.run + + def drift(argv: tuple[str, ...], *, timeout_s: float) -> ProcessResult: + result = original(argv, timeout_s=timeout_s) + if argv[2:4] == ("secret", "inspect"): + custody.records["foreign.json"] = {} + return result + + monkeypatch.setattr(runner, "run", drift) + runner.commands.clear() + with pytest.raises(TetherError, match="refused"): + create_or_import(service, "lychd-tether-next", path, operation) + assert custody.records == {**retained, "foreign.json": {}} + assert set(runner.secrets) == {REFERENCE} + assert not any(argv[2:4] == ("secret", "create") for argv in runner.commands) + + +@pytest.mark.parametrize("operation", ["ensure", "import"]) +@pytest.mark.parametrize("alias", ["raw", "clamped", "public"]) +def test_creation_batch_preserves_all_material_alias_checks( + gateway: tuple[keys.TetherKeys, Custody, Runner], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + operation: Literal["ensure", "import"], + alias: str, +) -> None: + service, custody, runner = gateway + service.ensure(REFERENCE, "wireguard") + candidate = PRIVATE if alias == "raw" else bytes([PRIVATE[0] | 1]) + PRIVATE[1:] + if alias == "public": + candidate = b"z" * 32 + original = runner.run_with_input + + def same_public(argv: tuple[str, ...], *, timeout_s: float, input_text: str) -> ProcessResult: + if argv == ("/tools/wg", "pubkey") and input_text == encoded(candidate): + return ProcessResult(argv, 0, encoded(hashlib.sha256(PRIVATE).digest())) + return original(argv, timeout_s=timeout_s, input_text=input_text) + + monkeypatch.setattr(runner, "run_with_input", same_public) + runner.private = candidate + path = private_input(tmp_path, candidate) + retained = copy.deepcopy(custody.records) + runner.commands.clear() + with pytest.raises(TetherError, match="refused"): + create_or_import(service, "lychd-tether-next", path, operation) + assert custody.records == retained + assert set(runner.secrets) == {REFERENCE} + assert not any(argv[2:4] == ("secret", "create") for argv in runner.commands) diff --git a/tests/unit/system/services/test_tether_runtime.py b/tests/unit/system/services/test_tether_runtime.py new file mode 100644 index 00000000..46c8b45e --- /dev/null +++ b/tests/unit/system/services/test_tether_runtime.py @@ -0,0 +1,731 @@ +"""Runtime wire rejection and active-stream fencing without host network effects.""" + +from __future__ import annotations + +import asyncio +import base64 +import copy +import hashlib +import json +import os +import socket +from pathlib import Path +from typing import Any, cast + +import pytest + +from lychd.system.services.tether import runtime + + +def common(role: str) -> dict[str, Any]: + return { + "schema": "lychd.tether.runtime", + "version": 1, + "role": role, + "network_id": "remote-b", + "installation_epoch": "a" * 32, + "generation": 3, + "policy_digest": "b" * 64, + "fence_path": "/etc/lychd-tether/fences.json", + } + + +def imported() -> dict[str, Any]: + return { + **common("imported-relay"), + "remote_lich_id": "lich-b", + "caller_uid": 1000, + "allowed_callers": ["intercom"], + "routes": [ + { + "service_id": "tasks", + "generation": 3, + "socket_path": "/run/lychd-tether/12345678/12345678.sock", + "address": "10.77.0.2", + "port": 443, + "application_identity_ref": "peer:b", + } + ], + } + + +def fences(p: dict[str, Any], *, enabled: bool = True) -> dict[str, Any]: + return { + "schema": "lychd.tether.fences", + "version": 1, + "installation_epoch": p["installation_epoch"], + "network_id": p["network_id"], + "generation": p["generation"], + "enabled": enabled, + "peers": [], + } + + +def header(p: dict[str, Any]) -> dict[str, Any]: + return { + "version": 1, + "installation_epoch": p["installation_epoch"], + "policy_digest": p["policy_digest"], + "remote_lich_id": p["remote_lich_id"], + "attachment_id": p["network_id"], + "generation": p["generation"], + "service_id": "tasks", + "caller": "intercom", + } + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("generation", True), + ("generation", 2), + ("caller", "scout"), + ("attachment_id", "remote-c"), + ("remote_lich_id", "lich-c"), + ("service_id", "database"), + ("installation_epoch", "c" * 32), + ("policy_digest", "d" * 64), + ("version", True), + ], +) +def test_header_binds_every_selection_field(field: str, value: object) -> None: + p = imported() + relay = runtime.Relay(runtime.validate_config(p)) + request = header(p) + relay.validate_header(json.dumps(request).encode() + b"\n", p["routes"][0], 1000) + request[field] = value + with pytest.raises(ValueError, match="refused"): + relay.validate_header(json.dumps(request).encode() + b"\n", p["routes"][0], 1000) + + +def test_header_rejects_extra_target_duplicate_fields_and_wrong_uid() -> None: + p = imported() + relay = runtime.Relay(p) + request = header(p) + request["address"] = "127.0.0.1" + with pytest.raises(ValueError, match="refused"): + relay.validate_header(json.dumps(request).encode() + b"\n", p["routes"][0], 1000) + encoded = json.dumps(header(p)).encode() + with pytest.raises(ValueError, match="refused"): + relay.validate_header(encoded[:-1] + b',"caller":"intercom"}\n', p["routes"][0], 1000) + with pytest.raises(ValueError, match="refused"): + relay.validate_header(encoded + b"\n", p["routes"][0], 1001) + with pytest.raises(ValueError, match="refused"): + relay.validate_header(encoded + b" " * runtime.MAX_HEADER + b"\n", p["routes"][0], 1000) + + +@pytest.mark.parametrize("extra", ["PostUp", "PreDown", "DNS", "AllowedIPs", "command"]) +def test_import_rejects_configuration_authority(extra: str) -> None: + p = imported() + p[extra] = "arbitrary" + with pytest.raises(ValueError, match="refused"): + runtime.validate_config(p) + + +def test_fence_rejects_epoch_rollback_and_wrong_generation() -> None: + p = imported() + f = fences(p) + runtime.validate_fences(f, p) + f["installation_epoch"] = "0" * 32 + with pytest.raises(ValueError, match="refused"): + runtime.validate_fences(f, p) + f = fences(p) + f["generation"] = 2 + with pytest.raises(ValueError, match="refused"): + runtime.validate_fences(f, p) + assert not runtime.admitted(p, None) + assert not runtime.admitted(p, fences(p, enabled=False)) + + +def wg() -> dict[str, Any]: + return { + **common("wireguard"), + "interface": "wg0", + "address": "10.77.0.1/32", + "listen_port": 0, + "private_key_path": "/run/secrets/wg-private", + "peers": [ + { + "id": "lich-b", + "generation": 3, + "public_key": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + "allowed_ips": ["10.77.0.2/32"], + "endpoint_address": "203.0.113.1", + "endpoint_port": 51820, + "keepalive": 25, + "preshared_key_path": None, + } + ], + "ingress": [], + "owner_loopback_port": None, + "egress": [ + {"address": "10.77.0.2", "port": 443, "protocol": "tcp"}, + {"address": "203.0.113.1", "port": 51820, "protocol": "udp"}, + ], + } + + +def test_imported_nft_never_accepts_inner_tcp_on_outer_interface() -> None: + p = runtime.validate_config(wg()) + policy = runtime.nft_policy(p, fences(p)) + tcp_rules = [line for line in policy.splitlines() if "tcp " in line] + assert tcp_rules + assert all('ifname "wg0"' in line for line in tcp_rules) + assert not any('!= "wg0"' in line for line in tcp_rules) + assert 'oifname != "wg0" ip daddr 203.0.113.1 udp dport 51820 accept' in policy + assert "forward { type filter hook forward priority -150; policy drop; }" in policy + closed = runtime.nft_policy(p, None) + assert "accept" not in closed + assert "policy drop" in closed + + +def test_offered_revocation_removes_only_peer_ingress() -> None: + p = wg() + p["listen_port"] = 51820 + p["egress"] = [] + p["peers"][0].update(endpoint_address=None, endpoint_port=None) + second = copy.deepcopy(p["peers"][0]) + second.update(id="lich-c", allowed_ips=["10.77.0.3/32"]) + p["peers"].append(second) + p["ingress"] = [{"peer_address": "10.77.0.2", "port": 2222}, {"peer_address": "10.77.0.3", "port": 2223}] + f = fences(p) + f["peers"] = [ + {"id": "lich-b", "generation": 3, "enabled": False}, + {"id": "lich-c", "generation": 3, "enabled": True}, + ] + policy = runtime.nft_policy(p, f) + assert "10.77.0.2" not in policy + assert "10.77.0.3" in policy + f["peers"][1]["generation"] = 4 + assert "10.77.0.3" not in runtime.nft_policy(p, f) + + +class Writer: + """Observe writes and closure without opening real sockets.""" + + def __init__(self, source: str = "10.77.0.2") -> None: + """Collect observable stream writes and closure.""" + self.source = source + self.writes: list[bytes] = [] + self.closed = False + + def get_extra_info(self, name: str) -> tuple[str, int]: + assert name == "peername" + return self.source, 12345 + + def write(self, value: bytes) -> None: + self.writes.append(value) + + async def drain(self) -> None: + await asyncio.sleep(0) + + def close(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_active_stream_fence_prevents_future_upstream_bytes(monkeypatch: pytest.MonkeyPatch) -> None: + p = { + **common("offered-relay"), + "services": [], + "peers": [{"id": "peer-b", "generation": 1, "address": "10.77.0.2", "service_ids": ["tasks"]}], + } + f = fences(p) + f["peers"] = [{"id": "peer-b", "generation": 1, "enabled": True}] + + def fake_load_fences(_p: dict[str, Any]) -> dict[str, Any]: + return f + + monkeypatch.setattr(runtime, "load_fences", fake_load_fences) + incoming, outgoing = asyncio.StreamReader(), asyncio.StreamReader() + client, upstream = Writer(), Writer() + connected = asyncio.Event() + + async def connect(*_args: object, **_kwargs: object) -> tuple[asyncio.StreamReader, Writer]: + connected.set() + return outgoing, upstream + + monkeypatch.setattr(asyncio, "open_connection", connect) + relay = runtime.Relay(p) + task = asyncio.create_task( + relay.handle( + incoming, + cast("asyncio.StreamWriter", client), + {"service_id": "tasks", "backend_address": "192.0.2.3", "backend_port": 443}, + ) + ) + await asyncio.wait_for(connected.wait(), 1) + incoming.feed_data(b"before") + for _ in range(5): + await asyncio.sleep(0) + assert upstream.writes == [b"before"] + f["peers"][0]["enabled"] = False + incoming.feed_data(b"must-not-cross") + await asyncio.wait_for(task, 1) + assert upstream.writes == [b"before"] + assert client.closed + assert upstream.closed + assert not relay.tasks + + +@pytest.mark.asyncio +async def test_offered_wrong_peer_never_opens_backend(monkeypatch: pytest.MonkeyPatch) -> None: + p: dict[str, Any] = {**common("offered-relay"), "peers": []} + + def fake_load_fences(_p: dict[str, Any]) -> dict[str, Any]: + return fences(p) + + monkeypatch.setattr(runtime, "load_fences", fake_load_fences) + opened = False + + async def connect(*_args: object, **_kwargs: object) -> None: + nonlocal opened + opened = True + + monkeypatch.setattr(asyncio, "open_connection", connect) + writer = Writer() + await runtime.Relay(p).handle(asyncio.StreamReader(), cast("asyncio.StreamWriter", writer), {"service_id": "tasks"}) + assert not opened + assert writer.closed + + +def test_owner_policy_is_exact_forward_only() -> None: + config = runtime.ssh_config({"listen_port": 2222, "listen_address": "10.0.0.1", "target_port": 27713}) + assert "PermitOpen 127.0.0.1:27713\n" in config + for line in ( + "MaxSessions 0", + "AllowTcpForwarding local", + "AllowStreamLocalForwarding no", + "PermitListen none", + "PermitTTY no", + "PermitTunnel no", + "AllowAgentForwarding no", + ): + assert line + "\n" in config + + +def test_nft_comparison_keeps_interface_negation_significant() -> None: + expected = 'oifname "wg0" ip daddr 10.77.0.2 tcp dport 443 accept' + bypass = 'oifname != "wg0" ip daddr 10.77.0.2 tcp dport 443 accept' + assert runtime.policy_tokens(expected) != runtime.policy_tokens(bypass) + + +def test_restart_adopts_only_exact_marked_table(monkeypatch: pytest.MonkeyPatch) -> None: + p = wg() + table = {"family": "inet", "name": "lychd_tether", "comment": runtime.ownership_marker(p)} + + def fake_run(_argv: tuple[str, ...]) -> str: + return json.dumps({"nftables": [{"table": table}]}) + + monkeypatch.setattr(runtime, "run", fake_run) + assert runtime.existing_owned_table(p) + table["comment"] = "foreign-installation" + with pytest.raises(ValueError, match="refused"): + runtime.existing_owned_table(p) + + +def test_restart_adopts_only_marked_wireguard_and_exact_routes(monkeypatch: pytest.MonkeyPatch) -> None: + p = wg() + links = [{"ifalias": runtime.ownership_marker(p), "linkinfo": {"info_kind": "wireguard"}}] + + def run(argv: tuple[str, ...]) -> str: + if "link" in argv: + return json.dumps(links) + if "address" in argv: + return json.dumps([{"addr_info": [{"family": "inet", "local": "10.77.0.1", "prefixlen": 32}]}]) + return json.dumps([{"dst": "10.77.0.2", "protocol": "boot"}]) + + monkeypatch.setattr(runtime, "run", run) + assert runtime.owned_interface_routes(p) == {"10.77.0.2/32"} + links[0]["ifalias"] = "foreign" + with pytest.raises(ValueError, match="refused"): + runtime.owned_interface_routes(p) + + +class WireGuardKernel: + """Enforce link-state and denial guards without changing a network namespace.""" + + def __init__(self, config: dict[str, Any], fence: dict[str, Any], routes: set[str]) -> None: + """Start with a down interface and explicit recovered route state.""" + self.config = config + self.fence = fence + self.routes = set(routes) + self.up = False + self.denied = False + self.peers_configured = False + self.ready = False + self.grants = 0 + + def run(self, argv: tuple[str, ...], *, content: str | None = None) -> str: + if argv == (runtime.NFT, "-f", "-"): + assert content is not None + self.denied = "accept" not in content + if not self.denied: + assert self.fence["enabled"] + assert self.up + assert self.peers_configured + assert self.routes == {"10.77.0.2/32"} + self.grants += 1 + elif argv[1:3] == ("link", "set") and argv[-1] == "up": + assert self.denied + assert self.peers_configured + self.up = True + elif argv[1:3] == ("route", "add"): + if not self.up: + message = "Device for nexthop is not up" + raise ValueError(message) + assert self.denied + self.routes.add(argv[3]) + elif argv[1:3] == ("route", "delete"): + assert self.denied + self.routes.remove(argv[3]) + elif argv[1:3] == ("link", "delete"): + assert self.denied + self.up = False + else: + assert self.denied + return "" + + def configure_peers(self, config: dict[str, Any], current_fence: dict[str, Any] | None) -> None: + assert self.denied + assert config == self.config + assert current_fence == self.fence + self.peers_configured = True + + def notify_ready(self) -> None: + assert self.up + assert self.routes == ({"10.77.0.2/32"} if self.fence["enabled"] else set()) + assert self.denied is not self.fence["enabled"] + self.ready = True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("recovered", [False, True]) +@pytest.mark.parametrize("enabled", [False, True]) +async def test_wireguard_routes_require_up_device_under_denial( + monkeypatch: pytest.MonkeyPatch, *, recovered: bool, enabled: bool +) -> None: + p = runtime.validate_config(wg()) + fence = fences(p, enabled=enabled) + initial_routes: set[str] = {"10.77.0.99/32"} if recovered else set() + kernel = WireGuardKernel(p, fence, initial_routes) + + async def stop_after_reconciliation(_seconds: float) -> None: + raise asyncio.CancelledError + + monkeypatch.setattr(runtime, "run", kernel.run) + + def fake_existing_owned_table(_p: dict[str, Any]) -> bool: + return recovered + + monkeypatch.setattr(runtime, "existing_owned_table", fake_existing_owned_table) + + def fake_owned_interface_routes(_p: dict[str, Any]) -> set[str] | None: + return initial_routes if recovered else None + + monkeypatch.setattr(runtime, "owned_interface_routes", fake_owned_interface_routes) + monkeypatch.setattr(runtime, "load_config", lambda: p) + + def fake_load_fences(_p: dict[str, Any]) -> dict[str, Any]: + return fence + + monkeypatch.setattr(runtime, "load_fences", fake_load_fences) + monkeypatch.setattr(runtime, "configure_peers", kernel.configure_peers) + monkeypatch.setattr(runtime, "notify_ready", kernel.notify_ready) + + def fake_public_status(_p: dict[str, Any], **_kwargs: object) -> None: + return None + + monkeypatch.setattr(runtime, "public_status", fake_public_status) + monkeypatch.setattr(runtime.asyncio, "sleep", stop_after_reconciliation) + with pytest.raises(asyncio.CancelledError): + await runtime.wireguard(p) + assert kernel.ready + assert kernel.denied + assert not kernel.up + assert kernel.grants == int(enabled) + + +def test_stale_socket_recovery_refuses_live_listener_and_regular_file(tmp_path: Path) -> None: + tmp_path.chmod(0o700) + relay = runtime.Relay(imported()) + relay.lock_producer(tmp_path) + path = tmp_path / "route.sock" + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as producer: + producer.bind(str(path)) + path.chmod(0o600) + producer.listen(1) + with pytest.raises(ValueError, match="refused"): + relay.recover_socket(path) + assert path.exists() + relay.recover_socket(path) + assert not path.exists() + path.write_text("not a socket") + path.chmod(0o600) + with pytest.raises(ValueError, match="refused"): + relay.recover_socket(path) + assert path.read_text() == "not a socket" + finally: + assert relay.lock_descriptor is not None + os.close(relay.lock_descriptor) + + +def test_producer_lock_refuses_second_live_producer_and_symlink(tmp_path: Path) -> None: + tmp_path.chmod(0o700) + first, second = runtime.Relay(imported()), runtime.Relay(imported()) + first.lock_producer(tmp_path) + try: + with pytest.raises(BlockingIOError): + second.lock_producer(tmp_path) + target = tmp_path / "target" + target.write_text("untouched") + path = tmp_path / "route.sock" + path.symlink_to(target) + with pytest.raises(ValueError, match="refused"): + first.recover_socket(path) + assert target.read_text() == "untouched" + assert path.is_symlink() + finally: + assert first.lock_descriptor is not None + os.close(first.lock_descriptor) + + +# Default nft formatting retains symbolic protocol/state names and numeric addresses/ports. +_OBSERVED_NFT = """table inet lychd_tether { + comment "lychd-tether:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:remote-b" + chain input { + type filter hook input priority mangle; policy drop; + iifname != "wg0" meta nfproto ipv4 udp dport 51820 accept + iifname "wg0" ip saddr 10.77.0.2 ip daddr 10.77.0.1 tcp dport 443 ct state established,new accept + } + chain output { + type filter hook output priority mangle; policy drop; + oifname != "wg0" meta nfproto ipv4 udp sport 51820 accept + oifname "wg0" ip saddr 10.77.0.1 ip daddr 10.77.0.2 tcp sport 443 ct state established accept + } + chain forward { + type filter hook forward priority mangle; policy drop; + } +} +""" + + +@pytest.mark.parametrize( + ("before", "after"), + [ + ("", ""), + ("dport 443", "dport 444"), + ('iifname != "wg0"', 'iifname "wg0"'), + ("hook forward priority mangle; policy drop", "hook forward priority mangle; policy accept"), + ("nfproto ipv4", "nfproto ipv6"), + ("nfproto ipv4", "nfproto 2"), + ("ct state established,new", "ct state established,related"), + ("priority mangle", "priority filter"), + ("udp dport 51820 accept", "udp dport 51820 drop"), + (' iifname != "wg0" meta nfproto ipv4 udp dport 51820 accept\n', ""), + ( + ' iifname != "wg0" meta nfproto ipv4 udp dport 51820 accept\n iifname "wg0" ip saddr 10.77.0.2 ip daddr 10.77.0.1 tcp dport 443 ct state established,new accept\n', + ' iifname "wg0" ip saddr 10.77.0.2 ip daddr 10.77.0.1 tcp dport 443 ct state established,new accept\n iifname != "wg0" meta nfproto ipv4 udp dport 51820 accept\n', + ), + (" chain forward {", " chain forward { accept"), + ("ct state established,new accept", "accept ct state established,new"), + ], +) +def test_public_status_requires_exact_default_formatted_nft_policy( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, before: str, after: str +) -> None: + p = wg() + p["listen_port"] = 51820 + p["peers"][0]["endpoint_address"] = None + p["peers"][0]["endpoint_port"] = None + p["ingress"] = [{"peer_address": "10.77.0.2", "port": 443}] + p["egress"] = [] + runtime.validate_config(p) + f = fences(p) + f["peers"] = [{"id": "lich-b", "generation": 3, "enabled": True}] + observed = _OBSERVED_NFT.replace(before, after) if before else _OBSERVED_NFT + assert not before or observed != _OBSERVED_NFT + calls: list[tuple[str, ...]] = [] + public_key = p["peers"][0]["public_key"] + + def fake_run(argv: tuple[str, ...]) -> str: + calls.append(argv) + if argv[0] == runtime.NFT: + # Default output never opts into reverse DNS, service names or numeric enum names. + assert argv == (runtime.NFT, "--stateless", "list", "table", "inet", "lychd_tether") + return observed + assert argv[:3] == (runtime.WG, "show", "wg0") + return {"peers": public_key, "allowed-ips": public_key + " 10.77.0.2/32", "public-key": public_key}[argv[3]] + + def current_fences(_p: dict[str, Any]) -> dict[str, Any]: + return f + + def current_capabilities(_role: str) -> dict[str, str]: + return {"CapEff": "1000"} + + status = tmp_path / "status" / "status.json" + monkeypatch.setattr(runtime, "run", fake_run) + monkeypatch.setattr(runtime, "load_fences", current_fences) + monkeypatch.setattr(runtime, "capabilities", current_capabilities) + monkeypatch.setattr(runtime, "STATUS", status) + if before: + with pytest.raises(ValueError, match="refused"): + runtime.public_status(p, fence=f, wg=True) + assert not status.exists() + else: + runtime.public_status(p, fence=f, wg=True) + result = json.loads(status.read_text()) + assert result["enabled"] is True + assert result["peer_public_keys"] == [public_key] + assert result["public_key"] == public_key + assert len(calls) == 4 + + +class HeartbeatKernel: + """Interleave publication with a stateful kernel and real heartbeat validation.""" + + def __init__(self, publication: str, drift: str, status_path: Path) -> None: + self.publication = publication + self.drift = drift + self.config = wg() + self.config["listen_port"] = 51820 + self.config["peers"][0].update(endpoint_address=None, endpoint_port=None) + second = { + **self.config["peers"][0], + "id": "lich-c", + "public_key": base64.b64encode(b"\x02" * 32).decode(), + "allowed_ips": ["10.77.0.3/32"], + } + self.config["peers"].append(second) + self.config["ingress"] = [{"peer_address": address, "port": 443} for address in ("10.77.0.2", "10.77.0.3")] + self.config["egress"] = [] + runtime.validate_config(self.config) + self.first = fences(self.config) + self.first["peers"] = [{"id": "lich-b", "generation": 3, "enabled": True}] + self.replacement = { + **self.first, + "peers": [*self.first["peers"], {"id": "lich-c", "generation": 3, "enabled": True}], + } + self.latest: dict[str, Any] | None = self.first + self.applied: list[dict[str, Any]] = [] + self.routes: set[str] = set() + self.policy = "" + self.up = self.deleted = False + self.ready = self.sleeps = 0 + self.clock = 10.0 + self.status = status_path + + def run(self, argv: tuple[str, ...], *, content: str | None = None) -> str: + if argv == (runtime.NFT, "-f", "-"): + assert content is not None + self.policy = content[content.index("table inet lychd_tether {") :] + return "" + if argv == (runtime.NFT, "--stateless", "list", "table", "inet", "lychd_tether"): + return self.policy.replace("policy drop", "policy accept", 1) if self.drift == "nft" else self.policy + if argv[:3] == (runtime.WG, "show", "wg0"): + if argv[3] == "peers": + return "" if self.drift == "peers" else "\n".join(row["public_key"] for row in self.applied) + if argv[3] == "allowed-ips": + return "\n".join(row["public_key"] + " " + " ".join(row["allowed_ips"]) for row in self.applied) + assert argv[3] == "public-key" + return self.config["peers"][0]["public_key"] + assert argv[0] == runtime.IP + assert "accept" not in self.policy + if argv[1:3] == ("link", "set") and argv[-1] == "up": + self.up = True + elif argv[1:3] == ("route", "add"): + assert self.up + self.routes.add(argv[3]) + elif argv[1:3] == ("route", "delete"): + self.routes.remove(argv[3]) + elif argv[1:3] == ("link", "delete"): + self.deleted = True + self.up = False + return "" + + def configure(self, config: dict[str, Any], current: dict[str, Any] | None) -> None: + assert "accept" not in self.policy + admitted: set[str] = ( + set() + if current is None or not current["enabled"] + else {row["id"] for row in current["peers"] if row["enabled"]} + ) + self.applied = [row for row in config["peers"] if row["id"] in admitted] + + def notify(self) -> None: + self.ready += 1 + if self.ready == 1: + # Publish after kernel reconciliation but before the first heartbeat. + self.latest = self.replacement if self.publication == "enroll" else None + + def load_fences(self, _config: dict[str, Any]) -> dict[str, Any] | None: + return self.latest + + def capabilities(self, _role: str) -> dict[str, str]: + return {"CapEff": "1000"} + + def existing_table(self, _config: dict[str, Any]) -> bool: + return False + + def existing_routes(self, _config: dict[str, Any]) -> set[str] | None: + return None + + async def polling_boundary(self, _seconds: float) -> None: + self.sleeps += 1 + assert self.up + assert not self.deleted + heartbeat = json.loads(self.status.read_text()) + if self.sleeps == 1: + assert self.routes == {"10.77.0.2/32"} + assert heartbeat["peer_public_keys"] == [self.config["peers"][0]["public_key"]] + assert ( + heartbeat["fence_digest"] == hashlib.sha256(json.dumps(self.first, sort_keys=True).encode()).hexdigest() + ) + with pytest.raises(ValueError, match="refused"): + runtime.observe() + self.clock += 1 + else: + assert self.sleeps == self.ready == 2 + assert self.routes == ({"10.77.0.2/32", "10.77.0.3/32"} if self.publication == "enroll" else set()) + assert ( + heartbeat["fence_digest"] + == hashlib.sha256(json.dumps(self.latest, sort_keys=True).encode()).hexdigest() + ) + assert heartbeat["enabled"] is (self.publication == "enroll") + assert heartbeat["peer_public_keys"] == sorted(row["public_key"] for row in self.applied) + runtime.observe() + # Deterministic test termination only, after both coherent observations. + raise asyncio.CancelledError + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publication", ["enroll", "missing"]) +@pytest.mark.parametrize("drift", ["none", "peers", "nft"]) +async def test_wireguard_heartbeat_uses_applied_fence_and_consumer_requires_latest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, publication: str, drift: str +) -> None: + kernel = HeartbeatKernel(publication, drift, tmp_path / "status" / "status.json") + monkeypatch.setattr(runtime, "run", kernel.run) + monkeypatch.setattr(runtime, "configure_peers", kernel.configure) + monkeypatch.setattr(runtime, "notify_ready", kernel.notify) + monkeypatch.setattr(runtime, "load_config", lambda: kernel.config) + monkeypatch.setattr(runtime, "load_fences", kernel.load_fences) + monkeypatch.setattr(runtime, "capabilities", kernel.capabilities) + monkeypatch.setattr(runtime, "existing_owned_table", kernel.existing_table) + monkeypatch.setattr(runtime, "owned_interface_routes", kernel.existing_routes) + monkeypatch.setattr(runtime, "STATUS", kernel.status) + monkeypatch.setattr(runtime.time, "monotonic", lambda: kernel.clock) + monkeypatch.setattr(runtime.asyncio, "sleep", kernel.polling_boundary) + if drift == "none": + with pytest.raises(asyncio.CancelledError): + await runtime.wireguard(kernel.config) + assert kernel.sleeps == 2 + else: + with pytest.raises(ValueError, match="refused"): + await runtime.wireguard(kernel.config) + assert kernel.sleeps == 0 + assert not kernel.status.exists() + assert kernel.deleted + assert not kernel.up + assert "accept" not in kernel.policy diff --git a/tests/unit/system/services/test_tether_service.py b/tests/unit/system/services/test_tether_service.py new file mode 100644 index 00000000..878f7e27 --- /dev/null +++ b/tests/unit/system/services/test_tether_service.py @@ -0,0 +1,1060 @@ +"""Lifecycle regressions with real multi-attachment compilation and effect ports. + +Run in the repository's admitted OCI test environment; these tests do not invoke +Podman, systemd, the actual Crypt, or a live application. +""" + +from __future__ import annotations + +import base64 +import json +from contextlib import nullcontext +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from lychd.config.quadlet import QuadletConfig +from lychd.domain.tether.models import ( + AttachmentAdmission, + ImportedAttachment, + ImportedService, + TetherRune, + TetherSnapshot, +) +from lychd.system.services.tether import service as native +from lychd.system.services.tether.models import TetherError, TetherIntent +from lychd.system.services.tether.service import NativeState, NativeTetherService + +if TYPE_CHECKING: + from lychd.domain.tether.compiler import TetherDeployment + from lychd.system.services.tether.custody import TetherCustody + from lychd.system.services.tether.host import TetherHost + + +class MemoryCustody: + def __init__(self, root: Path) -> None: + self.root = root + self.records: dict[str, dict[str, Any]] = {} + self.modes: dict[str, bool] = {} + + def attest(self) -> None: + pass + + def read(self, name: str) -> dict[str, Any] | None: + value = self.records.get(name) + return None if value is None else json.loads(json.dumps(value)) + + def write(self, name: str, value: dict[str, Any], **_kwargs: Any) -> bool: + changed = self.records.get(name) != value + self.records[name] = json.loads(json.dumps(value)) + return changed + + def directory(self, name: str, *, public: bool = False) -> Path: + for part in (Path(name), *Path(name).parents): + if str(part) == ".": + continue + previous = self.modes.setdefault(str(part), public) + if previous != public: + msg = "Directory mode differs from custody" + raise TetherError(msg) + return self.root / name + + def publish(self, name: Path, value: dict[str, Any], **_kwargs: Any) -> bool: + return self.write(str(name), value) + + def known_paths(self) -> tuple[Path, ...]: + return (self.root, self.root / ".custody.json", *(self.root / name for name in sorted(self.records))) + + +class HostPort: + runner = None + + def __init__(self) -> None: + self.containers: dict[str, dict[str, Any]] = {} + self.pods: dict[str, dict[str, Any]] = {} + self.invocations: dict[str, int] = {} + self.starts: list[str] = [] + self.stops: list[str] = [] + self.failed_observation: str | None = None + self.pending_restarts: set[str] = set() + self.refuse_quiescence = False + + def image(self, _reference: str) -> dict[str, str]: + return {"id": "a" * 64, "digest": "sha256:" + "a" * 64} + + def host_addresses(self) -> tuple[str, ...]: + return () + + def runtime_directories(self, root: Path, directories: tuple[Path, ...]) -> None: + root.mkdir(mode=0o700, exist_ok=True) + for path in directories: + path.mkdir(mode=0o700, exist_ok=True) + + def exists(self, _kind: str, name: str) -> bool: + return name in (self.pods if _kind == "pod" else self.containers) + + def row(self, _kind: str, name: str) -> dict[str, Any]: + return (self.pods if _kind == "pod" else self.containers)[name] + + def command(self, *args: str) -> str: + assert args[:3] == ("systemctl", "--user", "start") + name = args[3].removesuffix(".service") + self.starts.append(name) + self.invocations[name] = self.invocations.get(name, 0) + 1 + pod_name = "-".join(name.split("-")[:3]) + if pod_name not in self.pods: + self.invocations[pod_name + "-pod"] = self.invocations.get(pod_name + "-pod", 0) + 1 + self.pods[pod_name] = {"Id": pod_name} + self.containers[name] = {"Id": name, "State": {"Running": True}} + return "" + + def capture(self, config: Any, _image: dict[str, str]) -> dict[str, Any]: + if not self.containers.get(config.name, {}).get("State", {}).get("Running"): + msg = "Not running" + raise TetherError(msg) + return { + "container_id": config.name, + "container_name": config.name, + "cgroup": config.name, + "invocation_id": str(self.invocations[config.name]), + "unit_cgroup": config.name, + "pod_id": "-".join(config.name.split("-")[:3]), + "netns": "-".join(config.name.split("-")[:3]), + } + + def unit_incarnation(self, name: str) -> dict[str, str]: + container = name.removesuffix(".service") + return {"InvocationID": str(self.invocations.get(container, 0)), "ControlGroup": container} + + def capture_pod(self, name: str) -> dict[str, str]: + assert name in self.pods + return { + "pod_id": name, + "infra_cgroup": name, + "netns": name, + "invocation_id": str(self.invocations[name + "-pod"]), + } + + def stop_pod(self, retained: dict[str, Any]) -> None: + self.pods.pop(retained["pod_id"], None) + + def observe(self, config: Any) -> dict[str, Any]: + if config.network_id == self.failed_observation: + msg = "Readiness failed" + raise TetherError(msg) + return {"enabled": True} + + def cgroup_empty(self, name: str) -> bool: + return not self.containers.get(name, {}).get("State", {}).get("Running", False) + + def stop_unretained(self, name: str) -> None: + if name in self.containers or self.refuse_quiescence: + message = "Unit closure remains uncertain" + raise TetherError(message) + self.pending_restarts.discard(name) + self.stops.append(name) + + def stop(self, retained: dict[str, Any]) -> None: + name = retained["container_id"] + self.stops.append(name) + self.containers.pop(name, None) + + +class ServiceHarness(NativeTetherService): + core_ready = True + rune: TetherRune + + def load_state(self) -> NativeState: + """Read the real persisted state through the test harness.""" + return self._load() + + def save_state(self, state: NativeState) -> bool: + """Seed the real persistence boundary without widening its production API.""" + return self._save(state) + + def _load_retained(self) -> NativeState: + # The Lifecycle foundation is an explicit test port; retain the actual + # strict persisted DTO roundtrip independently of fresh grant checks. + self.custody.attest() + return NativeState.model_validate_json(json.dumps(self.custody.read("state.json"))) + + def _retained_projection(self, state: NativeState) -> Any: + return self._projection(state) + + def _require_core(self) -> None: + if not self.core_ready: + message = "Core bootstrap required" + raise TetherError(message) + + def _declared(self) -> tuple[Any, Any, Any, TetherRune]: + return SimpleNamespace(server=SimpleNamespace(port=7134)), None, None, self.rune + + def _attest_keys(self, state: NativeState, deployment: Any) -> None: + pass + + def observe_real_keys(self, state: NativeState, deployment: TetherDeployment) -> None: + """Exercise the production batch boundary when a test supplies its key port.""" + super()._attest_keys(state, deployment) + + def bind_request(self, state_override: NativeState | None = None) -> Any: + return SimpleNamespace(tether=self._projection(state_override or self._load())) + + def _bound(self, request: Any) -> dict[str, dict[str, str]]: + del request + return {} + + def _bind(self, state: NativeState) -> dict[str, Any]: + self._materialize(state) + return {"state": "bound", "changed": False} + + +@pytest.fixture +def lifecycle(monkeypatch: pytest.MonkeyPatch) -> Any: + monkeypatch.setattr(native.os, "geteuid", lambda: 1000) + monkeypatch.setattr(native, "LifecycleLock", nullcontext) + # Deadline advancement bounds expected readiness failures without sleeping. + tick = iter(range(0, 100000, 20)) + monkeypatch.setattr(native.time, "monotonic", lambda: next(tick)) + + def skip_sleep(_seconds: float) -> None: + pass + + monkeypatch.setattr(native.time, "sleep", skip_sleep) + key = base64.b64encode(bytes(range(32))).decode() + attachments = tuple( + ImportedAttachment( + id=name, + remote_lich_id=name, + interface="wg0", + address="10.77.0.1/32", + private_key_ref=f"lychd-tether-{name}-private", + remote_public_key=key, + endpoint_address="198.51.100.10", + endpoint_port=51820, + services=( + ImportedService( + id="intercom", + address="10.77.0.2", + port=443, + hostname=f"{name}.example", + application_identity_ref=name, + ), + ), + allowed_callers=("intercom",), + ) + for name in ("b", "c") + ) + rune = TetherRune(quadlet=QuadletConfig(image="example/tether@sha256:" + "a" * 64), attachments=attachments) + admissions = tuple(AttachmentAdmission(attachment_id=name, generation=1) for name in ("b", "c")) + intent = TetherIntent(rune=rune, attachments=admissions) + with TemporaryDirectory(prefix="tv-") as temporary: + root = Path(temporary) + host = HostPort() + # Doubles implement the custody/host operations exercised by this service harness. + service = ServiceHarness( + custody=cast("TetherCustody", MemoryCustody(root / "custody")), + host=cast("TetherHost", host), + runtime_root=root / "run", + ) + service.rune = TetherRune.model_validate_json(rune.model_dump_json()).bind_source_file(root / "tether.toml") + service.save_state( + NativeState( + intent=intent, + snapshot=TetherSnapshot( + installation_epoch="ab" * 16, + attachments=admissions, + ), + image=host.image(rune.quadlet.image), + ) + ) + yield service, host + + +def test_json_reload_and_materialization_preserve_admission(lifecycle: Any) -> None: + service, _ = lifecycle + state = service._load() + assert service.rune.source_file is not None + assert state.intent.rune.source_file is None + assert service.rune != state.intent.rune + service._materialize(state) + service._materialize(state) + assert service._load() == state + assert service.custody.modes["runtime"] is True + assert len(service._projection(state).networks) == 2 + + +@pytest.mark.parametrize("prepared", [False, True]) +def test_prepare_accepts_loader_bound_rune_and_public_json_intent( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch, *, prepared: bool +) -> None: + service, host = lifecycle + before = service._load() + intent_path = service.custody.root.parent / "intent.json" + intent_path.write_text(before.intent.model_dump_json(by_alias=True)) + intent_path.chmod(0o600) + monkeypatch.setattr(service, "_empty_preparation", lambda: not prepared) + result = service.prepare(intent_path, dry_run=True) + assert result == {"state": "prepared" if prepared else "absent", "changed": not prepared, "dry_run": True} + assert service._load() == before + assert not host.starts + + +@pytest.mark.parametrize("operation", ["load", "prepare"]) +def test_public_rune_field_drift_is_refused_despite_matching_source_provenance(lifecycle: Any, operation: str) -> None: + service, host = lifecycle + before = service._load() + source_file = service.rune.source_file + assert source_file is not None + intent_path = service.custody.root.parent / "intent.json" + intent_path.write_text(before.intent.model_dump_json(by_alias=True)) + intent_path.chmod(0o600) + changed = service.rune.model_dump(mode="json") + changed["attachments"][0]["endpoint_port"] += 1 + service.rune = TetherRune.model_validate_json(json.dumps(changed)).bind_source_file(source_file) + operation_call = service._load if operation == "load" else lambda: service.prepare(intent_path, dry_run=True) + with pytest.raises(TetherError, match="differs"): + operation_call() + assert service._load_retained() == before + assert not host.starts + + +def test_repeated_activation_preserves_epoch_routes_and_incarnations(lifecycle: Any) -> None: + service, host = lifecycle + assert service.activate()["changed"] is True + state = service._load() + catalog = (service.runtime_root / "catalog.json").read_bytes() + starts = tuple(host.starts) + assert service.activate()["changed"] is False + assert service._load() == state + assert tuple(host.starts) == starts + assert (service.runtime_root / "catalog.json").read_bytes() == catalog + + +def test_wrong_generation_revoke_has_no_effect(lifecycle: Any) -> None: + service, host = lifecycle + service.activate() + state = service._load() + catalog = (service.runtime_root / "catalog.json").read_bytes() + with pytest.raises(TetherError, match="generation"): + service.revoke("b", None, "2") + assert service._load() == state + assert not host.stops + assert (service.runtime_root / "catalog.json").read_bytes() == catalog + + +def test_revoke_is_repeatable_and_preserves_other_attachment(lifecycle: Any) -> None: + service, host = lifecycle + service.activate() + before = service._load() + unaffected = tuple(c.name for c in service._projection(before).runtime_configs if c.network_id == "c") + assert service.revoke("b", None, "1")["physical_closure"] is True + assert service.revoke("b", None, "1")["changed"] is False + assert all(name not in host.stops for name in unaffected) + assert all(service._load().incarnations[name] == before.incarnations[name] for name in unaffected) + rows = json.loads((service.runtime_root / "catalog.json").read_bytes())["routes"] + assert {row["selection"]["attachment_id"] for row in rows} == {"c"} + + +def test_resume_starts_owned_stopped_incarnation(lifecycle: Any) -> None: + service, host = lifecycle + service.activate() + before = len(host.starts) + for row in host.containers.values(): + row["State"]["Running"] = False + service.resume() + assert len(host.starts) > before + assert all(row["State"]["Running"] for row in host.containers.values()) + + +def test_activation_readiness_failure_does_not_publish_routes(lifecycle: Any) -> None: + service, host = lifecycle + host.failed_observation = "c" + with pytest.raises(TetherError, match="Readiness"): + service.activate() + catalog = service.runtime_root / "catalog.json" + assert not catalog.exists() or json.loads(catalog.read_bytes())["routes"] == [] + + +def test_revoke_retries_physical_closure_after_catalog_failure(lifecycle: Any, monkeypatch: pytest.MonkeyPatch) -> None: + service, host = lifecycle + service.activate() + publish = service._catalog + with monkeypatch.context() as patch: + + def fail_catalog(*args: Any) -> None: + msg = "Catalog publication failed" + raise TetherError(msg) + + patch.setattr(service, "_catalog", fail_catalog) + with pytest.raises(TetherError, match="pending"): + service.revoke("b", None, "1") + assert service._catalog == publish + result = service.revoke("b", None, "1") + assert result["physical_closure"] is True + assert host.stops + rows = json.loads((service.runtime_root / "catalog.json").read_bytes())["routes"] + assert {row["selection"]["attachment_id"] for row in rows} == {"c"} + + +def test_readmission_requires_next_generation_and_stays_inactive(lifecycle: Any) -> None: + service, _ = lifecycle + service.activate() + service.revoke("b", None, "1") + with pytest.raises(TetherError, match="generation"): + service.admit("b", 1) + assert service.admit("b", 2)["changed"] is True + assert service.admit("b", 2)["changed"] is False + assert "b" not in service._load().active + assert "c" in service._load().active + selected = next(row for row in service._load().snapshot.attachments if row.attachment_id == "b") + assert selected.generation == 2 + assert selected.enabled + + +def test_activation_requires_core_before_any_network_effect(lifecycle: Any) -> None: + service, host = lifecycle + service.core_ready = False + before = service._load() + with pytest.raises(TetherError, match="Core"): + service.activate() + assert service._load() == before + assert not host.starts + assert not host.pods + + +def test_unretained_role_requires_pending_restart_cancellation(lifecycle: Any) -> None: + service, host = lifecycle + targets = {row.name for row in service._projection(service._load()).runtime_configs if row.network_id == "b"} + host.pending_restarts.update(targets) + host.refuse_quiescence = True + with pytest.raises(TetherError, match="pending"): + service.revoke("b", None, "1") + assert host.pending_restarts == targets + assert all(row["status"] == "pending" for row in service._load().revocations.values()) + host.refuse_quiescence = False + assert service.revoke("b", None, "1")["physical_closure"] is True + assert not host.pending_restarts + assert targets <= set(host.stops) + + +def test_recover_start_before_role_and_pod_receipt_save(lifecycle: Any, monkeypatch: pytest.MonkeyPatch) -> None: + service, host = lifecycle + write = service.custody.write + with monkeypatch.context() as patch: + + def interrupt_seal(name: str, value: dict[str, Any], **kwargs: Any) -> bool: + if name == "state.json" and value.get("incarnations"): + message = "Interrupted before role seal" + raise OSError(message) + return write(name, value, **kwargs) + + patch.setattr(service.custody, "write", interrupt_seal) + with pytest.raises(OSError, match="Interrupted"): + service.activate() + pending = service._load() + assert pending.launches + assert not pending.incarnations + assert not pending.pods + assert len(host.starts) == 1 + first = host.starts[0] + service.resume() + assert host.starts.count(first) == 1 + assert not service._load().launches + assert first in service._load().incarnations + assert service._load().pods + + +def test_revoke_closes_target_despite_unrelated_missing_key(lifecycle: Any, monkeypatch: pytest.MonkeyPatch) -> None: + service, host = lifecycle + service.activate() + before = service._load() + untouched = {row.name for row in service._projection(before).runtime_configs if row.network_id == "c"} + starts = tuple(host.starts) + with monkeypatch.context() as patch: + + def strict_load_refused() -> NativeState: + message = "Current image or configuration no longer admitted" + raise TetherError(message) + + def strict_bind_refused(_state: NativeState) -> dict[str, Any]: + message = "Unrelated attachment key is missing" + raise TetherError(message) + + patch.setattr(service, "_load", strict_load_refused) + patch.setattr(service, "_bind", strict_bind_refused) + result = service.revoke("b", None, "1") + assert result["physical_closure"] is True + assert result["bindings_reconciled"] is False + state = service._load() + assert state.revocations["b:@:1"]["status"] == "closed" + assert state.revocations["b:@:1"]["bindings_pending"] is True + assert "b" not in state.active + assert "c" in state.active + assert not untouched.intersection(host.stops) + assert tuple(host.starts) == starts + with pytest.raises(TetherError, match="pending revocation"): + service.resume() + assert service.revoke("b", None, "1")["bindings_reconciled"] is True + assert service.revoke("b", None, "1")["changed"] is False + + +def test_completed_journal_compacts_and_generation_churn_stays_bounded(lifecycle: Any) -> None: + service, _ = lifecycle + service.revoke("b", None, "1") + journal = service._load().revocations["b:@:1"] + assert "previous_snapshot" not in journal + assert "incarnations" not in journal + assert "pod" not in journal + service.admit("b", 2) + service.revoke("b", None, "2") + assert set(service._load().revocations) == {"b:@:2"} + + +def test_other_pending_withdrawal_does_not_block_independent_denial(lifecycle: Any) -> None: + service, host = lifecycle + host.refuse_quiescence = True + with pytest.raises(TetherError, match="pending"): + service.revoke("b", None, "1") + host.refuse_quiescence = False + assert service.revoke("c", None, "1")["physical_closure"] is True + assert service._load().revocations["b:@:1"]["status"] == "pending" + assert service._load().revocations["c:@:1"]["status"] == "complete" + assert service.revoke("b", None, "1")["physical_closure"] is True + + +def new_attachment(service: ServiceHarness, name: str = "d") -> ImportedAttachment: + original = service.load_state().intent.rune.attachments[0] + return original.model_copy( + update={"id": name, "remote_lich_id": name, "private_key_ref": f"lychd-tether-{name}-private"} + ) + + +def test_register_new_road_without_rewriting_original_or_starting(lifecycle: Any) -> None: + service, host = lifecycle + service.activate() + before = service._load() + starts = tuple(host.starts) + attachment = new_attachment(service) + assert service.register_attachment(attachment)["changed"] is True + assert service.register_attachment(attachment)["changed"] is False + after = service._load() + assert after.intent == before.intent + assert service.rune.model_dump(mode="json") == before.intent.rune.model_dump(mode="json") + assert after.snapshot == before.snapshot + assert after.active == before.active + assert after.incarnations == before.incarnations + assert after.pods == before.pods + assert tuple(host.starts) == starts + assert service._projection(after).routes == service._projection(before).routes + assert service._references(after)[attachment.private_key_ref] == "wireguard" + service.admit("d", 1) + admitted = service._load() + assert "d" not in admitted.active + assert {row.selection.attachment_id for row in service._projection(admitted).routes} == {"b", "c", "d"} + + +def test_registered_replacement_requires_closure_and_preserves_generation(lifecycle: Any) -> None: + service, _ = lifecycle + old = service._load().intent.rune.attachments[0] + replacement = old.model_copy(update={"remote_lich_id": "replacement", "endpoint_port": 51821}) + with pytest.raises(TetherError, match="Revoke"): + service.register_attachment(replacement) + service.revoke("b", None, "1") + service.register_attachment(replacement) + admission = next(row for row in service._load().snapshot.attachments if row.attachment_id == "b") + assert admission.generation == 1 + assert not admission.enabled + with pytest.raises(TetherError, match="generation"): + service.admit("b", 1) + service.admit("b", 2) + selected = next(row for row in service._projection(service._load()).routes if row.selection.attachment_id == "b") + assert selected.selection.remote_lich_id == "replacement" + assert selected.selection.generation == 2 + + +def test_retired_reference_cannot_move_to_another_road(lifecycle: Any) -> None: + service, _ = lifecycle + original = service._load().intent.rune.attachments[0] + service.revoke("b", None, "1") + service.register_attachment(original.model_copy(update={"private_key_ref": "lychd-tether-b-rotated"})) + incoming = new_attachment(service).model_copy(update={"private_key_ref": original.private_key_ref}) + with pytest.raises(TetherError, match="another road"): + service.register_attachment(incoming) + + +def test_managed_topology_cannot_change_image_or_offered_envelope(lifecycle: Any) -> None: + service, _ = lifecycle + state = service._load() + changed = state.intent.rune.model_copy(update={"quadlet": QuadletConfig(image="other@sha256:" + "b" * 64)}) + with pytest.raises(TetherError, match="only imported attachments"): + service.effective_rune(state.model_copy(update={"topology": changed})) + + +def test_registration_refuses_foreign_runtime_before_state_mutation(lifecycle: Any) -> None: + service, host = lifecycle + incoming = new_attachment(service) + before = service._load() + import hashlib + + pod = "lychd-tether-" + hashlib.sha256(incoming.id.encode()).hexdigest()[:16] + host.pods[pod] = {"Id": "foreign"} + with pytest.raises(TetherError, match="existing runtime"): + service.register_attachment(incoming) + assert service._load() == before + + +def test_registration_dry_run_and_staged_update_have_no_admission(lifecycle: Any) -> None: + service, host = lifecycle + incoming = new_attachment(service) + before = service._load() + assert service.register_attachment(incoming, dry_run=True)["changed"] is True + assert service._load() == before + service.register_attachment(incoming) + service.register_attachment(incoming.model_copy(update={"endpoint_port": 51821})) + assert service._load().snapshot == before.snapshot + assert not host.starts + + +def test_offered_only_registration_and_repeat_prepare_preserve_managed_road( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + from unittest.mock import Mock + + from lychd.domain.tether.models import OfferedNetwork + + service, host = lifecycle + incoming = new_attachment(service) + old = service._load() + offered = OfferedNetwork( + id="home", + interface="wg0", + address="10.55.0.1/24", + listen_address="192.0.2.10", + listen_port=51820, + private_key_ref="lychd-tether-home-private", + ) + initial = TetherRune(quadlet=old.intent.rune.quadlet, offered=offered) + intent = TetherIntent(rune=initial) + state = NativeState( + intent=intent, snapshot=TetherSnapshot(installation_epoch=old.snapshot.installation_epoch), image=old.image + ) + service.rune = initial + service._save(state) + service.register_attachment(incoming) + registered = service._load() + assert registered.intent == intent + assert service._projection(registered).routes == () + assert service._projection(registered).consumer_mount is not None + keys = Mock() + keys.attest_many.return_value = { + offered.private_key_ref: { + "reference": offered.private_key_ref, + "purpose": "wireguard", + "public_key": "public", + "fingerprint": "fingerprint", + } + } + + def declared_intent(_path: Path) -> TetherIntent: + return intent + + monkeypatch.setattr(native, "load_intent", declared_intent) + monkeypatch.setattr(service, "_empty_preparation", lambda: False) + + def key_gateway(_state: NativeState) -> Mock: + return keys + + monkeypatch.setattr(service, "_keys", key_gateway) + assert service.prepare(Path("/public-intent.json"))["changed"] is False + assert service._load() == registered + assert not host.starts + + +def test_effective_registry_preserves_other_runes(lifecycle: Any) -> None: + from lychd.config.runes.base import RuneConfig + from lychd.config.runes.registry import RuneRegistry + + service, _ = lifecycle + original = service._load().intent.rune + other = RuneConfig() + registry = RuneRegistry((other, original)) + service.register_attachment(new_attachment(service)) + effective = service._effective_registry(service._load(), registry) + assert effective.all()[0] == other + assert effective.one(TetherRune) == service.effective_rune(service._load()) + assert registry.one(TetherRune) == original + + +def owner_peer(name: str, number: int) -> Any: + from lychd.domain.tether.models import OfferedPeer, OwnerAccess + + ssh = "ssh-ed25519 " + base64.b64encode(b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + bytes(range(32))).decode() + return OfferedPeer( + id=name, + generation=1, + public_key=base64.b64encode(bytes(range(number, number + 32))).decode(), + address=f"10.55.0.{number + 2}", + owner_access=OwnerAccess( + ssh_public_key=ssh, ssh_host_key_ref=f"lychd-tether-owner-{name}", listen_port=2222 + number + ), + ) + + +def add_offered_network(service: ServiceHarness, peers: tuple[Any, ...]) -> NativeState: + from lychd.domain.tether.models import OfferedNetwork + + state = service.load_state() + offered = OfferedNetwork( + id="home", + interface="wg0", + address="10.55.0.1/24", + listen_address="192.0.2.10", + listen_port=51820, + private_key_ref="lychd-tether-home-private", + ) + rune = state.intent.rune.model_copy(update={"offered": offered}) + intent = state.intent.model_copy(update={"rune": rune, "offered_peers": peers}) + updated = state.model_copy( + update={"intent": intent, "snapshot": state.snapshot.model_copy(update={"offered_peers": peers})} + ) + service.rune = rune + service.save_state(updated) + return updated + + +def test_hot_new_owner_preserves_other_peers_and_imported_incarnations( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + from unittest.mock import Mock + + service, host = lifecycle + first, second = owner_peer("first", 0), owner_peer("second", 1) + add_offered_network(service, (first,)) + keys = Mock() + + def key_gateway(_state: NativeState) -> Mock: + return keys + + monkeypatch.setattr(service, "_keys", key_gateway) + service.activate() + before = service._load() + starts = tuple(host.starts) + before_configs = service._projection(before).runtime_configs + owner_before = next(row for row in before_configs if row.role == "owner-ssh") + imports_before = tuple(row for row in before_configs if row.network_id in {"b", "c"}) + assert service.enroll(second)["changed"] is True + after = service._load() + assert len(host.starts) == len(starts) + 1 + assert all(after.incarnations[name] == identity for name, identity in before.incarnations.items()) + owner_after = next(row for row in service._projection(after).runtime_configs if row.name == owner_before.name) + assert owner_after.payload == owner_before.payload + assert owner_after.name == owner_before.name + assert ( + tuple(row for row in service._projection(after).runtime_configs if row.network_id in {"b", "c"}) + == imports_before + ) + keys.ensure.assert_called_once_with(second.owner_access.ssh_host_key_ref, "ssh-host") + assert service.enroll(second)["changed"] is False + assert service._load() == after + assert len(host.starts) == len(starts) + 1 + + +def test_hot_first_owner_bridge_refuses_before_state_or_key_effects( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + from unittest.mock import Mock + + service, _ = lifecycle + add_offered_network(service, ()) + service.activate() + before = service._load() + keys = Mock() + + def key_gateway(_state: NativeState) -> Mock: + return keys + + monkeypatch.setattr(service, "_keys", key_gateway) + with pytest.raises(TetherError, match="Pod policy"): + service.enroll(owner_peer("first", 0)) + assert service._load() == before + keys.ensure.assert_not_called() + + +def test_hot_missing_preshared_key_refuses_before_grant(lifecycle: Any, monkeypatch: pytest.MonkeyPatch) -> None: + from unittest.mock import Mock + + service, _ = lifecycle + add_offered_network(service, (owner_peer("first", 0),)) + service.activate() + before = service._load() + keys = Mock() + keys.attest_many.side_effect = TetherError("Missing key") + + def key_gateway(_state: NativeState) -> Mock: + return keys + + monkeypatch.setattr(service, "_keys", key_gateway) + second = owner_peer("second", 1).model_copy(update={"preshared_key_ref": "lychd-tether-peer-psk"}) + with pytest.raises(TetherError, match="Missing key"): + service.enroll(second) + assert service._load() == before + keys.ensure.assert_not_called() + + +def test_hot_enrollment_retries_saved_peer_after_binding_failure( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + from unittest.mock import Mock + + service, host = lifecycle + first, second = owner_peer("first", 0), owner_peer("second", 1) + add_offered_network(service, (first,)) + keys = Mock() + + def key_gateway(_state: NativeState) -> Mock: + return keys + + monkeypatch.setattr(service, "_keys", key_gateway) + service.activate() + starts = tuple(host.starts) + before = service._load() + with monkeypatch.context() as patch: + + def fail_bind(_state: NativeState) -> dict[str, Any]: + message = "Binding interrupted" + raise TetherError(message) + + patch.setattr(service, "_bind", fail_bind) + with pytest.raises(TetherError, match="interrupted"): + service.enroll(second) + assert second in service._load().snapshot.offered_peers + assert service.enroll(second)["changed"] is True + assert len(host.starts) == len(starts) + 1 + assert all(service._load().incarnations[name] == value for name, value in before.incarnations.items()) + keys.ensure.assert_called_once() + assert service.enroll(second)["changed"] is False + + +def test_failed_peer_key_preparation_retains_reference_owner_without_grant( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + from unittest.mock import Mock + + service, _ = lifecycle + add_offered_network(service, (owner_peer("first", 0),)) + before = service._load() + second = owner_peer("second", 1) + keys = Mock() + keys.ensure.side_effect = TetherError("Interrupted key preparation") + + def key_gateway(_state: NativeState) -> Mock: + return keys + + monkeypatch.setattr(service, "_keys", key_gateway) + with pytest.raises(TetherError, match="Interrupted"): + service.enroll(second) + retained = service._load() + assert retained.snapshot == before.snapshot + assert retained.active == before.active + assert retained.key_owners[second.owner_access.ssh_host_key_ref] == "peer:second:ssh-host" + third = owner_peer("third", 2) + third = third.model_copy( + update={ + "owner_access": third.owner_access.model_copy( + update={"ssh_host_key_ref": second.owner_access.ssh_host_key_ref} + ) + } + ) + with pytest.raises(TetherError, match="another road"): + service.enroll(third) + keys.ensure.assert_called_once() + + +def test_failed_start_launch_retires_only_after_its_role_closes( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + service, host = lifecycle + + def fail_start(*_args: str) -> str: + message = "Start failed before creating a container" + raise TetherError(message) + + monkeypatch.setattr(host, "command", fail_start) + for network in ("b", "c"): + with pytest.raises(TetherError, match="Start failed"): + service._activate(service._load(), (network,)) + pending = service._load() + assert {row["network_id"] for row in pending.launches.values()} == {"b", "c"} + host.refuse_quiescence = True + with pytest.raises(TetherError, match="pending"): + service.revoke("b", None, "1") + assert service._load().launches == pending.launches + host.refuse_quiescence = False + assert service.revoke("b", None, "1")["physical_closure"] is True + assert service._load().launches == {name: row for name, row in pending.launches.items() if row["network_id"] == "c"} + original = pending.intent.rune.attachments[0] + replacement = original.model_copy(update={"endpoint_port": 51821}) + assert service.register_attachment(replacement)["changed"] is True + assert service.revoke("b", None, "1")["changed"] is False + + +def fail_after_pod_start(host: HostPort, *args: str) -> str: + name = args[3].removesuffix(".service") + pod = "-".join(name.split("-")[:3]) + host.invocations[pod + "-pod"] = host.invocations.get(pod + "-pod", 0) + 1 + host.pods[pod] = {"Id": pod} + message = "Start failed after creating the Pod" + raise TetherError(message) + + +def test_revocation_recovers_failed_start_pod_before_stopping_it( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + service, host = lifecycle + + def fail_start(*args: str) -> str: + return fail_after_pod_start(host, *args) + + monkeypatch.setattr(host, "command", fail_start) + with pytest.raises(TetherError, match="Start failed"): + service._activate(service._load(), ("b",)) + assert host.pods + assert not service._load().pods + assert not host.containers + stop_pod = host.stop_pod + + def verify_checkpoint(retained: dict[str, Any]) -> None: + assert service._load().revocations["b:@:1"]["pod"] == retained + stop_pod(retained) + + monkeypatch.setattr(host, "stop_pod", verify_checkpoint) + assert service.revoke("b", None, "1")["physical_closure"] is True + assert not host.pods + assert not service._load().launches + assert service.revoke("b", None, "1")["changed"] is False + original = service._load().intent.rune.attachments[0] + assert service.register_attachment(original.model_copy(update={"endpoint_port": 51821}))["changed"] is True + + +@pytest.mark.parametrize("proof", ["missing", "boot", "network", "policy", "unchanged_invocation"]) +def test_revocation_refuses_pod_without_exact_failed_launch_proof( + lifecycle: Any, monkeypatch: pytest.MonkeyPatch, proof: str +) -> None: + service, host = lifecycle + + def fail_start(*args: str) -> str: + return fail_after_pod_start(host, *args) + + monkeypatch.setattr(host, "command", fail_start) + with pytest.raises(TetherError, match="Start failed"): + service._activate(service._load(), ("b",)) + state = service._load() + name, launch = next(iter(state.launches.items())) + changes = { + "boot": {"boot_id": "another-boot"}, + "network": {"network_id": "c"}, + "policy": {"policy_digest": "other-policy"}, + "unchanged_invocation": {"previous_pod_invocation_id": "1"}, + } + launches = {} if proof == "missing" else {name: {**launch, **changes[proof]}} + service._save(state.model_copy(update={"launches": launches})) + before = dict(host.pods) + + def refuse_stop(_retained: dict[str, Any]) -> None: + pytest.fail("An unproved Pod must never be stopped") + + monkeypatch.setattr(host, "stop_pod", refuse_stop) + with pytest.raises(TetherError, match="pending"): + service.revoke("b", None, "1") + assert host.pods == before + assert service._load().revocations["b:@:1"]["status"] == "pending" + + +@pytest.mark.parametrize("operation", ["projection", "binding_context", "bind_request", "attest_bind_request"]) +def test_public_planning_and_commit_reobserve_changed_key_backend( + lifecycle: tuple[ServiceHarness, HostPort], monkeypatch: pytest.MonkeyPatch, operation: str +) -> None: + from unittest.mock import Mock + + from lychd.config.runes.registry import RuneRegistry + from lychd.config.settings import SettingsSnapshot + from lychd.extensions.host import assemble_extensions + from lychd.system.services.tether.keys import TetherKeys + + service, host = lifecycle + state = service.load_state() + settings = SettingsSnapshot(payload="{}").materialize() + settings.extensions.builtins = ("tether",) + settings.server.port = state.intent.web_port + extensions = assemble_extensions(settings) + runes = RuneRegistry((service.rune,)) + key_port = Mock(spec=TetherKeys) + key_port.attest_many.return_value = {} + + def key_gateway(_state: NativeState) -> Mock: + return key_port + + def declared() -> tuple[object, object, RuneRegistry, TetherRune]: + return settings, extensions, runes, service.rune + + def compile_request(state_override: NativeState | None = None) -> object: + return NativeTetherService.bind_request(service, state_override) + + monkeypatch.setattr(service, "_keys", key_gateway) + monkeypatch.setattr(service, "_declared", declared) + monkeypatch.setattr(service, "_attest_keys", service.observe_real_keys) + monkeypatch.setattr(service, "bind_request", compile_request) + request = service.bind_request() + expected_references = tuple(sorted(attachment.private_key_ref for attachment in state.intent.rune.attachments)) + key_port.attest_many.assert_called_once_with(expected_references) + before = service.load_state() + key_port.attest_many.reset_mock() + key_port.attest_many.side_effect = TetherError("Synthetic backend identity replaced") + + def invoke() -> None: + if operation == "attest_bind_request": + service.attest_bind_request(request) + else: + getattr(service, operation)() + + with pytest.raises(TetherError, match="identity replaced"): + invoke() + key_port.attest_many.assert_called_once_with(expected_references) + assert service.load_state() == before + assert not host.starts + assert not host.stops + + +def test_preparation_status_uses_one_batch_for_all_reference_phases( + lifecycle: tuple[ServiceHarness, HostPort], monkeypatch: pytest.MonkeyPatch +) -> None: + from unittest.mock import Mock + + from lychd.system.services.tether.keys import KeyObservation, TetherKeys + + service, _host = lifecycle + state = service.load_state() + references = tuple(sorted(attachment.private_key_ref for attachment in state.intent.rune.attachments)) + observation = KeyObservation( + {reference: {"reference": reference} for reference in references}, + dict.fromkeys(references, "ready"), + ) + key_port = Mock(spec=TetherKeys) + key_port.observe.return_value = observation + + def key_gateway(_state: NativeState) -> Mock: + return key_port + + monkeypatch.setattr(service, "_empty_preparation", lambda: False) + monkeypatch.setattr(service, "_keys", key_gateway) + result = service.preparation_status() + assert result["state"] == "prepared" + assert result["missing_secrets"] == [] + assert result["owned_secrets"] == list(references) + assert result["required_secrets"] == list(references) + key_port.observe.assert_called_once_with() + key_port.preparation_state.assert_not_called() + key_port.owned_references.assert_not_called() diff --git a/tests/unit/system/test_container_security.py b/tests/unit/system/test_container_security.py index 32600771..935bbc60 100644 --- a/tests/unit/system/test_container_security.py +++ b/tests/unit/system/test_container_security.py @@ -184,5 +184,5 @@ def test_podman_secret_read_returns_exact_masked_value() -> None: assert value.get_secret_value() == "synthetic-value" assert "synthetic-value" not in repr(value) runner.run.assert_called_once_with( - ("/usr/bin/podman", "secret", "inspect", "--showsecret", "secret"), timeout_s=5.0 + ("/usr/bin/podman", "--remote=false", "secret", "inspect", "--showsecret", "secret"), timeout_s=5.0 )