Fix public bank endpoint rollout (Refs josh/tws-zombies-scripts#13)

This commit is contained in:
2026-10-12 22:20:45 -04:00
parent 95160de5b4
commit 54f4301994
8 changed files with 191 additions and 38 deletions
+32
View File
@@ -0,0 +1,32 @@
# Agent changelog
## 2026-10-12
### 1.9.1 — Public game-bank endpoint
- Companion to [TWS scripts #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13).
In `agent/ec_agent.py`, stop generating `tws_bank_url` in
`electric_cherry_bank.cfg`; continue injecting the private per-instance token.
Launch configuration still supplies `tws_server_id`. The host's enrolled
controller address and authentication remain unchanged.
- Recognize journals for `https://ec.thewrightserver.net/api/bank` and the legacy
bank URL derived from the enrolled controller. Reject other URLs and preserve
unresolved storage. Settlement still goes only through the enrolled controller's
authenticated, operation- and instance-scoped API. Original transaction IDs and
commit/cancel decisions are preserved across lost replies and retries.
- Compatibility: pair with T5/T6 bank scripts `2.0.0` and existing TWS plugins
`1.1.0+`. No new Python dependency, controller migration or protocol change.
Old scripts still fall back to the public endpoint when the dvar is absent.
- Deployment: package and offline-sign the release, update/restart host agents,
then rebuild through EC when a server's bank endpoint changes. Rebuild stops
the game and drains old decisions before starting the replacement or deleting
storage. An endpoint that already matches needs only a map restart for the
script update. Follow the [bank rollout procedure](../docs/guides/bank.md#upgrading-from-a-controller-derived-game-endpoint).
Packaging refreshes `static/agent/ec-agent.py` and `manifest.json` and removes
the old signature; a new offline signature is required before publishing.
No signing or deployment was performed.
- Regression coverage in `agent/tests/test_agent.py`: generated credentials,
public and legacy journals for both games, rebuild/removal, lost replies,
agent restart, unchanged identities and rejection of untrusted endpoints.
Live Windows/Plutonium endpoint migration and signed host rollout remain to
be validated.
+12 -7
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.9.0"
VERSION = "1.9.1"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -48,6 +48,7 @@ PLUTONIUM_MANIFEST_URL = "https://cdn.plutoniummod.com/updater/prod/info.json"
PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -1957,10 +1958,9 @@ class Agent:
scoped(self.root / "agent" / "validation", path)
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
raise Rejected("Invalid private bank config path")
controller = self.config["controller"].rstrip("/")
if any(char in controller for char in ('"', "\\", "\n", "\r", "\x00")):
raise Rejected("Invalid bank controller URL")
content = f'set tws_bank_token "{bank["token"]}"\nset tws_bank_url "{controller}/api/bank"\n'.encode()
# The game scripts use the public bank API; the host controller address
# is only for agent transport and must not become a game URL override.
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
def write_initial_files(self, root, files):
@@ -2057,8 +2057,13 @@ class Agent:
try:
journal = json.loads(raw)
reports = journal["reports"]
if journal["endpoint"].rstrip("/") != self.config["controller"].rstrip("/") + "/api/bank":
raise AgentError("Bank journal belongs to a different controller; runtime storage retained")
# Accept the fixed game endpoint and this host's legacy generated
# endpoint so rebuild/removal can drain pre-upgrade decisions.
# Neither value controls transport: settlement still uses the
# enrolled controller, host credential and immutable operation.
endpoints = {BANK_GAME_ENDPOINT, self.config["controller"].rstrip("/") + "/api/bank"}
if journal["endpoint"].rstrip("/") not in endpoints:
raise AgentError("Bank journal has an unrecognized endpoint; runtime storage retained")
if type(journal["version"]) is not int or journal["version"] != 1 or not isinstance(reports, list) or len(reports) > 4096:
raise ValueError()
# Controller independently binds this operation to the instance/server.
+78 -4
View File
@@ -1652,16 +1652,17 @@ class AgentTests(unittest.TestCase):
operation = self.operation("instance.create", payload, "create")
self.assertEqual(self.agent.execute(operation)["state"], "completed")
path = self.agent.instance_dir(INSTANCE, 1) / "app/configs/electric_cherry_bank.cfg"
self.assertEqual(path.read_text(), f'set tws_bank_token "{secret}"\nset tws_bank_url "https://controller.example/api/bank"\n')
self.assertEqual(path.read_text(), f'set tws_bank_token "{secret}"\n')
self.assertEqual(self.agent.config["controller"], "https://controller.example")
self.assertIn("bank.settlement", self.agent.inventory()["capabilities"])
payload["bank"]["token"] = secret + '\nset bad "1"'
with self.assertRaises(AgentError):
self.agent.initial_files(payload)
def bank_journal(self, decisions=("commit", "cancel"), game="t5"):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/scope.json"
def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json"
reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)]
module.atomic_json(path, {"version": 1, "server": "t5_zombie_test_1", "endpoint": "https://controller.example/api/bank", "reports": reports})
module.atomic_json(path, {"version": 1, "server": "t5_zombie_test_1", "endpoint": endpoint, "reports": reports})
return path
def bank_acknowledgment(self, method, path, payload):
@@ -1698,6 +1699,79 @@ 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):
secret = "ec_bank_" + "a" * 43
payload = self.create()
payload["bank"] = {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}
paths = [self.bank_journal(game=game, endpoint=endpoint)
for game in ("t5", "t6")
for endpoint in (module.BANK_GAME_ENDPOINT, "https://controller.example/api/bank")]
originals = {path: path.read_bytes() for path in paths}
operation = self.operation("instance.rebuild", {"spec": payload, "nextGeneration": 2, "safetyOverride": True}, "rebuild")
applied, calls = {}, []
def acknowledge(method, path, data):
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
self.assertEqual(self.agent.config["controller"], "https://controller.example")
for journal, raw in originals.items():
self.assertEqual(journal.read_bytes(), raw)
transfer = data["transfer"]
request_id = transfer["request_id"]
calls.append(request_id)
if request_id in applied:
self.assertEqual(applied[request_id], transfer)
applied[request_id] = transfer
response = self.bank_acknowledgment(method, path, data)
if len(calls) == 1:
raise AgentError("Lost reply after settlement")
return response
with patch.object(self.agent, "request", side_effect=acknowledge):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
with patch.object(self.agent, "request", side_effect=acknowledge):
self.assertEqual(self.agent.execute(operation)["state"], "failed")
retry = self.operation("instance.rebuild", operation["payload"], "replacement")
result = self.agent.execute(retry)
self.assertEqual(result["state"], "completed", result)
# Reconciliation drains once, then the replacement operation drains
# again before deleting the unchanged journals. All retries keep IDs.
self.assertEqual(len(calls), 17)
self.assertEqual(len(applied), 4)
self.assertEqual({item["decision"] for item in applied.values()}, {"commit", "cancel"})
self.assertTrue(all(not path.exists() for path in paths))
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
config = self.agent.instance_dir(INSTANCE, 2) / "app/configs/electric_cherry_bank.cfg"
self.assertEqual(config.read_text(), f'set tws_bank_token "{secret}"\n')
def test_remove_drains_legacy_and_public_endpoints_without_following_journal_urls(self):
self.create()
paths = [self.bank_journal(game=game, endpoint=endpoint)
for game in ("t5", "t6")
for endpoint in (module.BANK_GAME_ENDPOINT, "https://controller.example/api/bank")]
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 8)
self.assertTrue(all(not path.exists() for path in paths))
def test_unrecognized_bank_endpoints_retain_storage_without_settlement(self):
self.create()
path = self.bank_journal()
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
for endpoint in ("https://untrusted.example/api/bank", "http://ec.thewrightserver.net/api/bank",
"https://ec.thewrightserver.net.evil/api/bank", module.BANK_GAME_ENDPOINT + "?redirect=evil",
"https://controller.example/other/api/bank", "", None):
with self.subTest(endpoint=endpoint):
journal = json.loads(path.read_text())
journal["endpoint"] = endpoint
module.atomic_json(path, journal)
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
request.assert_not_called()
self.assertTrue(path.exists())
def test_rebuild_settles_before_starting_replacement_and_retains_unknown_decisions(self):
payload = self.create()
path = self.bank_journal(("commit",))