Protect private bank configuration from drift reconciliation (Refs #152)
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
## 2026-10-12
|
||||
|
||||
### 1.9.3 — Protect private bank configuration (EC #152)
|
||||
|
||||
- Exclude the generated bank config from file manifests and reject file reads,
|
||||
staged changes, stale activations, rollback snapshots and log reads targeting
|
||||
it. Creation and rebuilding still inject the private instance credential.
|
||||
- Package and offline-sign this release, then update agents before resuming
|
||||
drift recovery. Old private-file stages fail safely and require a fresh plan.
|
||||
|
||||
### 1.9.1 — Public game-bank endpoint
|
||||
|
||||
- Companion to [TWS scripts #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13).
|
||||
|
||||
+23
-2
@@ -35,7 +35,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.9.2"
|
||||
VERSION = "1.9.3"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -400,7 +400,17 @@ def managed_open(root, value):
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def private_bank_config(value):
|
||||
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg"
|
||||
|
||||
|
||||
def require_public_file(value):
|
||||
if private_bank_config(value):
|
||||
raise Rejected("Private bank configuration cannot be read or changed by file operations")
|
||||
|
||||
|
||||
def managed_read(root, value):
|
||||
require_public_file(value)
|
||||
try:
|
||||
with managed_open(root, value) as stream:
|
||||
if os.fstat(stream.fileno()).st_size > MAX_FILE:
|
||||
@@ -446,6 +456,8 @@ def managed_manifest(root, payload, stopping):
|
||||
|
||||
def validate_paths():
|
||||
for value in paths:
|
||||
if private_bank_config(value):
|
||||
continue
|
||||
check_budget()
|
||||
try:
|
||||
with managed_open(root, value):
|
||||
@@ -480,7 +492,7 @@ def managed_manifest(root, payload, stopping):
|
||||
entries += 1
|
||||
if entries > MANIFEST_MAX_ENTRIES:
|
||||
raise AgentError("Manifest scan exceeded its entry limit")
|
||||
if child.name in ignored:
|
||||
if child.name in ignored or private_bank_config(relative + "/" + child.name):
|
||||
continue
|
||||
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
|
||||
if stat_module.S_ISLNK(info.st_mode):
|
||||
@@ -1949,6 +1961,8 @@ class Agent:
|
||||
files = payload.get("files", [])
|
||||
if not isinstance(files, list):
|
||||
raise Rejected("Invalid initial file list")
|
||||
for item in files:
|
||||
require_public_file(item["path"])
|
||||
bank = payload.get("bank")
|
||||
if bank is None:
|
||||
return files
|
||||
@@ -2219,6 +2233,7 @@ class Agent:
|
||||
paths = set()
|
||||
total_size = 0
|
||||
for item in files:
|
||||
require_public_file(item["path"])
|
||||
scoped(directory / "app", item["path"])
|
||||
if item["path"] in paths:
|
||||
raise AgentError("Duplicate staged file path")
|
||||
@@ -2267,6 +2282,8 @@ class Agent:
|
||||
stage = directory / "stages" / identifier(stage_id)
|
||||
marker = stage / "manifest.json"
|
||||
saved = json.loads(marker.read_text())
|
||||
for item in saved["files"]:
|
||||
require_public_file(item["path"])
|
||||
if saved["state"] == "activated":
|
||||
return saved["before"]
|
||||
if saved["state"] != "staged":
|
||||
@@ -2297,6 +2314,8 @@ class Agent:
|
||||
return before
|
||||
|
||||
def restore_files(self, app, before):
|
||||
for item in before:
|
||||
require_public_file(item["path"])
|
||||
for item in before:
|
||||
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
||||
|
||||
@@ -2351,6 +2370,7 @@ class Agent:
|
||||
def read_log(self, instance_id, generation, payload):
|
||||
app = self.instance_dir(instance_id, generation) / "app"
|
||||
value = payload["path"]
|
||||
require_public_file(value)
|
||||
count = min(2000, max(1, int(payload.get("lines", 200))))
|
||||
# Validate the original relative spelling independently of symlinks.
|
||||
scoped(self.root / "agent" / "log-validation", value)
|
||||
@@ -2367,6 +2387,7 @@ class Agent:
|
||||
raise AgentError("Log symlink resolves outside the managed instance") from None
|
||||
# Reopen the resolved relative target through no-follow directory FDs;
|
||||
# swapping either the original link or a target component cannot escape.
|
||||
require_public_file(relative)
|
||||
with managed_open(app, relative) as stream:
|
||||
stat = os.fstat(stream.fileno())
|
||||
if payload.get("mode") == "incremental":
|
||||
|
||||
@@ -1644,9 +1644,51 @@ class AgentTests(unittest.TestCase):
|
||||
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
|
||||
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
|
||||
|
||||
def test_create_materializes_private_bank_config_from_instance_credential(self):
|
||||
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self):
|
||||
self.create()
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
value = "configs/electric_cherry_bank.cfg"
|
||||
target = app / value
|
||||
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
|
||||
target.write_bytes(secret)
|
||||
files = [{"id": 9, "path": value, "delete": True, "contentBase64": None}]
|
||||
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||
self.agent.stage(INSTANCE, 1, {"stageId": "private", "files": files}, "stage")
|
||||
marker = self.agent.instance_dir(INSTANCE, 1) / "stages/private/manifest.json"
|
||||
# A stage written by a previous agent must be rejected on activation too.
|
||||
module.atomic_json(marker, {"state": "staged", "files": files})
|
||||
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||
self.agent.activate(INSTANCE, 1, "private", override_safety=True)
|
||||
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||
self.agent.rebuild_before(INSTANCE, 1, "private", {"files": []})
|
||||
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||
self.agent.restore_files(app, [{"path": value, "contentBase64": None}])
|
||||
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||
self.agent.file_operation("files.read", INSTANCE, 1, {"path": value}, "read")
|
||||
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||
self.agent.read_log(INSTANCE, 1, {"path": value})
|
||||
manifest = self.agent.file_operation("files.manifest", INSTANCE, 1,
|
||||
{"roots": ["configs"], "paths": [value], "ignoreNames": []}, "manifest")
|
||||
self.assertNotIn(value, [item["path"] for item in manifest["files"]])
|
||||
self.assertEqual(target.read_bytes(), secret)
|
||||
self.assertNotIn("before", json.loads(marker.read_text()))
|
||||
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||
self.agent.initial_files({"files": [{"path": value, "contentBase64": base64.b64encode(secret).decode()}]})
|
||||
|
||||
def prepare_t6_library(self):
|
||||
target = self.agent.library_dir("t6", self.library_id)
|
||||
module.shutil.copytree(self.library, target)
|
||||
manifest = json.loads((target / "manifest.json").read_text())
|
||||
manifest["game"] = "t6"
|
||||
module.atomic_json(target / "manifest.json", manifest)
|
||||
|
||||
def test_t6_create_materializes_private_bank_config(self):
|
||||
self.prepare_t6_library()
|
||||
self.test_create_materializes_private_bank_config_from_instance_credential(game="t6")
|
||||
|
||||
def test_create_materializes_private_bank_config_from_instance_credential(self, game="t5"):
|
||||
secret = "ec_bank_" + "a" * 43
|
||||
payload = {"name": "server", "game": "t5", "libraryId": self.library_id,
|
||||
payload = {"name": "server", "game": game, "libraryId": self.library_id,
|
||||
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960,
|
||||
"bank": {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}}
|
||||
operation = self.operation("instance.create", payload, "create")
|
||||
@@ -1699,9 +1741,14 @@ class AgentTests(unittest.TestCase):
|
||||
self.assertFalse(path.exists())
|
||||
self.assertEqual(self.docker.calls, calls)
|
||||
|
||||
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self):
|
||||
def test_t6_rebuild_preserves_private_bank_configuration(self):
|
||||
self.prepare_t6_library()
|
||||
self.test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(game="t6")
|
||||
|
||||
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self, game="t5"):
|
||||
secret = "ec_bank_" + "a" * 43
|
||||
payload = self.create()
|
||||
payload["game"] = game
|
||||
payload["bank"] = {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}
|
||||
paths = [self.bank_journal(game=game, endpoint=endpoint)
|
||||
for game in ("t5", "t6")
|
||||
|
||||
Reference in New Issue
Block a user