diff --git a/README.md b/README.md index f15a674..2539f2a 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,10 @@ _Fleet overview with sample data._ - Launch individual servers or saved collections of maps and lobbies. - Monitor players, host capacity, match history, and leaderboards. +- Moderate players with scoped restrictions, live actions, preserved evidence and review history. - Manage scripts, plugins, and configuration with beta testing and rollback. - Queue maintenance around active games and scale spare lobbies with opt-in policies. -- Control access with administrator, operator, and viewer accounts. +- Control access with administrator, moderator, operator, and read-only accounts. ## Getting started diff --git a/agent/CHANGELOG.md b/agent/CHANGELOG.md index 9f03af7..ad0ea03 100644 --- a/agent/CHANGELOG.md +++ b/agent/CHANGELOG.md @@ -1,5 +1,22 @@ # Agent changelog +## 2026-10-15 + +### 1.11.0 — Private player moderation configuration + +- Advertise `moderation.configure` and accept separate generation-bound game + credentials through private create/rebuild configuration or the typed + `instance.configure-moderation` operation. Existing servers preserve startup + through a fixed include in their verified private bank configuration; servers + without a verified bootstrap require rebuilding. +- Keep moderation credentials out of file reads, logs, manifests, staged changes, + shared recipes and operation receipts. Configure loads only the fixed private + file, never restarts gameplay, and does not replay RCON after an unknown result. +- Controller coverage remains unavailable until the game moderation runtime + connects and reports its capabilities. Older agents retain normal launches. +- Package release artifacts for offline signing before publication; do not + publish unsigned artifacts. + ## 2026-10-14 ### 1.10.1 — Retain verified rebuild artifacts (EC #154) diff --git a/agent/ec_agent.py b/agent/ec_agent.py index b3b50d8..347dd17 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.10.1" +VERSION = "1.11.0" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 MANIFEST_MAX_ENTRIES = 10000 @@ -49,6 +49,8 @@ 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" +MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg" +MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n" 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") @@ -85,6 +87,7 @@ class AutoscalingInvalidated(Rejected): OPERATION_ERROR_PHASES = { "executing": "executing the operation", + "configuring-moderation": "configuring private game moderation", "checking-instance": "checking the existing instance", "validating-create-spec": "validating the container specification", "preparing-create-directory": "preparing the instance directory", @@ -401,13 +404,13 @@ 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 private_instance_config(value): + return isinstance(value, str) and PurePosixPath(value).as_posix() in ("configs/electric_cherry_bank.cfg", MODERATION_CONFIG_PATH) def require_public_file(value): - if private_bank_config(value): - raise Rejected("Private bank configuration cannot be read or changed by file operations") + if private_instance_config(value): + raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations") def managed_read(root, value): @@ -457,7 +460,7 @@ def managed_manifest(root, payload, stopping): def validate_paths(): for value in paths: - if private_bank_config(value): + if private_instance_config(value): continue check_budget() try: @@ -493,7 +496,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 or private_bank_config(relative + "/" + child.name): + if child.name in ignored or private_instance_config(relative + "/" + child.name): continue info = os.stat(child.name, dir_fd=directory, follow_symlinks=False) if stat_module.S_ISLNK(info.st_mode): @@ -1571,7 +1574,7 @@ class Agent: if line.startswith("PRETTY_NAME="): os_name = line.split("=", 1)[1].strip('"') update = self.update_inventory() - capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) + capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) plutonium_cache = self.plutonium_cache_inventory() return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} @@ -1964,6 +1967,8 @@ class Agent: def create(self, instance_id, generation, payload, *, before=None, prepared_files=None): self.operation_phase = "checking-instance" + if payload.get("moderation") is not None: + self.moderation_config(payload["moderation"], generation) self.check_creation_cancellation() directory = self.instance_dir(instance_id, generation) current_marker = directory.parent / "current.json" @@ -2025,18 +2030,92 @@ class Agent: for item in files: require_public_file(item["path"]) bank = payload.get("bank") - if bank is None: - return files - if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]): - raise Rejected("Invalid instance bank credential") - path = bank.get("configPath") - scoped(self.root / "agent" / "validation", path) - if PurePosixPath(path).name != "electric_cherry_bank.cfg": - raise Rejected("Invalid private bank config path") - # 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()}] + if bank is not None: + if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]): + raise Rejected("Invalid instance bank credential") + path = bank.get("configPath") + scoped(self.root / "agent" / "validation", path) + if PurePosixPath(path).name != "electric_cherry_bank.cfg": + raise Rejected("Invalid private bank config path") + # Game transport uses its public URL, never the agent's enrollment URL. + content = f'set tws_bank_token "{bank["token"]}"\n'.encode() + files = files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}] + if payload.get("moderation") is not None: + content = self.moderation_config(payload["moderation"]) + args = payload.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "") + if not self.config_exec(args, "electric_cherry_moderation.cfg"): + raise Rejected("Moderation configuration must be loaded by the launch arguments") + files = files + [{"path": MODERATION_CONFIG_PATH, "contentBase64": base64.b64encode(content).decode()}] + return files + + @staticmethod + def config_exec(args, filename): + return isinstance(args, str) and re.search(r"(?:^|\s)\+exec\s+" + re.escape(filename) + r"(?=\s|$)", args) is not None + + @staticmethod + def moderation_config(moderation, generation=None): + if not isinstance(moderation, dict) or set(moderation) != {"token", "configPath", "url", "generation"}: + raise Rejected("Invalid moderation configuration") + token, url, target = moderation["token"], moderation["url"], moderation["generation"] + if not isinstance(token, str) or not re.fullmatch(r"ec_moderation_[A-Za-z0-9_-]{43}", token): + raise Rejected("Invalid instance moderation credential") + if type(target) is not int or target < 1 or generation is not None and target != generation: + raise Rejected("Moderation credential belongs to a different instance generation") + if moderation["configPath"] != MODERATION_CONFIG_PATH: + raise Rejected("Invalid private moderation config path") + if not isinstance(url, str) or len(url) > 2048 or re.search(r'[\s\x00-\x1f\x7f";\\]', url): + raise Rejected("Invalid moderation controller URL") + try: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.path != "/api/moderation/runtime" or parsed.query or parsed.fragment or parsed.port == 0: + raise ValueError() + except ValueError: + raise Rejected("Invalid moderation controller URL") from None + return f'set tws_moderation_url "{url}"\nset tws_moderation_token "{token}"\nset tws_moderation_generation "{target}"\n'.encode() + + @staticmethod + def private_config_bytes(app, path): + with managed_open(app, path) as stream: + content = stream.read(8193) + if len(content) > 8192: + raise Rejected("Private instance configuration exceeds the supported size") + return content + + def moderation_bootstrap(self, instance_id, generation): + saved = self.spec(instance_id, generation) + args = saved.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "") + if self.config_exec(args, "electric_cherry_moderation.cfg"): + return None + if not self.config_exec(args, "electric_cherry_bank.cfg"): + raise Rejected("Rebuild this server before configuring moderation; startup cannot load the private configuration") + app = self.instance_dir(instance_id, generation) / "app" + try: + bank = self.private_config_bytes(app, "configs/electric_cherry_bank.cfg") + except FileNotFoundError: + raise Rejected("Rebuild this server before configuring moderation; private startup configuration is missing") from None + if not re.fullmatch(rb'set tws_bank_token "ec_bank_[A-Za-z0-9_-]{32,128}"\r?\n(?:exec electric_cherry_moderation.cfg\r?\n)?', bank): + raise Rejected("Rebuild this server before configuring moderation; private startup configuration cannot be verified") + return bank if bank.splitlines()[-1] == MODERATION_INCLUDE.rstrip(b"\n") else bank + MODERATION_INCLUDE + + def configure_moderation(self, instance_id, generation, payload): + if set(payload) != {"moderation"}: + raise Rejected("Invalid moderation configuration request") + content = self.moderation_config(payload["moderation"], generation) + health = self.inspect(instance_id, generation) + if not health["exists"]: + raise Rejected("Instance does not exist") + bank = self.moderation_bootstrap(instance_id, generation) + app = self.instance_dir(instance_id, generation) / "app" + self.operation_phase = "configuring-moderation" + self.checkpoint({"phase": "configuring-moderation", "generation": generation}) + uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)) + managed_write(app, MODERATION_CONFIG_PATH, content, uid, gid) + if bank is not None: + managed_write(app, "configs/electric_cherry_bank.cfg", bank, uid, gid) + # Loading fixed dvars is not a restart. Never retain a reply which could + # echo credentials, and never replay the command after a lost response. + loaded = self.rcon(instance_id, generation, "exec electric_cherry_moderation.cfg").get("ok", False) if health["running"] else False + return {"configured": True, "generation": generation, "liveLoad": "sent" if loaded else "unconfirmed" if health["running"] else "deferred"} def write_initial_files(self, root, prepared_files): for path, content in prepared_files.items(): @@ -2169,6 +2248,8 @@ class Agent: return self.create(instance_id, generation, payload) if kind == "instance.inspect": return self.inspect(instance_id, generation) + if kind == "instance.configure-moderation": + return self.configure_moderation(instance_id, generation, payload) health = self.inspect(instance_id, generation) name = self.container_name(instance_id, generation) before = [] @@ -2198,6 +2279,8 @@ class Agent: if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation: raise AgentError("Rebuild must use a newer immutable generation") spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")} + if spec.get("moderation") is not None: + self.moderation_config(spec["moderation"], next_generation) # Keep the complete verified set on this call's stack only. Never # mutate the immutable spec or cache bytes on the shared agent: # held, failed and later operations must prepare their own files. @@ -3001,6 +3084,16 @@ class Agent: return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision") if kind == "instance.cancel-create": return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"])) + if kind == "instance.configure-moderation": + current = json.loads((self.instance_dir(instance_id, generation).parent / "current.json").read_text()) + if current.get("generation") != generation or current.get("removed"): + return self.journal.update(operation["id"], "failed", error="Moderation configuration generation was superseded or removed") + content = self.moderation_config(payload.get("moderation"), generation) + app = self.instance_dir(instance_id, generation) / "app" + bank = self.moderation_bootstrap(instance_id, generation) + if self.private_config_bytes(app, MODERATION_CONFIG_PATH) != content or bank is not None and self.private_config_bytes(app, "configs/electric_cherry_bank.cfg") != bank: + return row + return self.journal.update(operation["id"], "completed", {"configured": True, "generation": generation, "liveLoad": "unconfirmed", "reconciled": True}) if kind == "instance.create": marker = self.instance_dir(instance_id, generation).parent / "current.json" if marker.exists(): diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index 8e7e9fb..bbdc0df 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -1650,10 +1650,12 @@ 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_private_bank_file_survives_stale_file_plans_without_snapshots(self): + def test_private_moderation_file_survives_stale_file_plans_without_snapshots(self): + self.test_private_bank_file_survives_stale_file_plans_without_snapshots(value=module.MODERATION_CONFIG_PATH) + + def test_private_bank_file_survives_stale_file_plans_without_snapshots(self, value="configs/electric_cherry_bank.cfg"): 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) @@ -1707,6 +1709,129 @@ class AgentTests(unittest.TestCase): with self.assertRaises(AgentError): self.agent.initial_files(payload) + def moderation_configuration(self, generation=1): + return {"token": "ec_moderation_" + "m" * 43, "configPath": module.MODERATION_CONFIG_PATH, + "url": "https://public-controller.example/api/moderation/runtime", "generation": generation} + + def create_moderation_bootstrap(self, *, direct=False, game="t5"): + payload = {"name": "server", "game": game, "libraryId": self.library_id, + "image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "rconPassword": "secret", + "env": {"PLUTAINER_EXTRA_ARGS": "+exec electric_cherry_bank.cfg +map_rotate"}, + "bank": {"token": "ec_bank_" + "b" * 43, "configPath": "configs/electric_cherry_bank.cfg"}} + if direct: + payload["moderation"] = self.moderation_configuration() + payload["env"]["PLUTAINER_EXTRA_ARGS"] = "+exec electric_cherry_moderation.cfg +map_rotate" + row = self.agent.execute(self.operation("instance.create", payload, "create")) + self.assertEqual(row["state"], "completed", row) + return payload + + def test_t6_create_materializes_private_moderation_configuration(self): + self.prepare_t6_library() + self.test_create_materializes_private_moderation_configuration(game="t6") + + def test_create_materializes_private_moderation_configuration(self, game="t5"): + payload = self.create_moderation_bootstrap(direct=True, game=game) + target = self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH + self.assertEqual(target.read_bytes(), self.agent.moderation_config(payload["moderation"], 1)) + self.assertNotIn(self.config["controller"], target.read_text()) + self.assertIn("moderation.configure", self.agent.inventory()["capabilities"]) + self.assertNotIn(payload["moderation"]["token"], self.agent.journal.get("create")["result"]) + + def test_configure_moderation_persists_existing_startup_and_discards_rcon_secrets(self): + saved = self.create_moderation_bootstrap() + payload = {"moderation": self.moderation_configuration()} + operation = self.operation("instance.configure-moderation", payload) + with patch.object(self.agent, "rcon", return_value={"ok": True, "text": payload["moderation"]["token"]}) as command: + row = self.agent.execute(operation) + self.assertEqual(row["state"], "completed", row) + self.assertEqual(self.agent.execute(operation), row) + command.assert_called_once_with(INSTANCE, 1, "exec electric_cherry_moderation.cfg") + self.assertNotIn(payload["moderation"]["token"], row["result"]) + app = self.agent.instance_dir(INSTANCE, 1) / "app" + self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(payload["moderation"], 1)) + bank = (app / "configs/electric_cherry_bank.cfg").read_bytes() + self.assertEqual(bank, f'set tws_bank_token "{saved["bank"]["token"]}"\n'.encode() + module.MODERATION_INCLUDE) + self.assertEqual(self.agent.spec(INSTANCE, 1), saved) + bank = bank.replace(b"\n", b"\r\n") + (app / "configs/electric_cherry_bank.cfg").write_bytes(bank) + with patch.object(self.agent, "rcon", return_value={"ok": False, "text": "timeout"}): + again = self.agent.execute(self.operation("instance.configure-moderation", payload, "configure-again")) + self.assertEqual(json.loads(again["result"])["liveLoad"], "unconfirmed") + self.assertEqual((app / "configs/electric_cherry_bank.cfg").read_bytes(), bank) + self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls)) + + def test_configure_moderation_rejects_unknown_startup_without_writing(self): + self.create() + with patch.object(self.agent, "rcon") as command: + row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()})) + self.assertEqual(row["state"], "failed", row) + self.assertIn("Rebuild this server", row["error"]) + self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists()) + command.assert_not_called() + + def test_configure_moderation_requires_readable_verified_private_startup(self): + self.create_moderation_bootstrap() + app = self.agent.instance_dir(INSTANCE, 1) / "app" + bank = app / "configs/electric_cherry_bank.cfg" + for index, content in enumerate((None, b'unknown startup commands\n')): + with self.subTest(content=content), patch.object(self.agent, "rcon") as command: + bank.unlink(missing_ok=True) + if content is not None: + bank.write_bytes(content) + row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}, f"invalid-bootstrap-{index}")) + self.assertEqual(row["state"], "failed", row) + self.assertIsNone(row["checkpoint"]) + self.assertFalse((app / module.MODERATION_CONFIG_PATH).exists()) + command.assert_not_called() + + def test_configure_moderation_validates_credentials_generation_url_and_private_path(self): + self.create_moderation_bootstrap() + for index, change in enumerate(( + {"token": 'ec_moderation_' + 'm' * 43 + '\nset other "1"'}, + {"generation": 2}, {"generation": True}, {"configPath": "configs/other.cfg"}, + {"url": "http://controller.example/api/moderation/runtime"}, + {"url": "https://user:secret@controller.example/api/moderation/runtime"}, + {"url": "https://controller.example/api/moderation/runtime?secret=1"}, + {"url": 'https://controller.example/api/moderation/runtime";quit'}, + {"unknown": "value"} + )): + with self.subTest(change=change), patch.object(self.agent, "rcon") as command: + payload = {"moderation": {**self.moderation_configuration(), **change}} + row = self.agent.execute(self.operation("instance.configure-moderation", payload, f"bad-moderation-{index}")) + self.assertEqual(row["state"], "failed", row) + self.assertIsNone(row["checkpoint"]) + command.assert_not_called() + self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists()) + + def test_configure_moderation_reconciles_written_configuration_without_replaying_rcon(self): + self.create_moderation_bootstrap() + operation = self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}) + with patch.object(self.agent, "rcon", side_effect=RuntimeError("secret must not leak")) as command: + unknown = self.agent.execute(operation) + self.assertEqual(unknown["state"], "unknown", unknown) + row = self.agent.execute(operation) + self.assertEqual(row["state"], "completed", row) + command.assert_called_once() + self.assertEqual(json.loads(row["result"])["liveLoad"], "unconfirmed") + self.assertNotIn("secret must not leak", str(unknown.get("error"))) + + def test_rebuild_rejects_moderation_credentials_for_the_previous_generation_before_disruption(self): + saved = self.create_moderation_bootstrap(direct=True) + row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2}, "rebuild")) + self.assertEqual(row["state"], "failed", row) + self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"]) + self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls)) + + def test_rebuild_writes_moderation_credentials_for_only_the_replacement_generation(self): + saved = self.create_moderation_bootstrap(direct=True) + saved["moderation"] = {**self.moderation_configuration(2), "token": "ec_moderation_" + "n" * 43} + row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2, "safetyOverride": True}, "rebuild")) + self.assertEqual(row["state"], "completed", row) + app = self.agent.instance_dir(INSTANCE, 2) / "app" + self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(saved["moderation"], 2)) + self.assertNotIn(saved["moderation"]["token"], row["result"]) + self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"]) + 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)] diff --git a/docs/README.md b/docs/README.md index 0f36759..c7ec0ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ and launch your first server. - [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports. - [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments. - [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery. +- [Player moderation](guides/moderation.md) — restrictions, live actions, cases, evidence and game integration. - [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API. ## Development diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index 46aca18..fab5c38 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -31,6 +31,13 @@ guessable token. An unset setup token holds setup closed. ## Roles +The Moderator role has Viewer-level fleet access plus private player moderation. +It permits live player actions, case review and server/game restrictions lasting +up to seven days, without granting fleet operations or access to raw configs and +credentials. Administrators also control permanent/fleet restrictions, integration +configuration and revoking another moderator's restriction. Super Viewers can +read moderation; Operators and Viewers cannot. See [Player moderation](moderation.md). + | Capability | Viewer | Super Viewer | Operator | Administrator | | --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- | | Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes | diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md new file mode 100644 index 0000000..d099b33 --- /dev/null +++ b/docs/guides/moderation.md @@ -0,0 +1,159 @@ +# Player moderation + +**Players → Moderation** provides player search, warnings, private messages, +kicks, announcements, bans, mutes, case records and private history. Restrictions +can cover a server, a game or the fleet. Saving a restriction records the policy; +the coverage table shows whether game runtimes have synchronized it. + +## Access + +Administrators manage all moderation controls and configure integrations. +Moderators can read private history, create notes/cases, send live actions, and +issue server/game restrictions lasting at most seven days. They can revoke their +own server/game restrictions originally lasting at most seven days. Applying or +revoking permanent, longer or fleet restrictions, and revoking another staff +member's restriction, require Administrator access. These limits also apply after +an administrator becomes a Moderator. Super Viewers can inspect +moderation but cannot change it. Operators and Viewers cannot read private +moderation data. Moderator does not grant fleet operation, script, plugin, +credential or account-management privileges. + +Assign Moderator through **Admin → Users**. Existing users retain their roles; +changing a role revokes that user's sessions. Each service rechecks current +authorization, including immediately before delivering a queued live action. + +## Player identity and restrictions + +Select a player from Moderation, a live scoreboard or a player profile. Search +matches known aliases as well as GUIDs. A display name never identifies a +restriction target. Manually entered GUIDs can have case records and restrictions +before gameplay is observed; their first/last seen dates remain **Not recorded**. + +IDs remain strings. Numeric game IDs are normalized without losing precision; +T6 RCON hexadecimal IDs have a separate conversion rule. Historical chat links +search for the raw log identity because its encoding is not assumed. Confirm a +known player identity before applying a restriction. Fleet restrictions require +the same numeric account ID to have been observed in both games. Otherwise use +game scope. + +Every restriction has a player-visible reason, optional private note, scope, +expiry, creator and revision. Reasons and live messages must fit in 240 UTF-8 +bytes. The native adapter replaces console control characters in displayed text. +Overlapping restrictions remain independent: revoking one mute does not cancel +another active mute. Revocation requires a reason and the current revision; +concurrent changes require refreshing before another decision. + +Server scope follows a logical server registration across instance replacement +or rebuild. Deleting and later registering the same server name creates a new +registration, which does not inherit the old server-scoped restriction. +Game/fleet policies apply to every connected compatible runtime in that scope, +including newly provisioned servers. A new runtime must synchronize before it +has a complete policy; provisioning readiness alone does not prove moderation +coverage. The initial limit is 1,000 active restrictions across the controller. + +## Enable the game integration + +The controller requires `EC_PUBLIC_URL` to be a public HTTPS origin. The host +agent must advertise `moderation.configure` (agent 1.11.0+). Install the matching +TWS moderation script 1.0.0, common 1.2.0+, and TWS native plugin 1.3.0+ from +`tws-zombies-scripts` for each game. New native plugins require a full +game-server process restart. Use the normal release/beta workflow and validate +one T5 Zombies and one T6 Zombies instance before fleet rollout. + +New launches/rebuilds on capable agents receive a private moderation config when +`EC_PUBLIC_URL` is configured. Existing instances can use **Enable integration** +in Moderation. This queues a typed operation that writes the fixed private config +and loads its dvars, without restarting gameplay. Existing startup persistence +uses the verified private bank config; older instances without that bootstrap +must be rebuilt. Installed moderation scripts retry missing configuration. + +The private file `configs/electric_cherry_moderation.cfg` contains the HTTPS +endpoint, generation and a separate per-instance credential. It is excluded from +shared artifacts, file reads, drift scans, logs and operation receipts. Game +credentials are distinct from bank, telemetry and host credentials. They only +authorize the active immutable instance generation; retirement/rebuild revokes +the previous generation's access. Older agents retain ordinary server launches, +but cannot provide this integration. + +Coverage states describe runtime communication: + +- **Policy current:** a fresh runtime supports both bans and mutes and has + acknowledged the current policy revision. +- **Pending:** the runtime is connected but has not acknowledged the latest revision + or does not support both bans and mutes. +- **Offline:** no runtime synchronization has arrived for more than 15 seconds. +- **Unavailable:** this instance generation has not connected. + +Policy current is not proof that an engine mute command took effect. The runtime +reports supported capabilities; unsupported live actions stay unavailable. +Native kick/mute command behavior and voice/text suppression require testing on +the deployed Plutonium build. A disconnected host cannot receive a new ban or +revocation until it reconnects. + +## Live actions and offline enforcement + +Live actions expire after 30 seconds and target the runtime's exact player +connection. A reconnect, reused slot, changed generation or revoked moderator +access prevents stale dispatch. Browser retries preserve the request identity. +The native adapter journals one-time actions before dispatch and never executes +the same action ID twice, including after restart. + +Queued actions can become delivered, confirmed, failed, expired or unknown. +An expired undelivered action did not run. Unknown means delivery occurred but +the result could not be established; do not assume the action failed. A durable +late receipt can resolve an unknown outcome. Confirmation of a message means +server dispatch, not proof the recipient read it. Kick confirmation requires an +observed disconnect. + +The native runtime caches the complete applicable policy on persistent storage, +scoped to the server, endpoint, generation and credential fingerprint. Cached +bans/mutes continue through controller outages and expire using the host clock. +Keep host time synchronized. A ban disconnects a player after connecting; it is +not a pre-authentication admission filter. The shared connection handler and a +500 ms reconciliation loop enforce it, retrying still-connected banned players +every five seconds. No native `ban.txt` entries are rewritten. + +Preserve `tws-moderation/t5/` or `tws-moderation/t6/` in the game working directory. +The cache includes an increasing runtime session counter that prevents an older +process from replacing the active player roster. Losing or rolling back this +cache requires a generation rebuild with a new credential before synchronization +can resume; re-enabling the same integration does not reset the counter. +Corrupt caches and failed durable writes stop new actions and require operator +investigation. A controller restored to an older policy revision cannot silently +replace a newer cached revision; review the restore and integration before +resetting its cache or credentials. + +## Cases, evidence and review + +Create a case for a report, investigation or appeal. Staff notes are explicitly +labeled as notes. Selecting archived chat verifies the target GUID and original +message against the recorded match and saves an independent snapshot, including +the original timing context. Unknown or estimated timestamps remain such; the +snapshot does not turn them into exact observations. Unarchived or already +deleted chat cannot be attached as verified evidence. + +Cases can be resolved or dismissed with a recorded reason. Evidence, restrictions +and append-only moderation events survive source-match cleanup and server +deletion. They currently have no automatic retention cleanup. Authentication +audit cleanup does not remove moderation history. Correct decisions by adding +notes or revoking restrictions; there is no history-erasing delete endpoint. + +Leaderboard disqualification, bank balance changes, automatic escalation and a +public report/appeal portal are separate features. This workflow supports staff +review of reports and appeals without exposing private evidence publicly. + +## Development and acceptance + +Controller tests cover permission boundaries, migrations, idempotency, exact +instance credentials, stale connections, expired actions, late receipts, +restriction expiry/revocation, policy coverage and evidence preservation. Agent +tests cover private configuration delivery and secret redaction. Companion +native tests exercise real policy/journal code and adapters with engine shims. + +Before deploying, build the x86 native plugins on Windows and validate both +games: duplicate names, account reconnects, slot reuse, kick reasons, voice/text +mute/unmute, overlapping restrictions, map/process restart, new server policy +sync, expiry, revocation, controller outage and persistent-storage failure. +Portable tests cannot establish the live Plutonium ABI or engine behavior. +Package and offline-sign the agent release before publishing it; local builds +remove any stale manifest signature and do not sign or deploy the release. diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index d85f051..5f22742 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -1,3 +1,5 @@ +import { moderatorMaximumRestrictionMs, type AuthUser } from './auth.js'; + export type ModerationGame = 't5' | 't6'; export type ModerationScope = 'server' | 'game' | 'fleet'; export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce'; @@ -9,8 +11,8 @@ export interface ModerationPlayer { guid: string; name: string; aliases: string[]; - firstSeen: string; - lastSeen: string; + firstSeen: string | null; + lastSeen: string | null; servers: string[]; restrictions: number; } @@ -31,6 +33,23 @@ export interface ModerationSanction { revision: number; active: boolean; } + +export function canRevokeModerationRestriction( + actor: Pick | null | undefined, + restriction: Pick +): boolean { + if (actor?.role === 'admin') return true; + if ( + actor?.role !== 'moderator' || + actor.id !== restriction.actorId || + (restriction.scope !== 'server' && restriction.scope !== 'game') || + !restriction.expiresAt + ) + return false; + const duration = Date.parse(restriction.expiresAt) - Date.parse(restriction.createdAt); + return duration > 0 && duration <= moderatorMaximumRestrictionMs; +} + export interface ModerationAction { id: string; game: ModerationGame; diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 1656d70..0e4d46e 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -1,9 +1,10 @@ -import { canReadAdmin, type UserRole } from './auth.js'; +import { canReadAdmin, canReadModeration, type UserRole } from './auth.js'; export interface NavigationItem { label: string; href: string; shortcut?: string; + moderation?: boolean; matches?: (path: string) => boolean; } export interface NavigationGroup { @@ -71,6 +72,7 @@ export const navigationGroups: NavigationGroup[] = [ { label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' }, { label: 'Matches', href: '/matches' }, { label: 'Bank', href: '/bank' }, + { label: 'Moderation', href: '/moderation', moderation: true }, { label: 'Usage', href: '/metrics', shortcut: 'm' } ] }, @@ -92,7 +94,12 @@ export const navigationGroups: NavigationGroup[] = [ ]; export function visibleNavigation(role: UserRole): NavigationGroup[] { - return navigationGroups.filter((group) => !group.admin || canReadAdmin(role)); + return navigationGroups + .filter((group) => !group.admin || canReadAdmin(role)) + .map((group) => ({ + ...group, + items: group.items.filter((item) => !item.moderation || canReadModeration(role)) + })); } export function navigationItemActive(item: NavigationItem, path: string): boolean { diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index 0e3b4d7..b438894 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -41,6 +41,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe if (within(routeId, '/api/agent')) return 'machine'; if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine'; if (routeId === '/api/telemetry' && method === 'POST') return 'machine'; + if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine'; if (routeId === '/api/leaderboard' && reading) return 'public'; if ((routeId === '/login' || routeId === '/setup') && reading) return 'public'; if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 9b77df7..6b06f7b 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -16,6 +16,7 @@ function num(name: string, fallback: number): number { } export const config = { + publicUrl: env.EC_PUBLIC_URL ?? '', serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'), t6ServersRoot: env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'), diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index c92b50e..0dea7b8 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -143,7 +143,24 @@ export const migrations: readonly Migration[] = [ mode: 'foreign-keys-off', run: migrateModeratorRole }, - { version: 26, name: 'player moderation records and enforcement', run: migrateModeration } + { version: 26, name: 'player moderation records and enforcement', run: migrateModeration }, + { + version: 27, + name: 'moderation runtime sessions and evidence lookup', + run: (handle) => { + handle.exec( + 'CREATE INDEX IF NOT EXISTS moderation_evidence_case ON moderation_evidence(case_id)' + ); + if ( + !(handle.pragma('table_info(moderation_runtimes)') as { name: string }[]).some( + (c) => c.name === 'session_sequence' + ) + ) + handle.exec( + 'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0' + ); + } + } ]; export const databaseVersion = migrations.length; diff --git a/src/lib/server/fleet/private-config.ts b/src/lib/server/fleet/private-config.ts index 257d811..6231f18 100644 --- a/src/lib/server/fleet/private-config.ts +++ b/src/lib/server/fleet/private-config.ts @@ -2,11 +2,18 @@ import { posix } from 'node:path'; import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js'; export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg'; +export const MODERATION_CONFIG_FILE = 'electric_cherry_moderation.cfg'; +export const PRIVATE_CONFIG_FILES = [BANK_CONFIG_FILE, MODERATION_CONFIG_FILE] as const; export function isPrivateConfigPath(path: string, game = 't5'): boolean { - return ( - posix.normalize(path.replaceAll('\\', '/')) === - posix.join(relDestForKind('config', game), BANK_CONFIG_FILE) + // Moderation bootstrap has one fixed runtime location, even when shared + // artifact destinations were customized for an older installation. + if (posix.normalize(path.replaceAll('\\', '/')) === `configs/${MODERATION_CONFIG_FILE}`) + return true; + return PRIVATE_CONFIG_FILES.some( + (filename) => + posix.normalize(path.replaceAll('\\', '/')) === + posix.join(relDestForKind('config', game), filename) ); } @@ -23,26 +30,31 @@ export function isPrivateConfigTarget(server: string, kind: string, path: string export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void { if (isPrivateConfigTarget(server, kind, path)) throw new Error( - 'Private bank configuration cannot be captured or changed by a file campaign.' + 'Private bank configuration and moderation configuration cannot be captured or changed by a file campaign.' ); } export function isReservedConfigFilename(path: string): boolean { - return posix.basename(path.replaceAll('\\', '/')).toLowerCase() === BANK_CONFIG_FILE; + return PRIVATE_CONFIG_FILES.some( + (filename) => posix.basename(path.replaceAll('\\', '/')).toLowerCase() === filename + ); } // Legacy campaign evidence omits the kind and stores the path relative to its destination. export function isPrivateConfigEvidencePath(path: string): boolean { return ( - posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE || - isPrivateConfigPath(path) + PRIVATE_CONFIG_FILES.some( + (filename) => posix.normalize(path.replaceAll('\\', '/')) === filename + ) || isPrivateConfigPath(path) ); } -/** Credential injection uses bank.configPath, never a shared file's path field. */ +/** Credential injection uses a private configPath, never a shared file's path field. */ export class PrivateFileOperationError extends Error { constructor() { - super('Private bank configuration cannot be read or changed by file operations.'); + super( + 'Private bank configuration and moderation configuration cannot be read or changed by file operations.' + ); } } diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index 31b7f20..21eca18 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -253,7 +253,7 @@ export function createScript(input: CreateScriptInput): { if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty'); if (isReservedConfigFilename(targetRelPath)) throw new ScriptError( - 'electric_cherry_bank.cfg is reserved for instance credentials.', + `${targetRelPath} is reserved for instance credentials.`, 'path-reserved' ); diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index cb1c91d..b22c8cd 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -1,6 +1,6 @@ import { assertPublicFilePayload, - BANK_CONFIG_FILE, + PRIVATE_CONFIG_FILES, PrivateFileOperationError, isPrivateConfigPath } from '../fleet/private-config.js'; @@ -1454,7 +1454,7 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer { .get(id, hostId) as { payload: Buffer } | undefined; if (!row) throw new Error('Host operation was not found for this host.'); // Block old private-file commands before delivery to agents predating the guard. - if (row.payload.includes(BANK_CONFIG_FILE)) + if (PRIVATE_CONFIG_FILES.some((filename) => row.payload.includes(filename))) assertPublicFilePayload(JSON.parse(row.payload.toString())); if ( !row.payload.includes('"contentSha256"') || @@ -1485,10 +1485,15 @@ export function reportHostOperationStatus( if (['completed', 'failed'].includes(operation.state)) return operation; const state = input.state as HostOperationState; if (operation.state === 'running' && state === 'accepted') return operation; - const error = + let error = input.error === undefined || input.error === null ? null : String(input.error).slice(0, 4000); + if (operation.kind === 'instance.configure-moderation' && error) + error = error.replace( + /ec_(?:moderation|bank)_[A-Za-z0-9_-]+/g, + '[redacted credential]' + ); let result = input.result === undefined ? null : JSON.stringify(input.result); if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024) throw new Error('Operation result exceeds the limit.'); @@ -1504,7 +1509,9 @@ export function reportHostOperationStatus( ? { redacted: true } : operation.kind === 'logs.read' ? logResultMetadata(input.result) - : input.result + : operation.kind === 'instance.configure-moderation' + ? moderationConfigurationReceipt(input.result) + : input.result ); result = JSON.stringify(packed.value); referenceBlobs('operation-result', id, packed.hashes); @@ -1568,6 +1575,22 @@ export function reportHostOperationStatus( queueMicrotask(() => workEvents.emit(`operation:${id}`)); return operation; } + +/** Configuration replies contain status only, never echoed dvars, file bodies or credentials. */ +function moderationConfigurationReceipt(value: unknown): Record { + const result = + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + return { + configured: result.configured === true, + ...(Number.isSafeInteger(result.generation) ? { generation: result.generation } : {}), + liveLoad: ['sent', 'unconfirmed', 'deferred'].includes(result.liveLoad as string) + ? result.liveLoad + : 'unconfirmed', + ...(result.reconciled === true ? { reconciled: true } : {}) + }; +} export function reportHostOperation( hostId: string, id: string, diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts new file mode 100644 index 0000000..48b7331 --- /dev/null +++ b/src/lib/server/moderation/actions.ts @@ -0,0 +1,396 @@ +import { randomUUID } from 'node:crypto'; +import { db } from '../db/index.js'; +import { AuthError } from '../auth/index.js'; +import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; +import { + canRevokeModerationRestriction, + moderationGuid, + type ModerationGame, + type ModerationScope +} from '../../moderation.js'; +import { + advancePolicy, + ensureSubject, + moderationEvent, + moderationMutation, + subject, + textField +} from './core.js'; + +export const runtimeFreshMs = 15_000; +function gameText(value: unknown, label: string): string { + const text = textField(value, label, 240); + if (Buffer.byteLength(text, 'utf8') > 240) + throw new AuthError(`${label} must fit within 240 UTF-8 bytes for the game server.`); + return text; +} +type SanctionRow = { + id: string; + game: ModerationGame; + guid: string; + actor_id: string; + scope: ModerationScope; + created_at: string; + expires_at: string | null; + revision: number; + revoked_at: string | null; +}; +type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string }; + +function serverTarget(name: unknown, game?: ModerationGame) { + const server = textField(name, 'Server', 255); + const row = db() + .prepare( + `SELECT s.name,s.game,s.created_at,n.id AS instance_id,n.generation + FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL + WHERE s.name=? AND s.destroyed_at IS NULL` + ) + .get(server) as + | { + name: string; + game: ModerationGame; + created_at: string; + instance_id: string | null; + generation: number | null; + } + | undefined; + if (!row || (game && row.game !== game)) + throw new AuthError('Choose an existing server for this game.', 404); + return row; +} + +function createSanction(actor: AuthUser, input: Record, now: string) { + const target = subject(input); + if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.'); + if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope)) + throw new AuthError('Choose a restriction scope.'); + const duration = input.durationMinutes; + if ( + duration !== null && + (!Number.isSafeInteger(duration) || + (duration as number) < 1 || + (duration as number) > 5256000) + ) + throw new AuthError('Choose a duration between 1 minute and 10 years, or permanent.'); + if ( + actor.role !== 'admin' && + (input.scope === 'fleet' || + duration === null || + (duration as number) * 60_000 > moderatorMaximumRestrictionMs) + ) + throw new AuthError( + 'Moderators can apply server or game restrictions lasting up to 7 days.', + 403 + ); + // Fleet identity is only shared where both games have reported the same numeric account ID. + if ( + input.scope === 'fleet' && + (!/^\d+$/.test(target.guid) || + ( + db() + .prepare( + 'SELECT COUNT(DISTINCT game) AS count FROM moderation_aliases WHERE guid=?' + ) + .get(target.guid) as { count: number } + ).count !== 2) + ) + throw new AuthError( + 'Fleet restrictions require this account ID to have been observed in both games. Use game scope until its identity is established.' + ); + const reason = gameText(input.reason, 'Player-visible reason'); + const note = textField(input.note, 'Private note', 4000, true); + const server = input.scope === 'server' ? serverTarget(input.server, target.game) : null; + const activeCount = ( + db() + .prepare( + 'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)' + ) + .get(now) as { count: number } + ).count; + if (activeCount >= 1000) + throw new AuthError('The limit of 1,000 active restrictions has been reached.', 409); + const id = randomUUID(); + const expires = + duration === null + ? null + : new Date(Date.parse(now) + (duration as number) * 60_000).toISOString(); + ensureSubject(target.game, target.guid); + db() + .prepare( + `INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)` + ) + .run( + id, + target.game, + target.guid, + input.kind, + input.scope, + server?.name ?? null, + server?.created_at ?? null, + reason, + note, + actor.id, + actor.username, + now, + expires + ); + advancePolicy(); + moderationEvent( + actor, + 'restriction.created', + target, + id, + JSON.stringify({ + kind: input.kind, + scope: input.scope, + server: server?.name ?? null, + reason, + expiresAt: expires, + note + }), + now + ); + return { id }; +} +function revokeSanction(actor: AuthUser, input: Record, now: string) { + const id = textField(input.id, 'Restriction ID', 128); + const row = db().prepare('SELECT * FROM moderation_sanctions WHERE id=?').get(id) as + SanctionRow | undefined; + if (!row) throw new AuthError('Restriction not found.', 404); + if (actor.role !== 'admin' && row.actor_id !== actor.id) + throw new AuthError( + 'Only an administrator can revoke another moderator’s restriction.', + 403 + ); + if ( + !canRevokeModerationRestriction(actor, { + actorId: row.actor_id, + scope: row.scope, + createdAt: row.created_at, + expiresAt: row.expires_at + }) + ) + throw new AuthError( + 'Moderators can only revoke their own server or game restrictions originally lasting up to 7 days.', + 403 + ); + if (row.revoked_at || row.revision !== input.revision) + throw new AuthError('This restriction changed. Refresh before trying again.', 409); + const reason = textField(input.reason, 'Revocation reason', 1000); + db() + .prepare('UPDATE moderation_sanctions SET revoked_at=?,revision=revision+1 WHERE id=?') + .run(now, id); + advancePolicy(); + moderationEvent(actor, 'restriction.revoked', row, id, reason, now); + return { id }; +} +function liveAction(actor: AuthUser, input: Record, now: string) { + if ( + typeof input.kind !== 'string' || + !['warn', 'message', 'kick', 'announce'].includes(input.kind) + ) + throw new AuthError('Choose a supported live action.'); + const target = input.kind === 'announce' ? null : subject(input); + const server = serverTarget(input.server, target?.game); + const runtime = db() + .prepare('SELECT * FROM moderation_runtimes WHERE instance_id=? AND generation=?') + .get(server.instance_id, server.generation) as + | { + session: string; + players: string; + capabilities: string; + connected_at: string; + } + | undefined; + if (!runtime || Date.parse(now) - Date.parse(runtime.connected_at) > runtimeFreshMs) + throw new AuthError( + 'The moderation integration is not connected to this server. Wait for a fresh connection.', + 409 + ); + if (!(JSON.parse(runtime.capabilities) as string[]).includes(String(input.kind))) + throw new AuthError('This server does not support that action.', 409); + const matches = (JSON.parse(runtime.players) as { guid: string; connection: string }[]).filter( + (p) => p.guid === target?.guid + ); + if (target && matches.length !== 1) + throw new AuthError( + 'This player’s current connection could not be identified. Refresh the roster.', + 409 + ); + const recent = ( + db() + .prepare( + 'SELECT COUNT(*) AS count FROM moderation_actions WHERE actor_id=? AND created_at>?' + ) + .get(actor.id, new Date(Date.parse(now) - 60_000).toISOString()) as { count: number } + ).count; + if (recent >= 30) + throw new AuthError('Too many moderation actions. Wait a minute before continuing.', 429); + const reason = gameText(input.reason, input.kind === 'kick' ? 'Kick reason' : 'Message'); + const id = randomUUID(); + db() + .prepare( + `INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,connection,kind,reason,actor_id,actor,created_at,expires_at,state) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'queued')` + ) + .run( + id, + server.game, + target?.guid ?? null, + server.name, + server.instance_id, + server.generation, + runtime.session, + target ? matches[0].connection : null, + input.kind, + reason, + actor.id, + actor.username, + now, + new Date(Date.parse(now) + 30_000).toISOString() + ); + moderationEvent( + actor, + 'action.queued', + { game: server.game, guid: target?.guid }, + id, + JSON.stringify({ kind: input.kind, server: server.name, reason }), + now + ); + return { id }; +} +function caseRow(value: unknown): CaseRow { + const row = db() + .prepare('SELECT * FROM moderation_cases WHERE id=?') + .get(textField(value, 'Case ID', 128)) as CaseRow | undefined; + if (!row) throw new AuthError('Case not found.', 404); + return row; +} +function evidence( + actor: AuthUser, + id: string, + text: string, + source: string, + snapshot: string | null, + now: string +) { + if ( + ( + db() + .prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?') + .get(id) as { count: number } + ).count >= 100 + ) + throw new AuthError('This case already contains 100 evidence entries.', 409); + db() + .prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)') + .run(randomUUID(), id, text, source, snapshot, actor.id, actor.username, now); +} +function createCase(actor: AuthUser, input: Record, now: string) { + const target = subject(input); + const title = textField(input.title, 'Case title', 160); + const note = textField(input.note, 'Case note', 4000, true); + const id = randomUUID(); + ensureSubject(target.game, target.guid); + db() + .prepare( + `INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)` + ) + .run(id, target.game, target.guid, title, actor.id, actor.username, now, now); + if (note) evidence(actor, id, note, 'Staff note', null, now); + if (input.evidence !== undefined) { + const requested = input.evidence as { matchId: unknown; message: unknown }; + if (!requested || !Number.isSafeInteger(requested.matchId)) + throw new AuthError('Choose a recorded chat message.'); + const message = textField(requested.message, 'Chat message', 4096); + const match = db() + .prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?') + .get(requested.matchId) as + { game: string; server: string; started_at: string } | undefined; + if (!match || match.game !== target.game) + throw new AuthError('The source match is unavailable.', 404); + const entries = db() + .prepare('SELECT entry FROM match_chat WHERE match_id=?') + .all(requested.matchId) as { entry: string }[]; + const found = entries + .filter((row) => { + const entry = JSON.parse(row.entry); + return ( + entry.type === 'message' && + moderationGuid(target.game, entry.message.guid) === target.guid && + entry.message.message === message + ); + }) + .slice(0, 10); + if (!found.length) + throw new AuthError( + 'The original chat message could not be verified in the archive.', + 409 + ); + for (const row of found) + evidence( + actor, + id, + message, + `Archived chat · ${match.server} · match ${requested.matchId}`, + JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }), + now + ); + } + moderationEvent(actor, 'case.opened', target, id, title, now); + return { id }; +} +export function mutateModeration(actorId: string, input: Record) { + return moderationMutation(actorId, input, (actor, now) => { + if (input.op === 'create-sanction') return createSanction(actor, input, now); + if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now); + if (input.op === 'action') return liveAction(actor, input, now); + if (input.op === 'create-case') return createCase(actor, input, now); + if (input.op === 'case-note') { + const row = caseRow(input.id); + const text = textField(input.text, 'Note', 4000); + evidence(actor, row.id, text, 'Staff note', null, now); + db() + .prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?') + .run(now, row.id); + moderationEvent(actor, 'case.note', row, row.id, text, now); + return { id: row.id }; + } + if (input.op === 'resolve-case') { + const row = caseRow(input.id); + if (row.revision !== input.revision || row.status !== 'open') + throw new AuthError('This case changed. Refresh before resolving it.', 409); + if (input.status !== 'resolved' && input.status !== 'dismissed') + throw new AuthError('Choose resolved or dismissed.'); + const reason = textField(input.reason, 'Decision reason', 4000); + evidence( + actor, + row.id, + reason, + input.status === 'resolved' ? 'Resolution' : 'Dismissal', + null, + now + ); + db() + .prepare( + 'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?' + ) + .run(input.status, now, row.id); + moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now); + return { id: row.id }; + } + if (input.op === 'note') { + const target = subject(input); + const text = textField(input.text, 'Private note', 4000); + ensureSubject(target.game, target.guid); + moderationEvent(actor, 'player.note', target, null, text, now); + return { + id: String( + (db().prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id + ) + }; + } + throw new AuthError('Unknown moderation action.'); + }); +} diff --git a/src/lib/server/moderation/configuration.ts b/src/lib/server/moderation/configuration.ts new file mode 100644 index 0000000..01ec0d6 --- /dev/null +++ b/src/lib/server/moderation/configuration.ts @@ -0,0 +1,49 @@ +import { config } from '../config.js'; +import { MODERATION_CONFIG_FILE } from '../fleet/private-config.js'; +import { AuthError } from '../auth/index.js'; + +export const moderationConfigureCapability = 'moderation.configure'; +export interface ModerationConfiguration { + token: string; + configPath: string; + url: string; + generation: number; +} + +/** The game connects to the public controller, never an agent's private enrollment URL. */ +export function moderationRuntimeUrl(): string { + let base: URL; + try { + base = new URL(config.publicUrl); + } catch { + throw new AuthError( + 'Set EC_PUBLIC_URL to the public HTTPS controller URL before configuring moderation.', + 409 + ); + } + if ( + base.protocol !== 'https:' || + base.username || + base.password || + base.search || + base.hash || + base.pathname !== '/' + ) + throw new AuthError( + 'EC_PUBLIC_URL must be a public HTTPS origin without a path, credentials, query or fragment.', + 409 + ); + return new URL('/api/moderation/runtime', base).href; +} + +export function moderationConfiguration( + token: string, + generation: number +): ModerationConfiguration { + return { + token, + generation, + configPath: `configs/${MODERATION_CONFIG_FILE}`, + url: moderationRuntimeUrl() + }; +} diff --git a/src/lib/server/moderation/configure.ts b/src/lib/server/moderation/configure.ts new file mode 100644 index 0000000..45ff308 --- /dev/null +++ b/src/lib/server/moderation/configure.ts @@ -0,0 +1,54 @@ +import { AuthError } from '../auth/index.js'; +import { nativeInstance } from '../backends/native.js'; +import { db } from '../db/index.js'; +import { getHost, queueHostOperation } from '../hosts/index.js'; +import { moderationActor, moderationEvent } from './core.js'; +import { moderationCredential } from './credentials.js'; +import { moderationConfiguration, moderationConfigureCapability } from './configuration.js'; + +/** Persist a private, generation-bound bootstrap without exposing the credential to the browser. */ +export function configureModerationServer(actorId: string, server: string): { id: string } { + return db() + .transaction(() => { + const actor = moderationActor(actorId, true); + if (actor.role !== 'admin') + throw new AuthError( + 'Administrator access is required to configure game moderation.', + 403 + ); + const instance = nativeInstance(server); + const live = db() + .prepare('SELECT destroyed_at FROM servers WHERE name=?') + .get(server) as { destroyed_at: string | null } | undefined; + if (!instance || !live || live.destroyed_at) + throw new AuthError('Choose an active native server.', 404); + const host = getHost(instance.host_id); + if (!host?.inventory.capabilities?.includes(moderationConfigureCapability)) + throw new AuthError( + 'Update the host agent before configuring game moderation.', + 409 + ); + const moderation = moderationConfiguration( + moderationCredential(instance.id, instance.generation), + instance.generation + ); + const operation = queueHostOperation( + instance.host_id, + 'instance.configure-moderation', + { moderation }, + { + instanceId: instance.id, + generation: instance.generation + } + ); + moderationEvent( + actor, + 'runtime.configure', + {}, + operation.id, + `Configure game moderation on ${server}.` + ); + return { id: operation.id }; + }) + .immediate(); +} diff --git a/src/lib/server/moderation/core.ts b/src/lib/server/moderation/core.ts index 70446af..4c25f97 100644 --- a/src/lib/server/moderation/core.ts +++ b/src/lib/server/moderation/core.ts @@ -44,7 +44,7 @@ export function observeModerationPlayers( ): void { const upsert = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT(game,guid) DO UPDATE SET name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END, - first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`); + first_seen_at=CASE WHEN first_seen_at='' THEN excluded.first_seen_at ELSE MIN(first_seen_at,excluded.first_seen_at) END,last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`); const aliases = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?) ON CONFLICT(game,guid,name) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`); for (const player of players) { @@ -53,10 +53,10 @@ export function observeModerationPlayers( aliases.run(...values); } } -export function ensureSubject(game: ModerationGame, guid: string, now: string): void { +export function ensureSubject(game: ModerationGame, guid: string): void { db() .prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING') - .run(game, guid, guid, now, now); + .run(game, guid, guid, '', ''); } export function moderationEvent( actor: Pick | null, diff --git a/src/lib/server/moderation/credentials.ts b/src/lib/server/moderation/credentials.ts new file mode 100644 index 0000000..b8d10a7 --- /dev/null +++ b/src/lib/server/moderation/credentials.ts @@ -0,0 +1,55 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { db } from '../db/index.js'; +import type { ModerationGame } from '../../moderation.js'; + +const digest = (value: string) => createHash('sha256').update(value).digest('hex'); +export function moderationCredential(instanceId: string, generation: number): string { + return db() + .transaction(() => { + if ( + !Number.isSafeInteger(generation) || + !db() + .prepare( + 'SELECT 1 FROM native_instances WHERE id=? AND generation IN (?,?) AND retired_at IS NULL' + ) + .get(instanceId, generation, generation - 1) + ) + throw new Error('Moderation credentials require an active instance generation.'); + const existing = db() + .prepare( + 'SELECT token FROM moderation_credentials WHERE instance_id=? AND generation=?' + ) + .get(instanceId, generation) as { token: string } | undefined; + if (existing) return existing.token; + const token = 'ec_moderation_' + randomBytes(32).toString('base64url'); + db() + .prepare('INSERT INTO moderation_credentials VALUES(?,?,?,?,?)') + .run(instanceId, generation, token, digest(token), new Date().toISOString()); + return token; + }) + .immediate(); +} +export interface ModerationRuntimeIdentity { + instanceId: string; + generation: number; + server: string; + game: ModerationGame; + serverCreatedAt: string; + credentialHash: string; +} +export function authenticateModerationRuntime(request: Request): ModerationRuntimeIdentity | null { + const token = request.headers + .get('authorization') + ?.match(/^Bearer (ec_moderation_[A-Za-z0-9_-]{43})$/)?.[1]; + if (!token) return null; + return ( + (db() + .prepare( + `SELECT n.id AS instanceId,n.generation,n.server,s.game,s.created_at AS serverCreatedAt,c.token_hash AS credentialHash + FROM moderation_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.generation=c.generation + JOIN servers s ON s.name=n.server + WHERE c.token_hash=? AND n.retired_at IS NULL AND s.destroyed_at IS NULL AND s.game IN ('t5','t6')` + ) + .get(digest(token)) as ModerationRuntimeIdentity | undefined) ?? null + ); +} diff --git a/src/lib/server/moderation/overview.ts b/src/lib/server/moderation/overview.ts new file mode 100644 index 0000000..4a0178f --- /dev/null +++ b/src/lib/server/moderation/overview.ts @@ -0,0 +1,210 @@ +import { db } from '../db/index.js'; +import { moderationActor, moderationNow, policyRevision } from './core.js'; +import { runtimeFreshMs } from './actions.js'; +import { refreshActionStates } from './runtime.js'; +import { + moderationGuid, + type ModerationOverview, + type ModerationQuery, + type ModerationPlayer, + type ModerationSanction, + type ModerationCase, + type ModerationEvent, + type ModerationAction, + type ModerationCoverage +} from '../../moderation.js'; + +export function moderationQuery(params: URLSearchParams): ModerationQuery { + const view = params.get('view'); + const game = params.get('game'); + const guid = (params.get('guid') ?? '').slice(0, 128); + const offset = Number(params.get('offset') ?? 0); + return { + view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players', + q: (params.get('q') ?? '').trim().slice(0, 200), + game: game === 't5' || game === 't6' ? game : '', + guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid, + offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0 + }; +} +export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview { + moderationActor(actorId); + return db().transaction(() => { + const handle = db(), + now = moderationNow(), + limit = 25; + refreshActionStates(now); + const revision = policyRevision(); + const runtimeRows = handle + .prepare( + `SELECT s.name AS server,s.game,n.id,r.connected_at AS connectedAt,r.revision,r.capabilities,r.players + FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL + LEFT JOIN moderation_runtimes r ON r.instance_id=n.id AND r.generation=n.generation + WHERE s.destroyed_at IS NULL AND s.game IN ('t5','t6') ORDER BY s.name` + ) + .all() as { + server: string; + game: 't5' | 't6'; + id: string | null; + connectedAt: string | null; + revision: number | null; + capabilities: string | null; + players: string | null; + }[]; + const coverage: ModerationCoverage[] = runtimeRows.map((row) => ({ + server: row.server, + game: row.game, + connectedAt: row.connectedAt, + revision: row.revision, + desiredRevision: revision, + state: !row.connectedAt + ? 'unavailable' + : Date.parse(now) - Date.parse(row.connectedAt) > runtimeFreshMs + ? 'offline' + : row.revision === revision && + ['ban', 'mute'].every((capability) => + (JSON.parse(row.capabilities ?? '[]') as string[]).includes(capability) + ) + ? 'current' + : 'pending', + capabilities: row.capabilities ? JSON.parse(row.capabilities) : [] + })); + const result: ModerationOverview = { + query: { ...query }, + players: [], + sanctions: [], + cases: [], + events: [], + actions: [], + coverage, + total: 0, + limit, + checkedAt: now + }; + const where: string[] = [], + values: (string | number)[] = []; + if (query.game) { + where.push( + query.view === 'restrictions' ? "(p.game=? OR p.scope='fleet')" : 'p.game=?' + ); + values.push(query.game); + } + if (query.guid) { + where.push('p.guid=?'); + values.push(query.guid); + } + if (query.q) { + const extra = + query.view === 'restrictions' + ? 'p.reason' + : query.view === 'cases' + ? 'p.title' + : query.view === 'history' + ? 'p.detail' + : 'p.name'; + where.push( + `(instr(lower(p.guid),lower(?))>0 OR instr(lower(${extra}),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=p.game AND a.guid=p.guid AND instr(lower(a.name),lower(?))>0))` + ); + values.push(query.q, query.q, query.q); + } + const clause = where.length ? ' WHERE ' + where.join(' AND ') : ''; + function rows(table: string, select: string, order: string): T[] { + result.total = ( + handle + .prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`) + .get(...values) as { count: number } + ).count; + result.query.offset = Math.min( + query.offset, + Math.max(0, Math.floor((result.total - 1) / limit) * limit) + ); + return handle + .prepare( + `SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?` + ) + .all(...values, limit, result.query.offset) as T[]; + } + if (query.view === 'players') { + result.players = rows>( + 'moderation_players', + "p.game,p.guid,p.name,NULLIF(p.first_seen_at,'') AS firstSeen,NULLIF(p.last_seen_at,'') AS lastSeen", + 'p.last_seen_at DESC,p.game,p.guid' + ).map((player) => ({ + ...player, + aliases: ( + handle + .prepare( + 'SELECT name FROM moderation_aliases WHERE game=? AND guid=? ORDER BY last_seen_at DESC LIMIT 30' + ) + .all(player.game, player.guid) as { name: string }[] + ).map((a) => a.name), + servers: runtimeRows + .filter( + (r) => + r.game === player.game && + r.connectedAt && + Date.parse(now) - Date.parse(r.connectedAt) <= runtimeFreshMs && + r.players && + (JSON.parse(r.players) as { guid: string }[]).some( + (p) => p.guid === player.guid + ) + ) + .map((r) => r.server), + restrictions: ( + handle + .prepare( + "SELECT COUNT(*) AS count FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet') AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)" + ) + .get(player.guid, player.game, now) as { count: number } + ).count + })); + } else if (query.view === 'restrictions') { + result.sanctions = rows>( + 'moderation_sanctions', + 'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision', + 'p.created_at DESC,p.id' + ).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) })); + } else if (query.view === 'cases') { + result.cases = rows>( + 'moderation_cases', + 'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision', + 'p.updated_at DESC,p.id' + ).map((c) => ({ + ...c, + evidence: handle + .prepare( + 'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id' + ) + .all(c.id) as ModerationCase['evidence'] + })); + } else { + result.events = rows( + 'moderation_events', + 'p.id,p.game,p.guid,p.action,p.subject_id AS subjectId,p.actor,p.detail,p.created_at AS createdAt', + 'p.id DESC' + ); + } + const actionWhere: string[] = [], + actionValues: string[] = []; + if (query.game) { + actionWhere.push('game=?'); + actionValues.push(query.game); + } + if (query.guid) { + actionWhere.push('guid=?'); + actionValues.push(query.guid); + } + if (query.q) { + actionWhere.push( + '(instr(lower(guid),lower(?))>0 OR instr(lower(reason),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=moderation_actions.game AND a.guid=moderation_actions.guid AND instr(lower(a.name),lower(?))>0))' + ); + actionValues.push(query.q, query.q, query.q); + } + result.actions = handle + .prepare( + `SELECT id,game,guid,server,kind,reason,actor,created_at AS createdAt,expires_at AS expiresAt,state,detail FROM moderation_actions${actionWhere.length ? ' WHERE ' + actionWhere.join(' AND ') : ''} ORDER BY created_at DESC,id LIMIT 25` + ) + .all(...actionValues) as ModerationAction[]; + return result; + })(); +} diff --git a/src/lib/server/moderation/runtime.ts b/src/lib/server/moderation/runtime.ts new file mode 100644 index 0000000..c7e8245 --- /dev/null +++ b/src/lib/server/moderation/runtime.ts @@ -0,0 +1,313 @@ +import { db } from '../db/index.js'; +import { AuthError, getUser } from '../auth/index.js'; +import { canModerate } from '../../auth.js'; +import { moderationGuid } from '../../moderation.js'; +import { + moderationEvent, + moderationNow, + observeModerationPlayers, + policyRevision, + textField +} from './core.js'; +import type { ModerationRuntimeIdentity } from './credentials.js'; +export { moderationCredential } from './credentials.js'; + +const capabilities = ['warn', 'message', 'kick', 'announce', 'ban', 'mute']; +export function refreshActionStates(now = moderationNow()): void { + const rows = db() + .prepare( + "SELECT id,game,guid,state FROM moderation_actions WHERE state IN ('queued','delivered') AND expires_at<=?" + ) + .all(now) as { id: string; game: string; guid: string | null; state: string }[]; + for (const row of rows) { + const state = row.state === 'queued' ? 'expired' : 'unknown'; + const detail = + state === 'expired' + ? 'The action expired before delivery.' + : 'The game did not acknowledge the delivered action before its deadline.'; + db() + .prepare( + 'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=? AND state=?' + ) + .run(state, detail, now, row.id, row.state); + moderationEvent(null, `action.${state}`, row, row.id, detail, now); + } +} +function token(value: unknown, label: string): string { + const result = textField(value, label, 128); + if (!/^[A-Za-z0-9:_-]+$/.test(result)) throw new AuthError(`Invalid ${label.toLowerCase()}.`); + return result; +} +export function syncModerationRuntime( + identity: ModerationRuntimeIdentity, + input: Record +) { + if ( + input.protocol !== 1 || + input.server !== identity.server || + input.generation !== identity.generation + ) + throw new AuthError('The runtime identity does not match this credential.', 403); + const session = token(input.session, 'Runtime session'); + if (!Number.isSafeInteger(input.sessionSequence) || (input.sessionSequence as number) < 1) + throw new AuthError('A positive runtime session sequence is required.'); + if ( + !Array.isArray(input.players) || + input.players.length > 64 || + !Array.isArray(input.receipts) || + input.receipts.length > 256 || + !Array.isArray(input.capabilities) || + input.capabilities.length > capabilities.length || + !Number.isSafeInteger(input.appliedRevision) || + (input.appliedRevision as number) < 0 + ) + throw new AuthError('Invalid moderation synchronization request.'); + const supported = [...new Set(input.capabilities)]; + if (supported.some((c) => typeof c !== 'string' || !capabilities.includes(c))) + throw new AuthError('Unknown moderation capability.'); + const players = input.players.map((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new AuthError('Invalid player.'); + const raw = token(value.guid, 'Player GUID'); + const name = textField(value.name, 'Player name', 255); + if (Buffer.byteLength(name, 'utf8') > 255) + throw new AuthError('Player names must fit within 255 UTF-8 bytes.'); + return { + guid: moderationGuid(identity.game, raw), + name, + connection: token(value.connection, 'Player connection') + }; + }); + if ( + new Set(players.map((p) => p.connection)).size !== players.length || + new Set(players.map((p) => p.guid)).size !== players.length + ) + throw new AuthError('Player identities and connections must be unique.'); + const receipts = input.receipts.map((value) => { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + !['confirmed', 'failed', 'unknown', 'expired'].includes(value.state) + ) + throw new AuthError('Invalid action receipt.'); + return { + id: token(value.id, 'Action ID'), + state: String(value.state), + detail: textField(value.detail, 'Receipt detail', 500, true) + }; + }); + return db() + .transaction(() => { + // Reading a request body yields: retirement/rebuild/credential replacement may + // have completed since the HTTP handler first authenticated this request. + const current = db() + .prepare( + `SELECT 1 FROM native_instances n + JOIN servers s ON s.name=n.server + JOIN moderation_credentials c ON c.instance_id=n.id AND c.generation=n.generation + WHERE n.id=? AND n.generation=? AND n.retired_at IS NULL AND n.server=? + AND s.destroyed_at IS NULL AND s.game=? AND s.created_at=? AND c.token_hash=?` + ) + .get( + identity.instanceId, + identity.generation, + identity.server, + identity.game, + identity.serverCreatedAt, + identity.credentialHash + ); + if (!current) + throw new AuthError('This moderation runtime credential is no longer active.', 403); + const previous = db() + .prepare( + 'SELECT session,session_sequence FROM moderation_runtimes WHERE instance_id=? AND generation=?' + ) + .get(identity.instanceId, identity.generation) as + { session: string; session_sequence: number } | undefined; + if ( + previous && + ((input.sessionSequence as number) < previous.session_sequence || + (input.sessionSequence === previous.session_sequence && + session !== previous.session)) + ) + throw new AuthError('This runtime session has been superseded.', 409); + const now = moderationNow(); + const revision = policyRevision(); + if ((input.appliedRevision as number) > revision) + throw new AuthError( + 'The runtime has a newer policy revision than this controller. Review the controller restore before reconfiguring this integration.', + 409 + ); + refreshActionStates(now); + for (const receipt of receipts) { + const action = db() + .prepare( + 'SELECT * FROM moderation_actions WHERE id=? AND instance_id=? AND generation=?' + ) + .get(receipt.id, identity.instanceId, identity.generation) as + | { + id: string; + game: string; + guid: string | null; + session: string; + state: string; + delivered_at: string | null; + detail: string | null; + } + | undefined; + // Old journal receipts can outlive controller retention/restores, but cannot target other instances. + if (!action) continue; + if (!action.delivered_at) + throw new AuthError('An undelivered action cannot be acknowledged.', 409); + if (['confirmed', 'failed', 'expired'].includes(action.state)) continue; + // Durable native receipts may be resubmitted after a game process restart. + if (action.state === receipt.state && action.detail === receipt.detail) continue; + db() + .prepare( + 'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?' + ) + .run(receipt.state, receipt.detail || null, now, action.id); + moderationEvent( + null, + `action.${receipt.state}`, + action, + action.id, + receipt.detail || receipt.state, + now + ); + } + if (previous && previous.session !== session) { + const abandoned = db() + .prepare( + "SELECT id,game,guid,state FROM moderation_actions WHERE instance_id=? AND generation=? AND session<>? AND state IN ('queued','delivered')" + ) + .all(identity.instanceId, identity.generation, session) as { + id: string; + game: string; + guid: string | null; + state: string; + }[]; + for (const action of abandoned) { + const state = action.state === 'queued' ? 'expired' : 'unknown'; + db() + .prepare( + 'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?' + ) + .run(state, 'The game runtime changed before completion.', now, action.id); + moderationEvent( + null, + `action.${state}`, + action, + action.id, + 'The game runtime changed before completion.', + now + ); + } + } + db() + .prepare( + `INSERT INTO moderation_runtimes(instance_id,generation,session,capabilities,players,revision,connected_at,session_sequence) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(instance_id) DO UPDATE SET + generation=excluded.generation,session=excluded.session,capabilities=excluded.capabilities,players=excluded.players, + revision=excluded.revision,connected_at=excluded.connected_at,session_sequence=excluded.session_sequence` + ) + .run( + identity.instanceId, + identity.generation, + session, + JSON.stringify(supported), + JSON.stringify(players), + input.appliedRevision, + now, + input.sessionSequence + ); + observeModerationPlayers(db(), identity.game, players, now); + const sanctions = db() + .prepare( + `SELECT id,revision,guid,kind,reason,expires_at AS expiresAt FROM moderation_sanctions + WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?) AND + ((scope='fleet') OR (game=? AND (scope='game' OR (scope='server' AND server=? AND server_created_at=?)))) ORDER BY id LIMIT 1001` + ) + .all(now, identity.game, identity.server, identity.serverCreatedAt); + if (sanctions.length > 1000) + throw new AuthError( + 'The moderation policy exceeds the runtime’s supported size.', + 503 + ); + const pending = db() + .prepare( + `SELECT * FROM moderation_actions WHERE instance_id=? AND generation=? AND session=? + AND state IN ('queued','delivered') AND expires_at>? ORDER BY created_at,id LIMIT 100` + ) + .all(identity.instanceId, identity.generation, session, now) as { + id: string; + guid: string | null; + connection: string | null; + kind: string; + reason: string; + expires_at: string; + actor_id: string; + game: string; + state: string; + }[]; + const actions = []; + for (const action of pending) { + const actor = getUser(action.actor_id); + let failure: string | null = null; + if ( + !actor || + actor.disabled || + actor.mustChangePassword || + !canModerate(actor.role) + ) + failure = 'The requesting moderator no longer has access.'; + else if (!supported.includes(action.kind)) + failure = 'The runtime no longer supports this action.'; + else if ( + action.guid && + !players.some( + (p) => p.guid === action.guid && p.connection === action.connection + ) + ) + failure = 'The targeted player connection has ended.'; + if (failure) { + const state = action.state === 'delivered' ? 'unknown' : 'failed'; + db() + .prepare( + 'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?' + ) + .run(state, failure, now, action.id); + moderationEvent(null, `action.${state}`, action, action.id, failure, now); + continue; + } + db() + .prepare( + "UPDATE moderation_actions SET state='delivered',delivered_at=COALESCE(delivered_at,?) WHERE id=?" + ) + .run(now, action.id); + actions.push({ + id: action.id, + guid: action.guid, + connection: action.connection, + kind: action.kind, + reason: action.reason, + expiresAt: action.expires_at + }); + } + const response = { + ok: true, + revision, + serverTime: now, + sanctions, + actions, + pollAfterMs: 3000 + }; + if (Buffer.byteLength(JSON.stringify(response)) > 1024 * 1024) + throw new AuthError( + 'The moderation response exceeds the runtime’s supported size.', + 503 + ); + return response; + }) + .immediate(); +} diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index 08097c6..a688933 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -79,23 +79,33 @@ export function migrateModeration(handle: Database.Database): void { const alias = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?) ON CONFLICT(game,guid,name) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`); - for (const row of handle - .prepare("SELECT * FROM leaderboard_players WHERE game IN ('t5','t6')") - .iterate() as Iterable<{ + type Player = { game: ModerationGame; guid: string; name: string; first_seen_at: string; last_seen_at: string; - }>) { - const values = [ - row.game, - moderationGuid(row.game, row.guid), - row.name, - row.first_seen_at, - row.last_seen_at - ]; - player.run(...values); - alias.run(...values); + }; + const read = handle.prepare(`SELECT * FROM leaderboard_players + WHERE game IN ('t5','t6') AND (game,guid)>(?,?) ORDER BY game,guid LIMIT 1000`); + let cursor = ['', '']; + for (;;) { + // Finish each read before writing on this connection, without loading + // the full historical player directory into memory during an upgrade. + const rows = read.all(...cursor) as Player[]; + if (!rows.length) break; + for (const row of rows) { + const values = [ + row.game, + moderationGuid(row.game, row.guid), + row.name, + row.first_seen_at, + row.last_seen_at + ]; + player.run(...values); + alias.run(...values); + } + const last = rows[rows.length - 1]; + cursor = [last.game, last.guid]; } } diff --git a/src/lib/server/provisioning/launch-preparation.ts b/src/lib/server/provisioning/launch-preparation.ts index d6e3b4a..a2e5a56 100644 --- a/src/lib/server/provisioning/launch-preparation.ts +++ b/src/lib/server/provisioning/launch-preparation.ts @@ -5,6 +5,7 @@ import { db } from '../db/index.js'; import type { NativeInstance } from '../backends/native.js'; import type { LaunchRecipePlan } from '../fleet/launch-recipes.js'; import type { PlacementOptions } from '../hosts/capacity.js'; +import type { ModerationConfiguration } from '../moderation/configuration.js'; export interface NativeLaunchInput { game: 't5' | 't6'; @@ -14,6 +15,7 @@ export interface NativeLaunchInput { serverKey: string; rconPassword: string; bankToken: string; + moderation?: ModerationConfiguration; rebuild: boolean; safetyOverride?: boolean; unhealthyRecovery?: boolean; diff --git a/src/lib/server/provisioning/launch-worker.ts b/src/lib/server/provisioning/launch-worker.ts index fc370a0..a3adc91 100644 --- a/src/lib/server/provisioning/launch-worker.ts +++ b/src/lib/server/provisioning/launch-worker.ts @@ -12,6 +12,7 @@ import { import { sha256Buffer } from '../fleet/files.js'; import type { NativeLaunchInput } from './launch-preparation.js'; import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js'; +import { isPrivateConfigPath } from '../fleet/private-config.js'; parentPort!.on( 'message', @@ -79,12 +80,16 @@ parentPort!.on( 'electric_cherry_bank.cfg' ); if ( - recipe.files.some( - (file) => - posix.join(relDestForKind(file.kind, recipe.game), file.path) === bankPath + recipe.files.some((file) => + isPrivateConfigPath( + posix.join(relDestForKind(file.kind, recipe.game), file.path), + recipe.game + ) ) ) - throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.'); + throw new Error( + 'Private configuration files are reserved for instance credentials.' + ); const payload = { ...(input.activationId ? { stageId: input.activationId } : {}), name: instance.server, @@ -95,13 +100,14 @@ parentPort!.on( bindAddress: instance.bind_address, rconPassword: password, bank: { token: input.bankToken, configPath: bankPath }, + ...(input.moderation ? { moderation: input.moderation } : {}), env: { PLUTAINER_SERVER_NAME: instance.server, PLUTAINER_PORT: String(instance.port), PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp', PLUTAINER_CONFIG_FILE: recipe.configFile, PLUTAINER_MAP_ROTATE: 'false', - PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg +set tws_server_id ${instance.server} +map_rotate`, + PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg${input.moderation ? ' +exec electric_cherry_moderation.cfg' : ''} +set tws_server_id ${instance.server} +map_rotate`, PLUTO_SERVER_KEY: input.serverKey }, files: recipe.files.map((file) => ({ diff --git a/src/lib/server/provisioning/native.ts b/src/lib/server/provisioning/native.ts index ffd8fc5..9a245a0 100644 --- a/src/lib/server/provisioning/native.ts +++ b/src/lib/server/provisioning/native.ts @@ -14,6 +14,11 @@ import { setImmediate as yieldToController } from 'node:timers/promises'; import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js'; import { randomBytes, randomUUID } from 'node:crypto'; import { bankCredential } from '../auth/bank.js'; +import { moderationCredential } from '../moderation/credentials.js'; +import { + moderationConfiguration, + moderationConfigureCapability +} from '../moderation/configuration.js'; import { posix } from 'node:path'; import { db } from '../db/index.js'; import { unsettledCancelledCommand } from './cancellation.js'; @@ -1996,6 +2001,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { let prepared: PreparedNativeLaunch | undefined; let preparedKey: string | null = null; let preparedPassword: string | undefined; + let preparedModeration = false; try { if (job.action === 'create' || job.action === 'rebuild') { if ( @@ -2027,6 +2033,11 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { (files.length ? runtimePasswordForServer(job.server) : undefined) || config.rcon.password || randomBytes(32).toString('hex'); + const rebuilding = job.action === 'rebuild' && !migration; + const generation = instance.generation + (rebuilding ? 1 : 0); + preparedModeration = + !!config.publicUrl && + !!host.inventory.capabilities?.includes(moderationConfigureCapability); prepared = await prepareNativeLaunch({ game: prior.game, mapSlug: prior.mapSlug, @@ -2035,7 +2046,15 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { serverKey: preparedKey, rconPassword: preparedPassword, bankToken: bankCredential(instance.id), - rebuild: job.action === 'rebuild' && !migration, + ...(preparedModeration + ? { + moderation: moderationConfiguration( + moderationCredential(instance.id, generation), + generation + ) + } + : {}), + rebuild: rebuilding, safetyOverride: overrideSafety, unhealthyRecovery: !files.length && @@ -2124,6 +2143,16 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { return; } if (prepared) { + if ( + preparedModeration && + !liveHost.inventory.capabilities?.includes(moderationConfigureCapability) + ) { + hold( + job, + 'Host moderation support changed during preparation; preparing the current selection next pass.' + ); + return; + } // Compare the same runtime config bytes while rechecking current promoted files. const expected = migration && isRebalanceMigration(job.id) diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index cc2186b..015e0d0 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -1,5 +1,8 @@ + + +
+
+ {#if targetRequired} + + {/if} + {#if mode !== 'action' || action !== 'announce'} + + {/if} + {/if} + {#if mode === 'restriction'} +
+ +
+ + {/if} + {#if actionUnavailable}

+ Select a connected server with support for this action. Unavailable actions + cannot be sent. +

{/if} + {#if mode === 'restriction'}

+ Restrictions are saved immediately. They take effect only on servers with the + required capability and current policy; review enforcement coverage after + saving. +

{/if} + {#if mode === 'case'} + + {#if seed.matchId && seed.message}
+ Selected chat evidence · Match #{seed.matchId} +

{seed.message}

+ The original archived message and its context will be preserved when + the case is saved. +
{/if} + {/if} + {#if mode === 'resolve'}