diff --git a/deployment/mission-core-map-access/access.json b/deployment/mission-core-map-access/access.json new file mode 100644 index 0000000..d4d1bfb --- /dev/null +++ b/deployment/mission-core-map-access/access.json @@ -0,0 +1 @@ +{"schemaVersion":"nodedc.mission-core-map-access.v1","state":"enabled"} diff --git a/infra/deploy-runner/build-mission-core-map-access-artifact.py b/infra/deploy-runner/build-mission-core-map-access-artifact.py new file mode 100644 index 0000000..7c4cc27 --- /dev/null +++ b/infra/deploy-runner/build-mission-core-map-access-artifact.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Deterministic data-only declaration for the registered NAS map access domain.""" +import argparse +import gzip +import hashlib +import io +import json +import re +import tarfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def build(patch, state="enabled"): + if not re.fullmatch(r"[A-Za-z0-9._-]{1,96}", patch) or state not in ("enabled", "disabled"): + raise ValueError("Invalid map access release") + descriptor = json.loads((ROOT / "deployment/mission-core-map-access/access.json").read_text()) + if descriptor != {"schemaVersion": "nodedc.mission-core-map-access.v1", "state": "enabled"}: + raise ValueError("Unexpected source contract") + descriptor["state"] = state + members = { + "manifest.env": f"id={patch}\ncomponent=mission-core-map-access\ntype=app-overlay\n".encode(), + "files.txt": b"access.json\n", + "payload/access.json": (json.dumps(descriptor, sort_keys=True, separators=(",", ":")) + "\n").encode(), + } + result = io.BytesIO() + with gzip.GzipFile(fileobj=result, mode="wb", filename="", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for name, content in members.items(): + member = tarfile.TarInfo(name); member.mode = 0o644; member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + return result.getvalue() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("patch") + parser.add_argument("--state", choices=("enabled", "disabled"), default="enabled") + args = parser.parse_args() + raw = build(args.patch, args.state) + target = ROOT / "infra/deploy-artifacts" / ("nodedc-" + args.patch + ".tgz") + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("xb") as output: + output.write(raw) + digest = hashlib.sha256(raw).hexdigest() + target.with_suffix(target.suffix + ".sha256").write_text(digest + " " + target.name + "\n") + print(json.dumps({"artifact": str(target), "sha256": digest, "bytes": len(raw)})) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index b4fb292..5b474fd 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -14,6 +14,7 @@ import pwd import re import secrets import shutil +import signal import socket import sqlite3 import stat @@ -2919,6 +2920,12 @@ GITEA_SALVAGE_UNSUPPORTED_SCHEMA_TABLES = tuple( ) COMPONENTS = { + "mission-core-map-access": { + "payload_root": Path("/volume1/docker/nodedc-platform/mission-core-map-access"), + "bootstrap_root": True, + "artifact_only": True, + "services": (), + }, "engine": { "payload_root": Path("/volume2/nodedc-demo"), "compose_root": Path("/volume2/nodedc-demo"), @@ -4894,6 +4901,11 @@ def allowed_payload_path(component, rel): if reason: die(f"path rejected: {rel}: {reason}") + if component == "mission-core-map-access": + if rel == "access.json": + return True + die("Mission Core Map Access accepts only access.json") + if component == "engine": if rel in ("nodedc-source/package.json", "nodedc-source/package-lock.json"): return True @@ -11490,6 +11502,8 @@ def load_artifact(artifact, work_dir): die(f"files.txt entry missing in payload: {rel}") validate_payload_tree(manifest["component"], payload_dir, entries) + if manifest["component"] == "mission-core-map-access": + read_map_access_descriptor(payload_dir, entries) if is_gitea_fresh_install_slice(manifest["component"], entries): validate_gitea_fresh_install_payload(payload_dir, entries) elif is_gitea_incident_salvage_slice(manifest["component"], entries): @@ -31181,6 +31195,172 @@ def accept_engine_n8n_runtime(descriptor): } +MAP_ACCESS_CONFIG = Path("/etc/ssh/sshd_config") +MAP_ACCESS_SSHD = Path("/usr/bin/sshd") +MAP_ACCESS_PID = Path("/var/run/sshd.pid") +MAP_ACCESS_BASE_SHA = "adf3da038acb21e30a1b79089f53917e7e82b469183889567b7d22f7b83163ba" +MAP_ACCESS_APPEND = ( + b"\n# BEGIN NODEDC MISSION CORE MAP ACCESS v1\n" + b"Match User dctouch\n" + b" AllowTcpForwarding local\n" + b" PermitOpen 127.0.0.1:18103\n" + b"Match all\n" + b"# END NODEDC MISSION CORE MAP ACCESS v1\n" +) + + +def read_map_access_descriptor(root, entries): + if tuple(entries) != ("access.json",): + die("Map access file selection mismatch") + value = read_strict_json(root / "access.json", "Map access descriptor", max_bytes=1024) + if (set(value) != {"schemaVersion", "state"} + or value["schemaVersion"] != "nodedc.mission-core-map-access.v1" + or value["state"] not in ("enabled", "disabled")): + die("Map access descriptor mismatch") + return value["state"] + + +def map_access_policy(raw, desired): + base = raw[:-len(MAP_ACCESS_APPEND)] if raw.endswith(MAP_ACCESS_APPEND) else raw + if hashlib.sha256(base).hexdigest() != MAP_ACCESS_BASE_SHA: + die("Map access SSH policy predecessor drift") + return base + MAP_ACCESS_APPEND if desired == "enabled" else base + + +def map_access_read_config(): + metadata = MAP_ACCESS_CONFIG.lstat() + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 + or metadata.st_mode & 0o022 or metadata.st_size > 65536): + die("Map access SSH configuration metadata rejected") + return MAP_ACCESS_CONFIG.read_bytes() + + +def map_access_effective(config, user, address): + result = subprocess.run( + [str(MAP_ACCESS_SSHD), "-T", "-f", str(config), "-C", + f"user={user},host=localhost,addr={address}"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + die("Map access SSH effective-policy validation failed") + return dict(line.split(" ", 1) for line in result.stdout.splitlines() if " " in line) + + +def map_access_validate_candidate(before, candidate): + check = subprocess.run([str(MAP_ACCESS_SSHD), "-t", "-f", str(candidate)], + capture_output=True, timeout=10) + if check.returncode != 0: + die("Map access SSH syntax validation failed") + enabled = candidate.read_bytes().endswith(MAP_ACCESS_APPEND) + with tempfile.NamedTemporaryFile(dir=TMP_DIR) as original: + original.write(before); original.flush() + for address in ("127.0.0.1", "100.114.248.4"): + for user in ("dctouch", "root", "admin", "anonymous"): + old = map_access_effective(original.name, user, address) + new = map_access_effective(candidate, user, address) + if user == "dctouch": + old["allowtcpforwarding"] = "local" if enabled else "no" + old["permitopen"] = "127.0.0.1:18103" if enabled else "any" + if old != new: + die("Map access would change unrelated SSH authority") + + +def preflight_map_access(root, entries): + desired = read_map_access_descriptor(root, entries) + before = map_access_read_config() + after = map_access_policy(before, desired) + with tempfile.NamedTemporaryFile(dir=TMP_DIR) as candidate: + candidate.write(after); candidate.flush() + map_access_validate_candidate(before, Path(candidate.name)) + return before, after + + +def map_access_replace(raw): + # Same-filesystem atomic replace; host policy is never accepted from an artifact. + original = MAP_ACCESS_CONFIG.stat() + fd, name = tempfile.mkstemp(prefix=".nodedc-map-", dir=MAP_ACCESS_CONFIG.parent) + try: + with os.fdopen(fd, "wb") as stream: + os.fchown(stream.fileno(), 0, original.st_gid) + os.fchmod(stream.fileno(), stat.S_IMODE(original.st_mode)) + stream.write(raw); stream.flush(); os.fsync(stream.fileno()) + os.replace(name, MAP_ACCESS_CONFIG) + directory_fd = os.open(str(MAP_ACCESS_CONFIG.parent), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if os.path.exists(name): + os.unlink(name) + + +def map_access_reload(): + metadata = MAP_ACCESS_PID.lstat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_mode & 0o022: + die("Map access SSH PID metadata rejected") + pid = int(MAP_ACCESS_PID.read_text().strip()) + if pid <= 1 or Path(f"/proc/{pid}/exe").resolve() != MAP_ACCESS_SSHD.resolve(): + die("Map access SSH master identity mismatch") + os.kill(pid, signal.SIGHUP) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", 22), timeout=2) as connection: + connection.settimeout(2) + if connection.recv(255).startswith(b"SSH-2.0-"): + return + except OSError: + pass + time.sleep(0.25) + die("Map access SSH listener did not recover") + + +def backup_map_access(backup_dir): + raw = map_access_read_config() + map_access_policy(raw, "enabled") + path = backup_dir / "sshd-config-before" + with path.open("xb") as output: + os.fchmod(output.fileno(), 0o600) + output.write(raw); output.flush(); os.fsync(output.fileno()) + + +def apply_map_access(root, entries): + before, after = preflight_map_access(root, entries) + if after != before: + map_access_replace(after) + map_access_reload() + + +def accept_map_access(root, entries): + before, after = preflight_map_access(root, entries) + if before != after: + die("Map access installed policy differs from the requested state") + healthcheck_url({ + "url": "http://127.0.0.1:18103/healthz", + "headers": {"x-nodedc-user-id": "mission-core-loopback-operator"}, + "expected_json": {"ok": True, "service": "nodedc-map-gateway"}, + }) + + +def rollback_map_access(root, backup_dir, entries, current_stamp): + original = backup_dir / "sshd-config-before" + metadata = original.lstat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_mode & 0o077: + die("Map access backup metadata rejected") + raw = original.read_bytes() + map_access_policy(raw, "enabled") + current = map_access_read_config() + map_access_policy(current, "enabled") + map_access_validate_candidate(current, original) + if raw != current: + map_access_replace(raw) + map_access_reload() + restore_overlay_source(root, backup_dir, entries, current_stamp) + if map_access_read_config() != raw: + die("Map access rollback policy verification failed") + + def plan_artifact(artifact): validate_artifact_location(artifact) ensure_layout() @@ -31237,6 +31417,8 @@ def plan_artifact(artifact): validate_gitea_compose_schema(payload_dir / GITEA_COMPOSE_REL) if is_gitea_incident_salvage_slice(manifest["component"], entries): validate_gitea_compose_schema(payload_dir / GITEA_COMPOSE_REL) + if manifest["component"] == "mission-core-map-access": + preflight_map_access(payload_dir, entries) reject_failed_artifact_replay(manifest, sha) reject_terminal_engine_l2_failed_artifact(manifest, sha) reject_terminal_device_plane_foundation_artifact(manifest, sha) @@ -31820,6 +32002,10 @@ def plan_artifact(artifact): print(f"component={component}") print(f"type={manifest['type']}") print(f"payload_root={root}") + if component == "mission-core-map-access": + print("access=local-forward:dctouch:127.0.0.1:18103") + print("runtime=sshd-scoped-reload;docker=untouched;cache=preserved") + print("policy_predecessor=exact;rollback=automatic") print(f"compose_root={compose_root}") compose_project = component_compose_project(component) if compose_project: @@ -37346,6 +37532,9 @@ process.stdout.write('engine-l2-closed-loop:0.7.0:cas+safe-profile+external-plan def run_healthchecks(component, entries=None, services=None): + if component == "mission-core-map-access": + accept_map_access(component_root(component), entries) + return if is_gitea_fresh_install_slice(component, entries): if tuple(services or ()) != (GITEA_SERVICE,): die("Gitea fresh-install service set mismatch") @@ -38486,6 +38675,8 @@ def apply_artifact(artifact): compose_root = component_compose_root(component) bootstrap_root = bool(COMPONENTS[component].get("bootstrap_root")) services = component_services(component, entries) + if component == "mission-core-map-access": + preflight_map_access(payload_dir, entries) artifact_only = component_artifact_only(component) defer_bootstrap_root = is_gitea_bootstrap_slice( component, @@ -39011,6 +39202,8 @@ def apply_artifact(artifact): include_nginx_html = component == "engine" and component_publish_dist(component, entries) create_backup(root, backup_dir, entries, include_nginx_html) + if component == "mission-core-map-access": + backup_map_access(backup_dir) if component == "device-plane": if device_plane_runtime_before is None: die("Device Plane pre-apply runtime inventory is missing") @@ -39077,6 +39270,9 @@ def apply_artifact(artifact): for rel in entries: seal_n8n_private_extension_release(root, rel) + if component == "mission-core-map-access": + apply_map_access(root, entries) + if component_publish_dist(component, entries): publish_engine_dist(root, current_stamp) @@ -39194,7 +39390,13 @@ def apply_artifact(artifact): and backup_dir is not None and root is not None ): - if is_gitea_fresh_install_slice(component, entries): + if component == "mission-core-map-access": + try: + rollback_map_access(root, backup_dir, entries, current_stamp) + rollback_status = "ok:mission-core-map-access" + except Exception as rollback_exc: + rollback_status = f"failed:{type(rollback_exc).__name__}" + elif is_gitea_fresh_install_slice(component, entries): try: restored_action = rollback_gitea_fresh_install( root, diff --git a/infra/deploy-runner/test_mission_core_map_access.py b/infra/deploy-runner/test_mission_core_map_access.py new file mode 100644 index 0000000..38b4a6e --- /dev/null +++ b/infra/deploy-runner/test_mission_core_map_access.py @@ -0,0 +1,95 @@ +import hashlib +import importlib.machinery +import importlib.util +import io +import json +import stat +from types import SimpleNamespace +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parent + +def load(name, path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec); loader.exec_module(module) + return module + +R = load("map_access_runner", ROOT / "nodedc-deploy") +B = load("map_access_builder", ROOT / "build-mission-core-map-access-artifact.py") + +class MapAccessTests(unittest.TestCase): + def test_scope_never_selects_docker_or_accepts_host_policy_payload(self): + name = "mission-core-map-access" + self.assertEqual(R.component_services(name, ["access.json"]), ()) + self.assertEqual(R.component_builds(name, ["access.json"]), ()) + self.assertTrue(R.component_artifact_only(name)) + self.assertTrue(R.allowed_payload_path(name, "access.json")) + for path in ("sshd_config", "../sshd_config", "script.py", "docker-compose.yml", ".env", "secrets/key"): + with self.assertRaises(R.DeployError): R.allowed_payload_path(name, path) + + def test_deterministic_data_bundle_and_fail_closed_descriptor(self): + raw = B.build("map-access-test-001") + self.assertEqual(raw, B.build("map-access-test-001")) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact = root / "candidate.tgz" + artifact.write_bytes(raw) + with tarfile.open(fileobj=io.BytesIO(raw)) as archive: + self.assertEqual(archive.getnames(), ["manifest.env", "files.txt", "payload/access.json"]) + unpacked = root / "unpacked" + unpacked.mkdir() + manifest, entries, payload = R.load_artifact(artifact, unpacked) + self.assertEqual(manifest["component"], "mission-core-map-access") + self.assertEqual(R.read_map_access_descriptor(payload, entries), "enabled") + for value in ({"schemaVersion":"nodedc.mission-core-map-access.v1","state":"enabled","user":"root"}, {"schemaVersion":"unknown","state":"enabled"}): + (payload/"access.json").write_text(json.dumps(value)) + with self.assertRaises(R.DeployError): R.read_map_access_descriptor(payload, entries) + + def test_exact_predecessor_idempotence_and_explicit_disable(self): + base = b"AllowTcpForwarding no\n" + with patch.object(R, "MAP_ACCESS_BASE_SHA", hashlib.sha256(base).hexdigest()): + enabled = R.map_access_policy(base, "enabled") + self.assertEqual(enabled, base + R.MAP_ACCESS_APPEND) + self.assertEqual(R.map_access_policy(enabled, "enabled"), enabled) + self.assertEqual(R.map_access_policy(enabled, "disabled"), base) + for drift in (base+b"# external drift\n", enabled+b"# external drift\n", enabled+R.MAP_ACCESS_APPEND): + with self.assertRaises(R.DeployError): R.map_access_policy(drift, "enabled") + + def test_effective_policy_may_only_change_exact_user_and_destination(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(R, "TMP_DIR", Path(tmp)), patch.object(R.subprocess,"run") as run: + run.return_value.returncode=0 + candidate=Path(tmp)/"candidate";candidate.write_bytes(R.MAP_ACCESS_APPEND) + baseline={"allowtcpforwarding":"no", "permitopen":"any", "passwordauthentication":"yes"} + def effective(config,user,address): + value=dict(baseline) + if str(config)==str(candidate) and user=="dctouch":value.update(allowtcpforwarding="local",permitopen="127.0.0.1:18103") + return value + with patch.object(R,"map_access_effective",side_effect=effective):R.map_access_validate_candidate(b"baseline",candidate) + def broad(config,user,address): + value=effective(config,user,address) + if str(config)==str(candidate):value["permitopen"]="any" + return value + with patch.object(R,"map_access_effective",side_effect=broad), self.assertRaises(R.DeployError):R.map_access_validate_candidate(b"baseline",candidate) + + def test_reload_failure_is_routed_to_domain_rollback(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(R,"preflight_map_access",return_value=(b"old",b"new")), patch.object(R,"map_access_replace") as replace, patch.object(R,"map_access_reload",side_effect=R.DeployError("reload")): + with self.assertRaises(R.DeployError):R.apply_map_access(Path(tmp),["access.json"]) + replace.assert_called_once_with(b"new") + + def test_rollback_restores_exact_policy_and_descriptor(self): + base=b"AllowTcpForwarding no\n" + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp); backup=root/"backup"; backup.mkdir() + (backup/"sshd-config-before").write_bytes(base) + calls=[] + metadata=SimpleNamespace(st_mode=stat.S_IFREG|0o600,st_uid=0) + with patch.object(R,"MAP_ACCESS_BASE_SHA",hashlib.sha256(base).hexdigest()), patch.object(Path,"lstat",return_value=metadata), patch.object(R,"map_access_read_config",side_effect=[base+R.MAP_ACCESS_APPEND,base]), patch.object(R,"map_access_validate_candidate"), patch.object(R,"map_access_replace",side_effect=lambda value:calls.append(("replace",value))), patch.object(R,"map_access_reload",side_effect=lambda:calls.append(("reload",))), patch.object(R,"restore_overlay_source",side_effect=lambda *args:calls.append(("restore",))): + R.rollback_map_access(root,backup,["access.json"],"stamp") + self.assertEqual(calls,[("replace",base),("reload",),("restore",)]) + +if __name__ == "__main__": unittest.main()