From d1f581d6a80db131016025f2bf2c1cbad168bf71 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 9 Oct 2026 19:18:54 -0400 Subject: [PATCH] Verify prepared runtime IDs at launch (Refs #93) --- agent/ec_agent.py | 27 +++++++++--- agent/tests/test_agent.py | 88 ++++++++++++++++++++++++++++++++++++- docs/guides/native-hosts.md | 6 +++ static/agent/ec-agent.py | 27 +++++++++--- static/agent/manifest.json | 4 +- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/agent/ec_agent.py b/agent/ec_agent.py index e7969c5..90d8bb0 100644 --- a/agent/ec_agent.py +++ b/agent/ec_agent.py @@ -35,7 +35,7 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.8.6" +VERSION = "1.8.7" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 MANIFEST_MAX_ENTRIES = 10000 @@ -1643,7 +1643,20 @@ class Agent: raise AgentError("Invalid runtime environment") # Runtime download is a separate preparation operation. A deployment # uses the already prepared image even during a registry outage. - self.docker.call("image", "inspect", image) + image_id = self.docker.call("image", "inspect", "--format", "{{.Id}}", image).strip() + if not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id): + raise AgentError("Docker did not report a valid runtime image ID") + try: + raw = managed_read(self.root / "agent", "runtime-images.json") + prepared = json.loads(raw) if raw is not None else {} + except (OSError, ValueError): + raise AgentError("Runtime preparation records are unavailable") from None + if not isinstance(prepared, dict): + raise AgentError("Runtime preparation records are invalid") + # The controller also authorizes its configured default without a pull + # record. Whenever preparation was recorded, enforce that exact image. + if image in prepared and prepared[image] != image_id: + raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.") files = payload.get("files", []) if not isinstance(files, list) or len(files) > 10000: raise Rejected("Invalid initial file list") @@ -1656,7 +1669,7 @@ class Agent: total += len(self.decode_file(item)) if total > 64 * 1024 * 1024: raise Rejected("Initial files exceed the 64 MiB operation limit") - return game, library, image, port, bind, env + return game, library, image, image_id, port, bind, env @staticmethod def plutonium_manifest(data): @@ -1890,7 +1903,7 @@ class Agent: raise AgentError("Instance already exists with a different specification") return existing self.operation_phase = "validating-create-spec" - game, library, image, port, bind, env = self.validate_spec(payload) + game, library, image, image_id, port, bind, env = self.validate_spec(payload) app = directory / "app" self.operation_phase = "preparing-create-directory" app.mkdir(parents=True, exist_ok=True) @@ -1905,15 +1918,15 @@ class Agent: atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode()) name = self.container_name(instance_id, generation) spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()) - # Do not let Docker fetch a replacement if the prepared image disappears - # between validation and container creation. + # Use the validated immutable ID so retagging after validation cannot + # substitute another image. A deleted image must not trigger a pull. args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)] if self.config.get("hostId"): args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])]) bind_arg = "[" + bind + "]" if ":" in bind else bind for protocol in ("udp", "tcp"): args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"]) - args.append(image) + args.append(image_id) self.check_creation_cancellation() self.operation_phase = "creating-container" self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha}) diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index 1a7e591..1122f73 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -24,6 +24,7 @@ class FakeDocker: self.fail_restart = False self.lose_reply_after = None self.started = 0 + self.image_id = "sha256:" + "1" * 64 def inspect(self, name): return self.containers.get(name) @@ -34,6 +35,8 @@ class FakeDocker: return json.dumps({"ServerVersion": "27.5.1"}) if args[:2] == ("image", "ls"): return "ghcr.io/ayymoss/plutainer:latest\n" + if args[:4] == ("image", "inspect", "--format", "{{.Id}}"): + return self.image_id if args[:2] == ("container", "create"): labels = {args[index + 1].split("=", 1)[0]: args[index + 1].split("=", 1)[1] for index, arg in enumerate(args) if arg == "--label"} name = args[args.index("--name") + 1] @@ -989,7 +992,8 @@ class AgentTests(unittest.TestCase): payload = self.create() creation = next(args for args in self.docker.calls if args[:2] == ("container", "create")) self.assertIn("--pull=never", creation) - self.assertEqual(creation[-1], payload["image"]) + self.assertEqual(creation[-1], self.docker.image_id) + self.assertEqual(self.agent.spec(INSTANCE, 1)["image"], payload["image"]) self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls)) def test_image_disappearing_after_validation_cannot_be_pulled_or_started(self): @@ -1008,6 +1012,88 @@ class AgentTests(unittest.TestCase): self.assertEqual(self.docker.started, 0) self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls)) + def test_retargeted_prepared_image_requires_a_new_admin_pull_before_creation(self): + image = "registry.example/custom-runtime:stable" + payload = {"game": "t5", "libraryId": self.library_id, "image": image, "port": 28960, + "rconPassword": "rcon-secret", "env": {"PLUTAINER_KEY": "server-secret"}} + self.assertEqual(self.agent.execute(self.operation("runtime.pull", {"image": image}, "prepare"))["state"], "completed") + prepared_id = self.docker.image_id + self.docker.image_id = "sha256:" + "2" * 64 + original = self.docker.call + + def call(*args, **kwargs): + if args[:2] == ("image", "ls"): + # The old image still exists, so cached-ID inventory alone + # cannot prove that this mutable tag is safe to launch. + return (f"registry.example/custom-runtime|stable||{self.docker.image_id}\n" + f"|||{prepared_id}\n") + return original(*args, **kwargs) + + with patch.object(self.docker, "call", side_effect=call): + self.assertIn(image, self.agent.inventory()["preparedRuntimeImages"]) + result = self.agent.execute(self.operation("instance.create", payload, "retargeted")) + self.assertEqual(result["state"], "failed") + self.assertIn("Prepared runtime image has changed", result["error"]) + self.assertFalse(any(args[:2] == ("container", "create") for args in self.docker.calls)) + self.assertEqual(self.docker.started, 0) + self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "environment").exists()) + + self.assertEqual(self.agent.execute(self.operation("runtime.pull", {"image": image}, "prepare-again"))["state"], "completed") + self.assertEqual(self.agent.execute(self.operation("instance.create", payload, "retry"))["state"], "completed") + creation = next(args for args in self.docker.calls if args[:2] == ("container", "create")) + self.assertEqual(creation[-1], self.docker.image_id) + + def test_retag_after_validation_cannot_replace_the_image_used_for_creation(self): + image = "ghcr.io/ayymoss/plutainer:latest" + self.agent.dispatch(self.operation("runtime.pull", {"image": image})) + prepared_id = self.docker.image_id + original = self.agent.write_initial_files + + def retag(*args): + self.docker.image_id = "sha256:" + "2" * 64 + return original(*args) + + with patch.object(self.agent, "write_initial_files", side_effect=retag): + payload = self.create() + creation = next(args for args in self.docker.calls if args[:2] == ("container", "create")) + self.assertIn("--pull=never", creation) + self.assertEqual(creation[-1], prepared_id) + self.assertNotEqual(creation[-1], self.docker.image_id) + self.assertEqual(self.agent.spec(INSTANCE, 1), payload) + # Retagging must not invalidate an already created immutable instance. + calls = list(self.docker.calls) + self.assertTrue(self.agent.create(INSTANCE, 1, payload)["exists"]) + self.assertEqual(self.docker.calls, calls) + + def test_rebuild_rejects_retargeted_image_before_stopping_existing_container(self): + image = "ghcr.io/ayymoss/plutainer:latest" + self.agent.dispatch(self.operation("runtime.pull", {"image": image})) + payload = self.create() + self.docker.image_id = "sha256:" + "2" * 64 + self.docker.calls.clear() + result = self.agent.execute(self.operation("instance.rebuild", {"nextGeneration": 2, "spec": payload}, "rebuild")) + self.assertEqual(result["state"], "failed") + self.assertIn("Prepared runtime image has changed", result["error"]) + self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm"), ("container", "create")) for args in self.docker.calls)) + self.assertTrue(self.docker.inspect(self.agent.container_name(INSTANCE, 1))["State"]["Running"]) + + def test_launch_rejects_invalid_image_ids_and_preparation_records(self): + payload = {"game": "t5", "libraryId": self.library_id, "image": "prepared:runtime", "port": 28960} + for image_id in ("", "prepared:runtime", "sha256:short"): + with self.subTest(image_id=image_id): + self.docker.image_id = image_id + with self.assertRaisesRegex(AgentError, "valid runtime image ID"): + self.agent.create(INSTANCE, 1, payload) + self.docker.image_id = "sha256:" + "1" * 64 + aliases = self.agent.root / "agent/runtime-images.json" + for raw in ("invalid json", "[]", "null"): + with self.subTest(records=raw): + aliases.write_text(raw) + with self.assertRaisesRegex(AgentError, "Runtime preparation records"): + self.agent.create(INSTANCE, 1, payload) + self.assertFalse(any(args[:2] == ("container", "create") for args in self.docker.calls)) + self.assertEqual(self.docker.started, 0) + def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self): self.create() operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]}) diff --git a/docs/guides/native-hosts.md b/docs/guides/native-hosts.md index 893fb43..708b9d1 100644 --- a/docs/guides/native-hosts.md +++ b/docs/guides/native-hosts.md @@ -253,6 +253,12 @@ still run the configured default, but must update before accepting custom runtim Queued launches and migrations never trigger custom image pulls, and container creation cannot implicitly pull a missing image. +Agent 1.8.7 also checks a runtime's current image ID against its recorded preparation +before creating or rebuilding a container. If another program retags a prepared +image, an admin must prepare it again from the host page before launching it. +Containers are created using the validated immutable image ID, so a tag change +after validation cannot substitute another image. Existing containers keep running. + ### Resource samples and learned allowances Only provisioning reserves a host and port. Agent **1.4.6** fixes container diff --git a/static/agent/ec-agent.py b/static/agent/ec-agent.py index e7969c5..90d8bb0 100644 --- a/static/agent/ec-agent.py +++ b/static/agent/ec-agent.py @@ -35,7 +35,7 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.8.6" +VERSION = "1.8.7" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 MANIFEST_MAX_ENTRIES = 10000 @@ -1643,7 +1643,20 @@ class Agent: raise AgentError("Invalid runtime environment") # Runtime download is a separate preparation operation. A deployment # uses the already prepared image even during a registry outage. - self.docker.call("image", "inspect", image) + image_id = self.docker.call("image", "inspect", "--format", "{{.Id}}", image).strip() + if not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id): + raise AgentError("Docker did not report a valid runtime image ID") + try: + raw = managed_read(self.root / "agent", "runtime-images.json") + prepared = json.loads(raw) if raw is not None else {} + except (OSError, ValueError): + raise AgentError("Runtime preparation records are unavailable") from None + if not isinstance(prepared, dict): + raise AgentError("Runtime preparation records are invalid") + # The controller also authorizes its configured default without a pull + # record. Whenever preparation was recorded, enforce that exact image. + if image in prepared and prepared[image] != image_id: + raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.") files = payload.get("files", []) if not isinstance(files, list) or len(files) > 10000: raise Rejected("Invalid initial file list") @@ -1656,7 +1669,7 @@ class Agent: total += len(self.decode_file(item)) if total > 64 * 1024 * 1024: raise Rejected("Initial files exceed the 64 MiB operation limit") - return game, library, image, port, bind, env + return game, library, image, image_id, port, bind, env @staticmethod def plutonium_manifest(data): @@ -1890,7 +1903,7 @@ class Agent: raise AgentError("Instance already exists with a different specification") return existing self.operation_phase = "validating-create-spec" - game, library, image, port, bind, env = self.validate_spec(payload) + game, library, image, image_id, port, bind, env = self.validate_spec(payload) app = directory / "app" self.operation_phase = "preparing-create-directory" app.mkdir(parents=True, exist_ok=True) @@ -1905,15 +1918,15 @@ class Agent: atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode()) name = self.container_name(instance_id, generation) spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()) - # Do not let Docker fetch a replacement if the prepared image disappears - # between validation and container creation. + # Use the validated immutable ID so retagging after validation cannot + # substitute another image. A deleted image must not trigger a pull. args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)] if self.config.get("hostId"): args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])]) bind_arg = "[" + bind + "]" if ":" in bind else bind for protocol in ("udp", "tcp"): args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"]) - args.append(image) + args.append(image_id) self.check_creation_cancellation() self.operation_phase = "creating-container" self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha}) diff --git a/static/agent/manifest.json b/static/agent/manifest.json index 3cdb700..21feebc 100644 --- a/static/agent/manifest.json +++ b/static/agent/manifest.json @@ -1,8 +1,8 @@ { - "version": "1.8.6", + "version": "1.8.7", "protocolVersion": 1, "platform": "debian-amd64", - "sha256": "79702dc8cf80d2521153bfda9241f7240285014fe746fc921f0a7793dd81212b", + "sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701", "updaterProtocolVersion": 1, "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" }