Compare commits

..
83 Commits
Author SHA1 Message Date
josh 0f05be3a59 Acknowledge Discord interactions before database work
CI / checks (push) Failing after 7m33s
2026-10-18 18:40:46 -04:00
josh d715173719 Adjust Discord publication and lobby capacity defaults
CI / checks (push) Failing after 7m21s
2026-10-18 17:23:50 -04:00
josh fd00ef5357 Add native Discord boards and availability alerts
CI / checks (push) Failing after 7m41s
2026-10-18 16:55:49 -04:00
josh f2b2541b46 Compact archived evidence transcripts (Refs #159)
CI / checks (push) Failing after 7m22s
2026-10-18 16:20:29 -04:00
josh a7f1c65d17 Separate moderation case status from decisions
CI / checks (push) Failing after 7m24s
2026-10-17 21:30:43 -04:00
josh 48653af3dc Index moderation case references for database integrity checks
CI / checks (push) Successful in 7m31s
2026-10-17 20:14:48 -04:00
josh 870f13b150 Capture team chat and expose moderation match archives 2026-10-17 20:11:49 -04:00
josh b678de2f6f Add case follow-up queues and personal update notifications 2026-10-17 20:00:13 -04:00
josh 90b94911c5 Add related cases and duplicate review suggestions 2026-10-17 19:52:50 -04:00
josh 428aefdf24 Add structured moderation assessments with revision history 2026-10-17 19:47:28 -04:00
josh aca728b557 Link prior moderation actions to case decisions 2026-10-17 19:42:05 -04:00
josh ef96a2c308 Preserve conversation excerpts and deduplicate case evidence 2026-10-17 19:35:52 -04:00
josh 83f1c91877 Sign agent 1.12.0 release (Refs #151)
CI / checks (push) Successful in 6m54s
2026-10-17 19:15:08 -04:00
josh 59f88ac33d Add AFK account and session overview (Refs #151)
CI / checks (push) Successful in 7m19s
2026-10-17 17:52:03 -04:00
josh d723f23d67 Add persistent AFK reserves and recovery (Refs #151)
CI / checks (push) Successful in 7m7s
2026-10-17 16:57:26 -04:00
josh 7f2205e9c4 Expand moderation case workflows
CI / checks (push) Successful in 6m49s
2026-10-17 16:19:33 -04:00
josh 4501eba873 Prevent cross-address login lockouts (Refs #63)
CI / checks (push) Successful in 6m30s
2026-10-17 10:55:28 -04:00
josh f3b9699317 Restrict plugin lookups and reuse cached chat reads (Refs #98) 2026-10-17 10:31:13 -04:00
josh b9bc897d6e Fix separator spacing in attention and provisioning summaries (Refs #112) 2026-10-17 10:20:37 -04:00
josh 2e6eb0bdb0 Use friendly UI labels and release feedback (Refs #120)
CI / checks (push) Successful in 6m39s
2026-10-17 09:54:01 -04:00
josh d93a766578 Fix count and status wording (Refs #121) 2026-10-17 09:48:42 -04:00
josh ba9bed6a0d Fix release version labels across deployment views 2026-10-17 09:45:53 -04:00
josh b5037a70fe Fix mobile overflow and touch targets
CI / checks (push) Successful in 6m41s
2026-10-16 17:48:06 -04:00
josh 4e04191330 Sign agent 1.11.0 release
CI / checks (push) Successful in 6m53s
2026-10-16 15:02:18 -04:00
josh 8b372fa2c1 Add player moderation workflows and game integration
CI / checks (push) Successful in 7m27s
2026-10-15 21:33:50 -04:00
josh c91d49c298 Add moderation permissions and durable player records 2026-10-15 21:06:08 -04:00
josh c1758cce25 Wait for round two before adding spare lobbies
CI / checks (push) Successful in 7m20s
2026-10-15 20:40:03 -04:00
josh 68616327ec Add all eligible files to beta setup
CI / checks (push) Successful in 5m59s
2026-10-14 19:47:31 -04:00
josh 1fc329b123 Hide promoted files from beta picker
CI / checks (push) Successful in 6m11s
2026-10-14 19:43:29 -04:00
josh 428df7f03a Exclude inactive hosts from capacity history
CI / checks (push) Successful in 6m13s
2026-10-14 19:31:26 -04:00
josh be7f6c77c1 Sign agent 1.10.1 release (Refs #155)
CI / checks (push) Successful in 6m13s
2026-10-14 12:38:02 -04:00
josh dedf15d9af Fix rebuild generation reconciliation (Refs #155) 2026-10-14 12:36:32 -04:00
josh 61dbfb2c5f Reuse verified artifacts during rebuilds (Refs #154) 2026-10-14 11:56:24 -04:00
josh 171f657876 Add host temperature telemetry and history 2026-10-13 23:39:04 -04:00
josh 09396ab584 Remove redundant account menu items
CI / checks (push) Successful in 6m31s
2026-10-13 16:47:37 -04:00
josh eceb38a6bc Remove standalone Activity log page (Refs #153) 2026-10-13 15:39:05 -04:00
josh 82e07ea33e Add optional 30-day remembered sessions
CI / checks (push) Successful in 6m29s
2026-10-13 14:42:53 -04:00
josh e85dc58130 Add Bank accounts, transactions, and pending transfer views
CI / checks (push) Successful in 7m50s
2026-10-13 11:50:26 -04:00
josh d6147077d7 Sign agent 1.9.3 release (Refs #152)
CI / checks (push) Successful in 22m32s
2026-10-12 23:33:47 -04:00
josh d9fa15cdf6 Protect private bank configuration from drift reconciliation (Refs #152) 2026-10-12 23:32:20 -04:00
josh 4e3df81786 Fix lifecycle simulation in script scope tests 2026-10-12 23:14:00 -04:00
josh 2932b74f55 Add signed agent 1.9.2 release manifest
CI / checks (push) Failing after 5m28s
2026-10-12 22:56:25 -04:00
josh 537b9841a7 Rebuild beta and campaign file activations 2026-10-12 22:52:58 -04:00
josh fa0baa8db5 Add signed agent release manifest
CI / checks (push) Successful in 5m33s
2026-10-12 22:25:10 -04:00
josh 54f4301994 Fix public bank endpoint rollout (Refs josh/tws-zombies-scripts#13) 2026-10-12 22:20:45 -04:00
josh 95160de5b4 Exempt admins and super viewers from auth rate limits
CI / checks (push) Successful in 5m40s
2026-10-12 22:01:03 -04:00
josh 0a98ea319c Sign packaged agent release
CI / checks (push) Successful in 5m30s
2026-10-12 20:50:26 -04:00
josh d14c6a7ff4 Provision instance bank credentials automatically (Refs #146) 2026-10-12 20:44:01 -04:00
josh e071e5b574 Add persistent cross-game player bank (Refs #146) 2026-10-12 20:21:58 -04:00
josh bcd3b99ee4 Fix variant-aware fleet card map labels (Refs #150)
CI / checks (push) Successful in 5m47s
2026-10-12 19:55:26 -04:00
josh 106d3a5ded Fix library page overflow on mobile. Refs #143
CI / checks (push) Successful in 5m30s
2026-10-12 19:21:22 -04:00
josh 1b908f891f Require administrator-requested host credential rotation (Refs #97)
CI / checks (push) Successful in 5m24s
2026-10-12 19:13:29 -04:00
josh 272055a1ed Cover beta health continuity and readiness boundaries (Refs #149)
CI / checks (push) Successful in 5m29s
2026-10-12 19:09:27 -04:00
josh f91ec4d152 Preserve health during empty-server monitoring handoff (Refs #148) 2026-10-12 19:02:54 -04:00
josh 2f52dcb1ef Require release versions for script uploads
CI / checks (push) Successful in 5m26s
2026-10-12 18:46:57 -04:00
josh ffda04f276 Show recorded match score totals with legacy fallback (Refs #147)
CI / checks (push) Successful in 5m22s
2026-10-12 16:13:48 -04:00
josh 81fa35b792 Show only the status badge in provisioning rows
CI / checks (push) Successful in 5m15s
2026-10-12 16:05:33 -04:00
josh c296769b33 Align provisioning UI with lifecycle states
CI / checks (push) Successful in 5m37s
2026-10-12 15:52:34 -04:00
josh 6059ebf2a5 Store cumulative player scores alongside balances
CI / checks (push) Successful in 6m27s
2026-10-12 13:57:34 -04:00
josh 76325b99ad Document first-report ordering limitation (Refs #144)
CI / checks (push) Successful in 5m33s
2026-10-12 10:28:44 -04:00
josh 785cc4616b Fix keyed telemetry match attribution (Refs #144)
CI / checks (push) Successful in 5m26s
2026-10-12 00:58:05 -04:00
josh 4ea291d305 Add Super Viewer read-only administrator role
CI / checks (push) Successful in 5m40s
2026-10-11 23:30:40 -04:00
josh 253d47300c Fix word wrapping in narrow table columns (Refs #126)
CI / checks (push) Successful in 5m1s
2026-10-10 19:49:38 -04:00
josh d2b2958700 Standardize responsive breakpoints (Refs #130)
CI / checks (push) Successful in 5m20s
2026-10-10 19:44:55 -04:00
josh 7442c6fd15 Flatten compact fleet sections (Refs #131)
CI / checks (push) Successful in 5m11s
2026-10-10 19:36:16 -04:00
josh 03e2d08a38 Fix stale match liveness and stop handling (Refs #136)
CI / checks (push) Successful in 5m15s
2026-10-10 19:30:51 -04:00
josh e3e740cea3 Add unique and new player metrics (Refs #142)
CI / checks (push) Successful in 4m53s
2026-10-10 18:47:56 -04:00
josh 7fcf1cded2 Run migration memory tests after the main suite (Refs #102)
CI / checks (push) Successful in 4m57s
2026-10-10 18:15:55 -04:00
josh d6041e40ef Show configured metrics retention (Refs #141) 2026-10-10 17:57:02 -04:00
josh 528ed7f37a Add autoscaling outcomes to Metrics (Refs #140) 2026-10-10 17:51:31 -04:00
josh 0bfda267ec Replace unused server metrics with a count (Refs #139)
CI / checks (push) Successful in 4m51s
2026-10-10 16:55:28 -04:00
josh aebfc7a783 Replace instance metrics with map lobby counts (Refs #139)
CI / checks (push) Successful in 4m55s
2026-10-10 16:40:07 -04:00
josh 616b1937ed Show match demand in metrics (Refs #138)
CI / checks (push) Successful in 4m44s
2026-10-10 16:26:32 -04:00
josh ea932ec563 Preserve shortage signals during global pause (Refs #137)
CI / checks (push) Successful in 4m47s
2026-10-10 16:11:24 -04:00
josh 33da4482a6 Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s
2026-10-10 14:48:52 -04:00
josh bd34c3c9e7 Show profiles for players without qualifying matches
CI / checks (push) Successful in 4m56s
2026-10-10 13:50:53 -04:00
josh cc9c10226e Use shared form controls and enforce UI patterns (Refs #110)
CI / checks (push) Successful in 4m51s
2026-10-10 00:19:48 -04:00
josh 8d1bb69591 Use ordered SQLite migrations (Refs #75)
CI / checks (push) Successful in 5m6s
2026-10-10 00:01:18 -04:00
josh a0663e43b8 Show live match links and elapsed time (Refs #115)
CI / checks (push) Successful in 5m0s
2026-10-09 23:47:46 -04:00
josh 3e0b4db736 Collapse provisioning filters on phones (Refs #127)
CI / checks (push) Successful in 5m55s
2026-10-09 23:42:06 -04:00
josh 44e755a166 Publish signed agent release (Refs #64)
CI / checks (push) Successful in 4m51s
2026-10-09 23:08:12 -04:00
josh a93ddb5ef9 Refresh agent signing keys from repository (Refs #64) 2026-10-09 21:39:03 -04:00
josh 6f71822d41 Require signed agent releases (Refs #64) 2026-10-09 20:36:18 -04:00
515 changed files with 49420 additions and 5211 deletions
+4
View File
@@ -10,3 +10,7 @@ archive
*.log *.log
test-fixtures test-fixtures
data data
# Offline release signing material must never enter the build context.
**/.gnupg
**/private-keys-v1.d
**/secring.gpg
+18
View File
@@ -1,5 +1,14 @@
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts. # Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
EC_PUBLIC_URL=https://electric-cherry.example.com EC_PUBLIC_URL=https://electric-cherry.example.com
# Client addresses for login throttling behind a trusted reverse proxy.
# Leave ADDRESS_HEADER empty for direct connections. Enable only when all access
# goes through a proxy that replaces untrusted forwarding headers.
# For one trusted proxy using X-Forwarded-For:
# ADDRESS_HEADER=x-forwarded-for
# XFF_DEPTH=1
# Set XFF_DEPTH to the number of trusted proxy hops; see the access guide.
ADDRESS_HEADER=
XFF_DEPTH=1
# Generate separate secrets with: openssl rand -hex 32 # Generate separate secrets with: openssl rand -hex 32
# Setup token authorizes creation of the first administrator only. Remove it # Setup token authorizes creation of the first administrator only. Remove it
# after setup. Never reuse the telemetry token as a setup or host credential. # after setup. Never reuse the telemetry token as a setup or host credential.
@@ -7,6 +16,15 @@ EC_SETUP_TOKEN=
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token. # Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
# Telemetry ingestion is disabled until a token of at least 32 chars is set. # Telemetry ingestion is disabled until a token of at least 32 chars is set.
EC_TELEMETRY_TOKEN= EC_TELEMETRY_TOKEN=
# Optional native Discord companion. Keep these in the controller environment;
# enable the integration and optionally exclude servers under Admin → Discord.
# The application ID and public key are in the Discord Developer Portal.
# The bot token is a secret: never put it in channel messages or screenshots.
EC_DISCORD_APPLICATION_ID=
EC_DISCORD_PUBLIC_KEY=
EC_DISCORD_BOT_TOKEN=
# Bank credentials are generated and injected automatically per native instance.
EC_BANK_MAX_BALANCE=250000
# Persistent SQLite metadata, game-file storage, and beta archives. # Persistent SQLite metadata, game-file storage, and beta archives.
# For an existing deployment, retain its original data directory. # For an existing deployment, retain its original data directory.
EC_DATA_DIR=/root/zombies/.electric-cherry EC_DATA_DIR=/root/zombies/.electric-cherry
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Install Python and native build tools - name: Install Python and native build tools
run: | run: |
apt-get update apt-get update
apt-get install -y --no-install-recommends python3 make g++ apt-get install -y --no-install-recommends python3 make g++ gpg gpg-agent gpgv
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+2 -1
View File
@@ -13,9 +13,10 @@ _Fleet overview with sample data._
- Launch individual servers or saved collections of maps and lobbies. - Launch individual servers or saved collections of maps and lobbies.
- Monitor players, host capacity, match history, and leaderboards. - 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. - Manage scripts, plugins, and configuration with beta testing and rollback.
- Queue maintenance around active games and scale spare lobbies with opt-in policies. - 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 ## Getting started
+106
View File
@@ -0,0 +1,106 @@
# Agent changelog
## 2026-10-17
### 1.12.0 — Recover AFK reservations before teardown (EC #151)
- Advertise `afk.settlement` and drain durable AFK decisions only after confirming
the game process has stopped. Removal, rebuild and creation cancellation retain
runtime storage until the controller acknowledges every recovery decision.
- Authenticate recovery with the assigned host and unfinished teardown operation;
never follow a journal URL or use a retired game credential. Keep uncertain
crash durations distinct from confirmed consumption.
- Deploy before enabling the matching AFK scripts/plugins. Package and offline-sign
the release before publication; no deployment or signing is performed here.
## 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)
- Retain the complete verified replacement file set before stopping the current
container, including inline files and private bank configuration, within the
existing 64 MiB operation limit. Staged-file comparison, replacement validation
and writing reuse those bytes without further artifact downloads.
- Keep prepared bytes local to the operation. Failed and held attempts discard
their files; later operations prepare a fresh set. Immutable specifications,
journal requests and container identity labels remain unchanged.
- Preserve checksum, path, per-file size, player-safety, bank-settlement and
generation checks. Add regressions for preflight failures, lost connectivity
after preflight, file-set limits and operation isolation.
- Package release artifacts for offline signing before publication. No protocol
change or controller migration is required.
## 2026-10-13
### 1.10.0 — Host temperature telemetry
- Read supported Linux hwmon CPU package/core, AMD physical die/CCD, NVMe and
Intel PCH temperatures with the Python standard library. Sensor failures,
faults and disabled inputs are skipped independently; unsupported hosts report
an empty list. AMD Tctl control offsets are excluded from physical readings.
- Include labeled, bounded sensors in heartbeats with the `host.temperatures`
capability. Physical device/channel identities survive hwmon renumbering.
- The controller shows current CPU readings and expandable sensor details, and
adds temperature history to Capacity. Fleet history has one CPU line per host;
individual hosts can add NVMe composite and chipset lines. History retains
minute peaks using the host-resource retention setting; missing data stays
unavailable and no thermal alerts or placement rules are introduced.
- Adds database migration 23. Existing agents remain compatible. Release
artifacts are packaged and the stale signature is removed. Offline signing
and host rollout are still required; neither was performed here.
## 2026-10-12
### 1.9.3 — Protect private bank configuration (EC #152)
- Exclude the generated bank config from file manifests and reject file reads,
staged changes, stale activations, rollback snapshots and log reads targeting
it. Creation and rebuilding still inject the private instance credential.
- Package and offline-sign this release, then update agents before resuming
drift recovery. Old private-file stages fail safely and require a fresh plan.
### 1.9.1 — Public game-bank endpoint
- Companion to [TWS scripts #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13).
In `agent/ec_agent.py`, stop generating `tws_bank_url` in
`electric_cherry_bank.cfg`; continue injecting the private per-instance token.
Launch configuration still supplies `tws_server_id`. The host's enrolled
controller address and authentication remain unchanged.
- Recognize journals for `https://ec.thewrightserver.net/api/bank` and the legacy
bank URL derived from the enrolled controller. Reject other URLs and preserve
unresolved storage. Settlement still goes only through the enrolled controller's
authenticated, operation- and instance-scoped API. Original transaction IDs and
commit/cancel decisions are preserved across lost replies and retries.
- Compatibility: pair with T5/T6 bank scripts `2.0.0` and existing TWS plugins
`1.1.0+`. No new Python dependency, controller migration or protocol change.
Old scripts still fall back to the public endpoint when the dvar is absent.
- Deployment: package and offline-sign the release, update/restart host agents,
then rebuild through EC when a server's bank endpoint changes. Rebuild stops
the game and drains old decisions before starting the replacement or deleting
storage. An endpoint that already matches needs only a map restart for the
script update. Follow the [bank rollout procedure](../docs/guides/bank.md#upgrading-from-a-controller-derived-game-endpoint).
Packaging refreshes `static/agent/ec-agent.py` and `manifest.json` and removes
the old signature; a new offline signature is required before publishing.
No signing or deployment was performed.
- Regression coverage in `agent/tests/test_agent.py`: generated credentials,
public and legacy journals for both games, rebuild/removal, lost replies,
agent restart, unchanged identities and rejection of untrusted endpoints.
Live Windows/Plutonium endpoint migration and signed host rollout remain to
be validated.
+468 -33
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.8.7" VERSION = "1.12.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -48,6 +48,10 @@ PLUTONIUM_MANIFEST_URL = "https://cdn.plutoniummod.com/updater/prod/info.json"
PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024 PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024 PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024 PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
AFK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/afk"
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}$") ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -62,6 +66,7 @@ UPDATE_TIMER_ENABLED = Path("/etc/systemd/system/timers.target.wants/electric-ch
UPDATE_JOURNAL = Path("/etc/electric-cherry/update/journal.json") UPDATE_JOURNAL = Path("/etc/electric-cherry/update/journal.json")
UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json") UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json") AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
HWMON_ROOT = Path("/sys/class/hwmon")
ROOT_MARKER = ".electric-cherry.json" ROOT_MARKER = ".electric-cherry.json"
@@ -83,6 +88,7 @@ class AutoscalingInvalidated(Rejected):
OPERATION_ERROR_PHASES = { OPERATION_ERROR_PHASES = {
"executing": "executing the operation", "executing": "executing the operation",
"configuring-moderation": "configuring private game moderation",
"checking-instance": "checking the existing instance", "checking-instance": "checking the existing instance",
"validating-create-spec": "validating the container specification", "validating-create-spec": "validating the container specification",
"preparing-create-directory": "preparing the instance directory", "preparing-create-directory": "preparing the instance directory",
@@ -399,7 +405,17 @@ def managed_open(root, value):
os.close(fd) os.close(fd)
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_instance_config(value):
raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations")
def managed_read(root, value): def managed_read(root, value):
require_public_file(value)
try: try:
with managed_open(root, value) as stream: with managed_open(root, value) as stream:
if os.fstat(stream.fileno()).st_size > MAX_FILE: if os.fstat(stream.fileno()).st_size > MAX_FILE:
@@ -445,6 +461,8 @@ def managed_manifest(root, payload, stopping):
def validate_paths(): def validate_paths():
for value in paths: for value in paths:
if private_instance_config(value):
continue
check_budget() check_budget()
try: try:
with managed_open(root, value): with managed_open(root, value):
@@ -479,7 +497,7 @@ def managed_manifest(root, payload, stopping):
entries += 1 entries += 1
if entries > MANIFEST_MAX_ENTRIES: if entries > MANIFEST_MAX_ENTRIES:
raise AgentError("Manifest scan exceeded its entry limit") raise AgentError("Manifest scan exceeded its entry limit")
if child.name in ignored: if child.name in ignored or private_instance_config(relative + "/" + child.name):
continue continue
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False) info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode): if stat_module.S_ISLNK(info.st_mode):
@@ -1036,6 +1054,86 @@ class LibraryTransfer:
self.persist(force=True) self.persist(force=True)
def afk_recovery_requests(journal):
"""Mirror the native journal's deterministic recovery after a proven stop.
Active crashes never invent an elapsed duration. Their checkpoint is only
a lower bound; EC retains the hold for an audited correction. Requests that
may already have reached EC preserve their original identities and bodies.
"""
def identifier(value):
return isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", value)
def seconds(value):
return type(value) is int and 0 <= value <= 604800
def decode(raw):
if not isinstance(raw, str) or not 0 < len(raw.encode()) < 0x5000:
raise ValueError()
value = json.loads(raw)
if (not isinstance(value, dict) or value.get("server") != journal["server"]
or not all(identifier(value.get(key)) for key in ("guid", "match", "request_id"))
or value.get("action") not in ("event", "purchase", "reserve", "start", "settle")):
raise ValueError()
if value["action"] in ("reserve", "start", "settle") and not identifier(value.get("session_id")):
raise ValueError()
if value["action"] == "reserve" and (not seconds(value.get("seconds")) or value["seconds"] == 0):
raise ValueError()
return value
try:
if (not isinstance(journal, dict) or type(journal.get("version")) is not int
or journal["version"] != 1 or not isinstance(journal.get("server"), str)
or not 0 < len(journal["server"]) <= 128
or journal.get("endpoint") != AFK_GAME_ENDPOINT
or not isinstance(journal.get("requests"), list)
or not isinstance(journal.get("sessions"), list)
or len(journal["requests"]) + len(journal["sessions"]) > 4096):
raise ValueError()
requests = {}
for raw in journal["requests"]:
value = decode(raw)
if value["request_id"] in requests:
raise ValueError()
requests[value["request_id"]] = value
sessions = set()
for session in journal["sessions"]:
data = decode(session["body"])
session_id = data.get("session_id")
phase = session["phase"]
authorized, consumed = session["authorized_seconds"], session["consumed_seconds"]
if (data["action"] != "reserve" or session_id in sessions
or phase not in ("prepared", "authorized", "active")
or not seconds(authorized) or not seconds(consumed)
or consumed > authorized or authorized > data["seconds"]
or (phase == "prepared" and authorized != 0)
or (phase != "prepared" and authorized == 0)
or (phase != "active" and consumed != 0)):
raise ValueError()
sessions.add(session_id)
requests = {key: value for key, value in requests.items()
if not (value["action"] in ("reserve", "start") and value["session_id"] == session_id)}
hashed = 14695981039346656037
scope = journal["server"] + "\0" + journal["endpoint"] + "\0" + session_id
for byte in scope.encode():
hashed = ((hashed ^ byte) * 1099511628211) & 0xffffffffffffffff
transfer = {"server": journal["server"], "guid": data["guid"], "match": data["match"],
"session_id": session_id, "request_id": f"afk-stop-{hashed:016x}",
"action": "settle", "end_reason": "crash" if phase == "active" else "never_started",
"consumed_seconds": None if phase == "active" else 0}
if phase == "active":
transfer["confirmed_seconds"] = consumed
previous = requests.get(transfer["request_id"])
if previous is not None and previous != transfer:
raise ValueError()
requests[transfer["request_id"]] = transfer
if any(value["action"] in ("reserve", "start") for value in requests.values()):
raise ValueError()
return list(requests.values())
except (ValueError, TypeError, KeyError, AttributeError):
raise AgentError("AFK journal is invalid; runtime storage retained") from None
class Agent: class Agent:
def __init__(self, config, docker=None): def __init__(self, config, docker=None):
self.config = config self.config = config
@@ -1295,6 +1393,59 @@ class Agent:
return None return None
return round(100 * (total - deltas[3] - deltas[4]) / total, 1) return round(100 * (total - deltas[3] - deltas[4]) / total, 1)
def temperatures(self):
"""Best-effort, read-only samples from known millidegree-Celsius drivers."""
samples = []
try:
chips = sorted(HWMON_ROOT.glob("hwmon*"))[:64]
except OSError:
return samples
for chip in chips:
try:
device = (chip / "name").read_text().strip()
if device not in ("coretemp", "k10temp", "nvme") and not device.startswith("pch_"):
continue
# hwmon numbers can change on reboot. The physical sysfs path
# and channel identify separate CPU sockets and NVMe devices.
identity = re.sub(r"/(?:hwmon/)?hwmon\d+$", "", str(chip.resolve()))
inputs = sorted(chip.glob("temp[0-9]*_input"))[:128]
except (OSError, ValueError):
continue
for source in inputs:
try:
channel = source.name.removesuffix("_input")
label_path = chip / (channel + "_label")
label = label_path.read_text().strip() if label_path.exists() else channel
if not label or len(label) > 120:
continue
fault = chip / (channel + "_fault")
enabled = chip / (channel + "_enable")
if (fault.exists() and int(fault.read_text()) != 0) or (enabled.exists() and int(enabled.read_text()) == 0):
continue
if device == "coretemp":
kind = "cpu-package" if label.startswith("Package id ") else "cpu-core" if label.startswith("Core ") else None
elif device == "k10temp":
# Tctl can contain a fan-control offset. Never present
# it as the physical die temperature or a fleet metric.
kind = "cpu-die" if label == "Tdie" or re.fullmatch(r"Tccd\d+", label) else None
else:
kind = "nvme" if device == "nvme" else "chipset"
if kind is None:
continue
celsius = int(source.read_text()) / 1000
if not -100 <= celsius <= 200:
continue
sensor_id = hashlib.sha256((identity + "/" + channel).encode()).hexdigest()[:24]
samples.append({"id": sensor_id, "device": device, "label": label,
"kind": kind, "celsius": round(celsius, 1)})
if len(samples) >= 128:
return samples
except (OSError, ValueError):
# Read permission alone does not imply data is available
# (e.g. ENODATA), and a device can disappear during a read.
continue
return samples
def instance_resources(self, observations=None): def instance_resources(self, observations=None):
"""One bounded Docker stats batch; unavailable samples are never zero load.""" """One bounded Docker stats batch; unavailable samples are never zero load."""
ids = self.docker.call("container", "ls", "--all", "--filter", "label=" + LABEL + ".managed=true", ids = self.docker.call("container", "ls", "--all", "--filter", "label=" + LABEL + ".managed=true",
@@ -1379,6 +1530,7 @@ class Agent:
def inventory(self): def inventory(self):
cpu_usage = self.cpu_usage_percent() cpu_usage = self.cpu_usage_percent()
temperatures = self.temperatures()
usage = shutil.disk_usage(self.root) usage = shutil.disk_usage(self.root)
memory = {} memory = {}
try: try:
@@ -1503,9 +1655,9 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset"] + (["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", "afk.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
plutonium_cache = self.plutonium_cache_inventory() 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, **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})} 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})}
def safe_to_disrupt(self, instance_id, generation, *, allow_unhealthy=False): def safe_to_disrupt(self, instance_id, generation, *, allow_unhealthy=False):
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
@@ -1617,7 +1769,7 @@ class Agent:
players = status_players(text) if command == "status" else None players = status_players(text) if command == "status" else None
return {"ok": True, "text": text, "players": players} return {"ok": True, "text": text, "players": players}
def validate_spec(self, payload): def validate_spec(self, payload, *, prepared_files=None):
game = payload.get("game") game = payload.get("game")
if game not in ("t5", "t6"): if game not in ("t5", "t6"):
raise AgentError("Only T5 and T6 are supported") raise AgentError("Only T5 and T6 are supported")
@@ -1657,19 +1809,25 @@ class Agent:
# record. Whenever preparation was recorded, enforce that exact image. # record. Whenever preparation was recorded, enforce that exact image.
if image in prepared and prepared[image] != image_id: if image in prepared and prepared[image] != image_id:
raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.") raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.")
files = payload.get("files", []) files = self.initial_files(payload)
if not isinstance(files, list) or len(files) > 10000: if not isinstance(files, list) or len(files) > 10000:
raise Rejected("Invalid initial file list") raise Rejected("Invalid initial file list")
if prepared_files is not None:
if set(prepared_files) != {item["path"] for item in files} or any(not isinstance(content, bytes) for content in prepared_files.values()):
raise Rejected("Prepared files do not match the replacement specification")
verified_files = {}
total, paths = 0, set() total, paths = 0, set()
for item in files: for item in files:
scoped(self.root / "agent" / "validation", item["path"]) scoped(self.root / "agent" / "validation", item["path"])
if item["path"] in paths: if item["path"] in paths:
raise Rejected("Duplicate initial file path") raise Rejected("Duplicate initial file path")
paths.add(item["path"]) paths.add(item["path"])
total += len(self.decode_file(item)) content = self.decode_file(item, content=None if prepared_files is None else prepared_files[item["path"]])
total += len(content)
if total > 64 * 1024 * 1024: if total > 64 * 1024 * 1024:
raise Rejected("Initial files exceed the 64 MiB operation limit") raise Rejected("Initial files exceed the 64 MiB operation limit")
return game, library, image, image_id, port, bind, env verified_files[item["path"]] = content
return game, library, image, image_id, port, bind, env, verified_files
@staticmethod @staticmethod
def plutonium_manifest(data): def plutonium_manifest(data):
@@ -1888,8 +2046,10 @@ class Agent:
managed_write(app, marker, raw, uid, gid) managed_write(app, marker, raw, uid, gid)
return True return True
def create(self, instance_id, generation, payload): def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
self.operation_phase = "checking-instance" self.operation_phase = "checking-instance"
if payload.get("moderation") is not None:
self.moderation_config(payload["moderation"], generation)
self.check_creation_cancellation() self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
current_marker = directory.parent / "current.json" current_marker = directory.parent / "current.json"
@@ -1903,7 +2063,7 @@ class Agent:
raise AgentError("Instance already exists with a different specification") raise AgentError("Instance already exists with a different specification")
return existing return existing
self.operation_phase = "validating-create-spec" self.operation_phase = "validating-create-spec"
game, library, image, image_id, port, bind, env = self.validate_spec(payload) game, library, image, image_id, port, bind, env, prepared_files = self.validate_spec(payload, prepared_files=prepared_files)
app = directory / "app" app = directory / "app"
self.operation_phase = "preparing-create-directory" self.operation_phase = "preparing-create-directory"
app.mkdir(parents=True, exist_ok=True) app.mkdir(parents=True, exist_ok=True)
@@ -1911,7 +2071,7 @@ class Agent:
self.operation_phase = "seeding-runtime" self.operation_phase = "seeding-runtime"
self.seed_plutonium(app, image) self.seed_plutonium(app, image)
self.operation_phase = "writing-create-files" self.operation_phase = "writing-create-files"
self.write_initial_files(app, payload.get("files", [])) self.write_initial_files(app, prepared_files)
self.operation_phase = "persisting-create-metadata" self.operation_phase = "persisting-create-metadata"
atomic_json(directory / "spec.json", payload) atomic_json(directory / "spec.json", payload)
env_path = directory / "environment" env_path = directory / "environment"
@@ -1929,7 +2089,7 @@ class Agent:
args.append(image_id) args.append(image_id)
self.check_creation_cancellation() self.check_creation_cancellation()
self.operation_phase = "creating-container" self.operation_phase = "creating-container"
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha}) self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha, **({"before": before} if before is not None else {})})
self.docker.call(*args, timeout=120) self.docker.call(*args, timeout=120)
self.check_creation_cancellation() self.check_creation_cancellation()
self.operation_phase = "starting-container" self.operation_phase = "starting-container"
@@ -1944,30 +2104,126 @@ class Agent:
if self.executing_id and self.executing_id in self.cancelled_creations: if self.executing_id and self.executing_id in self.cancelled_creations:
raise AgentError("Creation cancelled; waiting for instance cleanup") raise AgentError("Creation cancelled; waiting for instance cleanup")
def write_initial_files(self, root, files): def initial_files(self, payload):
if not isinstance(files, list) or len(files) > 10000: files = payload.get("files", [])
raise AgentError("Invalid file list") if not isinstance(files, list):
raise Rejected("Invalid initial file list")
for item in files: for item in files:
content = self.decode_file(item) require_public_file(item["path"])
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))) bank = payload.get("bank")
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
def decode_file(self, item): @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():
managed_write(root, path, content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
def decode_file(self, item, *, content=None):
if "contentSha256" in item: if "contentSha256" in item:
sha = item["contentSha256"] sha = item["contentSha256"]
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha): if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
raise AgentError("Invalid file content hash") raise AgentError("Invalid file content hash")
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha if content is None:
with self.artifact_open("GET", url) as response: url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
content = response.read(MAX_FILE + 1) with self.artifact_open("GET", url) as response:
content = response.read(MAX_FILE + 1)
if len(content) > MAX_FILE: if len(content) > MAX_FILE:
raise AgentError("File exceeds staging size limit") raise AgentError("File exceeds staging size limit")
if digest(content) != sha or item.get("sha256", sha) != sha: if digest(content) != sha or item.get("sha256", sha) != sha:
raise AgentError("Staged file checksum mismatch") raise AgentError("Staged file checksum mismatch")
return content return content
try: try:
content = base64.b64decode(item["contentBase64"], validate=True) decoded = base64.b64decode(item["contentBase64"], validate=True)
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
raise AgentError("Invalid encoded file content") from None raise AgentError("Invalid encoded file content") from None
if content is None:
content = decoded
elif content != decoded:
raise Rejected("Prepared files do not match the replacement specification")
if len(content) > MAX_FILE: if len(content) > MAX_FILE:
raise AgentError("File exceeds staging size limit") raise AgentError("File exceeds staging size limit")
if item.get("sha256") and item["sha256"] != digest(content): if item.get("sha256") and item["sha256"] != digest(content):
@@ -1992,6 +2248,123 @@ class Agent:
if (time.monotonic() - started) * 1000 >= ttl: if (time.monotonic() - started) * 1000 >= ttl:
raise AutoscalingInvalidated("Autoscaling removal authorization expired before mutation") raise AutoscalingInvalidated("Autoscaling removal authorization expired before mutation")
def settle_bank(self, instance_id, generation, operation_id=None):
"""Drain stopped runtime journals before discarding any generation's storage.
Use only our configured controller and host credential. Never follow an
endpoint or symlink supplied by a game journal. Lost replies are retried
with the original request identity; files remain until teardown succeeds.
"""
app = scoped(self.instance_dir(instance_id, generation), "app")
if not app.exists():
return
if self.inspect(instance_id, generation)["running"]:
raise AgentError("Bank settlement requires a stopped instance")
operation_id = operation_id or self.executing_id
visited = 0
deadline = time.monotonic() + 30
journals = []
def unreadable(_error):
raise AgentError("Bank journal discovery failed; runtime storage retained")
for directory, directories, files in os.walk(app, followlinks=False, onerror=unreadable):
relative = Path(directory).relative_to(app)
inside_bank = "tws-bank" in relative.parts
visited += 1
if visited > 100000 or time.monotonic() > deadline:
raise AgentError("Bank journal discovery limit exceeded; runtime storage retained")
for name in directories + files:
if (inside_bank or name == "tws-bank") and (Path(directory) / name).is_symlink():
raise AgentError("Bank journal paths must not be symlinks")
if not inside_bank:
continue
for name in files:
if name.endswith(".json"):
journals.append((relative / name).as_posix())
if len(journals) > 4096:
raise AgentError("Too many bank journals; runtime storage retained")
for path in sorted(journals):
try:
raw = managed_read(app, path)
except Rejected:
raise AgentError("Bank journal cannot be read; runtime storage retained") from None
if raw is None or len(raw) > 16 * 1024 * 1024:
raise AgentError("Bank journal is unavailable or oversized")
try:
journal = json.loads(raw)
reports = journal["reports"]
# Accept the fixed game endpoint and this host's legacy generated
# endpoint so rebuild/removal can drain pre-upgrade decisions.
# Neither value controls transport: settlement still uses the
# enrolled controller, host credential and immutable operation.
endpoints = {BANK_GAME_ENDPOINT, self.config["controller"].rstrip("/") + "/api/bank"}
if journal["endpoint"].rstrip("/") not in endpoints:
raise AgentError("Bank journal has an unrecognized endpoint; runtime storage retained")
if type(journal["version"]) is not int or journal["version"] != 1 or not isinstance(reports, list) or len(reports) > 4096:
raise ValueError()
# Controller independently binds this operation to the instance/server.
for report in reports:
if not isinstance(report, str) or len(report.encode()) > 20000:
raise ValueError()
transfer = json.loads(report)
action = transfer["decision"]
if action not in ("commit", "cancel") or transfer["server"] != journal["server"] or not operation_id:
raise ValueError()
response = self.request("POST", "/api/agent/bank/settle", {
"operationId": operation_id, "action": action, "transfer": transfer})
expected = ("committed",) if action == "commit" else ("cancelled", "rejected")
if not isinstance(response, dict) or response.get("request_id") != transfer["request_id"] or response.get("state") not in expected:
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
except (ValueError, TypeError, KeyError, AttributeError):
raise AgentError("Bank journal is invalid; runtime storage retained") from None
self.settle_afk(instance_id, generation, operation_id)
def settle_afk(self, instance_id, generation, operation_id=None):
"""Retain AFK evidence until stopped-process reconciliation is acknowledged."""
app = scoped(self.instance_dir(instance_id, generation), "app")
if not app.exists():
return
if self.inspect(instance_id, generation)["running"]:
raise AgentError("AFK settlement requires a stopped instance")
operation_id = operation_id or self.executing_id
visited, journals = 0, []
deadline = time.monotonic() + 30
def unreadable(_error):
raise AgentError("AFK journal discovery failed; runtime storage retained")
for directory, directories, files in os.walk(app, followlinks=False, onerror=unreadable):
relative = Path(directory).relative_to(app)
inside = "tws-afk" in relative.parts
visited += 1
if visited > 100000 or time.monotonic() > deadline:
raise AgentError("AFK journal discovery limit exceeded; runtime storage retained")
for name in directories + files:
if (inside or name == "tws-afk") and (Path(directory) / name).is_symlink():
raise AgentError("AFK journal paths must not be symlinks")
if inside:
journals.extend((relative / name).as_posix() for name in files if name.endswith(".json"))
if len(journals) > 4096:
raise AgentError("Too many AFK journals; runtime storage retained")
for path in sorted(journals):
try:
raw = managed_read(app, path)
if raw is None or len(raw) > 16 * 1024 * 1024:
raise ValueError()
transfers = afk_recovery_requests(json.loads(raw))
except (Rejected, ValueError, UnicodeError):
raise AgentError("AFK journal cannot be read; runtime storage retained") from None
if transfers and not operation_id:
raise AgentError("AFK settlement requires a teardown operation")
for transfer in transfers:
response = self.request("POST", "/api/agent/afk/settle", {
"operationId": operation_id, "action": transfer["action"], "transfer": transfer})
expected = ("settled", "unresolved", "corrected") if transfer["action"] == "settle" else ("rewarded", "purchased", "rejected")
if (not isinstance(response, dict) or response.get("request_id") != transfer["request_id"]
or response.get("state") not in expected):
raise AgentError("AFK recovery was not acknowledged; runtime storage retained")
def lifecycle(self, kind, instance_id, generation, payload): def lifecycle(self, kind, instance_id, generation, payload):
unhealthy_recovery = payload.get("unhealthyRecovery") is True unhealthy_recovery = payload.get("unhealthyRecovery") is True
if unhealthy_recovery and (kind not in ("instance.restart", "instance.rebuild") or payload.get("stageId") or "autoscaling" in payload): if unhealthy_recovery and (kind not in ("instance.restart", "instance.rebuild") or payload.get("stageId") or "autoscaling" in payload):
@@ -2002,6 +2375,8 @@ class Agent:
return self.create(instance_id, generation, payload) return self.create(instance_id, generation, payload)
if kind == "instance.inspect": if kind == "instance.inspect":
return self.inspect(instance_id, generation) 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) health = self.inspect(instance_id, generation)
name = self.container_name(instance_id, generation) name = self.container_name(instance_id, generation)
before = [] before = []
@@ -2031,25 +2406,35 @@ class Agent:
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation: if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
raise AgentError("Rebuild must use a newer immutable 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")} spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
self.validate_spec(spec) 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.
*_, prepared_files = self.validate_spec(spec)
if not override_safety: if not override_safety:
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery) self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
if "autoscaling" in payload: if "autoscaling" in payload:
self.authorize_autoscaling_removal(instance_id, generation, payload) self.authorize_autoscaling_removal(instance_id, generation, payload)
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")}) if kind == "instance.rebuild" and payload.get("stageId"):
before = self.rebuild_before(instance_id, generation, payload["stageId"], prepared_files)
if not override_safety:
self.safe_to_disrupt(instance_id, generation)
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt"), **({"before": before} if kind == "instance.rebuild" and payload.get("stageId") else {})})
if payload.get("stageId") and kind == "instance.restart": if payload.get("stageId") and kind == "instance.restart":
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety) before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
if kind in ("instance.remove", "instance.rebuild"): if kind in ("instance.remove", "instance.rebuild"):
if health["exists"]: if health["exists"]:
self.docker.call("container", "stop", "--time", "30", name) self.docker.call("container", "stop", "--time", "30", name)
self.docker.call("container", "rm", name) self.docker.call("container", "rm", name)
self.settle_bank(instance_id, generation)
if kind == "instance.rebuild": if kind == "instance.rebuild":
result = self.create(instance_id, next_generation, spec) result = self.create(instance_id, next_generation, spec, before=before if payload.get("stageId") else None, prepared_files=prepared_files)
self.operation_phase = "removing-old-instance" self.operation_phase = "removing-old-instance"
old = self.instance_dir(instance_id, generation) old = self.instance_dir(instance_id, generation)
if old.exists(): if old.exists():
shutil.rmtree(old, ignore_errors=False) shutil.rmtree(old, ignore_errors=False)
return result return {**result, "before": before}
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
if directory.exists(): if directory.exists():
shutil.rmtree(directory) shutil.rmtree(directory)
@@ -2062,7 +2447,7 @@ class Agent:
raise AgentError("Unsupported instance operation") raise AgentError("Unsupported instance operation")
return {**self.inspect(instance_id, generation), "before": before} return {**self.inspect(instance_id, generation), "before": before}
def cancel_creation(self, instance_id, generation, payload): def cancel_creation(self, instance_id, generation, payload, operation_id=None):
"""Explicit launch cancellation, fenced to its immutable creation identity. """Explicit launch cancellation, fenced to its immutable creation identity.
The operation loop serializes this with create. Once delivered, removal The operation loop serializes this with create. Once delivered, removal
@@ -2106,6 +2491,7 @@ class Agent:
self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, target_generation)) self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, target_generation))
if self.inspect(instance_id, target_generation)["exists"]: if self.inspect(instance_id, target_generation)["exists"]:
raise AgentError("Cancelled creation container still exists") raise AgentError("Cancelled creation container still exists")
self.settle_bank(instance_id, target_generation, operation_id)
directory = self.instance_dir(instance_id, target_generation) directory = self.instance_dir(instance_id, target_generation)
if directory.exists(): if directory.exists():
shutil.rmtree(directory) shutil.rmtree(directory)
@@ -2123,6 +2509,7 @@ class Agent:
paths = set() paths = set()
total_size = 0 total_size = 0
for item in files: for item in files:
require_public_file(item["path"])
scoped(directory / "app", item["path"]) scoped(directory / "app", item["path"])
if item["path"] in paths: if item["path"] in paths:
raise AgentError("Duplicate staged file path") raise AgentError("Duplicate staged file path")
@@ -2143,11 +2530,36 @@ class Agent:
atomic_json(marker, manifest) atomic_json(marker, manifest)
return {"stageId": stage_id, "files": len(planned), "state": "staged"} return {"stageId": stage_id, "files": len(planned), "state": "staged"}
def rebuild_before(self, instance_id, generation, stage_id, prepared_files):
"""Validate requested replacement bytes and snapshot the old generation before teardown."""
directory = self.instance_dir(instance_id, generation)
marker = directory / "stages" / identifier(stage_id) / "manifest.json"
saved = json.loads(marker.read_text())
if saved["state"] != "staged":
raise Rejected("Rebuild requires an unactivated file stage")
before = []
for item in saved["files"]:
require_public_file(item["path"])
target = prepared_files.get(item["path"])
if item["delete"]:
if target is not None:
raise Rejected("Rebuild includes a file requested for removal")
elif target is None or digest(target) != item["sha256"]:
raise Rejected("Rebuild does not match staged file content")
content = managed_read(directory / "app", item["path"])
actual_sha = None if content is None else digest(content)
if item.get("checkBefore") and actual_sha != item.get("expectedBeforeSha"):
raise Rejected("Target changed after campaign review")
before.append({"id": item.get("id"), "path": item["path"], "contentBase64": None if content is None else base64.b64encode(content).decode()})
return before
def activate(self, instance_id, generation, stage_id, *, override_safety=False): def activate(self, instance_id, generation, stage_id, *, override_safety=False):
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
stage = directory / "stages" / identifier(stage_id) stage = directory / "stages" / identifier(stage_id)
marker = stage / "manifest.json" marker = stage / "manifest.json"
saved = json.loads(marker.read_text()) saved = json.loads(marker.read_text())
for item in saved["files"]:
require_public_file(item["path"])
if saved["state"] == "activated": if saved["state"] == "activated":
return saved["before"] return saved["before"]
if saved["state"] != "staged": if saved["state"] != "staged":
@@ -2178,6 +2590,8 @@ class Agent:
return before return before
def restore_files(self, app, before): def restore_files(self, app, before):
for item in before:
require_public_file(item["path"])
for item in before: for item in before:
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))) managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
@@ -2232,6 +2646,7 @@ class Agent:
def read_log(self, instance_id, generation, payload): def read_log(self, instance_id, generation, payload):
app = self.instance_dir(instance_id, generation) / "app" app = self.instance_dir(instance_id, generation) / "app"
value = payload["path"] value = payload["path"]
require_public_file(value)
count = min(2000, max(1, int(payload.get("lines", 200)))) count = min(2000, max(1, int(payload.get("lines", 200))))
# Validate the original relative spelling independently of symlinks. # Validate the original relative spelling independently of symlinks.
scoped(self.root / "agent" / "log-validation", value) scoped(self.root / "agent" / "log-validation", value)
@@ -2248,6 +2663,7 @@ class Agent:
raise AgentError("Log symlink resolves outside the managed instance") from None raise AgentError("Log symlink resolves outside the managed instance") from None
# Reopen the resolved relative target through no-follow directory FDs; # Reopen the resolved relative target through no-follow directory FDs;
# swapping either the original link or a target component cannot escape. # swapping either the original link or a target component cannot escape.
require_public_file(relative)
with managed_open(app, relative) as stream: with managed_open(app, relative) as stream:
stat = os.fstat(stream.fileno()) stat = os.fstat(stream.fileno())
if payload.get("mode") == "incremental": if payload.get("mode") == "incremental":
@@ -2747,6 +3163,7 @@ class Agent:
if not unchanged_generation() or not both_missing(): if not unchanged_generation() or not both_missing():
return row return row
self.settle_bank(instance_id, generation, operation["id"])
# Preparation can leave runtime/configuration files before create's # Preparation can leave runtime/configuration files before create's
# checkpoint. A fresh operation must not inherit those partial files. # checkpoint. A fresh operation must not inherit those partial files.
expected_spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")} expected_spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
@@ -2793,7 +3210,17 @@ class Agent:
if kind == "instance.remove" and "autoscaling" in payload and not checkpoint: if kind == "instance.remove" and "autoscaling" in payload and not checkpoint:
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision") return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
if kind == "instance.cancel-create": if kind == "instance.cancel-create":
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload)) 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": if kind == "instance.create":
marker = self.instance_dir(instance_id, generation).parent / "current.json" marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists(): if marker.exists():
@@ -2873,11 +3300,17 @@ class Agent:
health = self.inspect(instance_id, observed_generation) health = self.inspect(instance_id, observed_generation)
before = [] before = []
if payload.get("stageId"): if payload.get("stageId"):
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json" if kind == "instance.rebuild":
saved = json.loads(stage.read_text()) # The creating checkpoint retains snapshots after old storage is deleted.
if saved["state"] != "activated": if not isinstance(checkpoint.get("before"), list):
return row return row
before = saved["before"] before = checkpoint["before"]
else:
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
saved = json.loads(stage.read_text())
if saved["state"] != "activated":
return row
before = saved["before"]
proven = False proven = False
if kind in ("instance.create", "instance.rebuild"): if kind in ("instance.create", "instance.rebuild"):
proven = health["running"] and health.get("creationOperation") == operation["id"] and health.get("specSha256") == checkpoint.get("specSha256") proven = health["running"] and health.get("creationOperation") == operation["id"] and health.get("specSha256") == checkpoint.get("specSha256")
@@ -2885,6 +3318,7 @@ class Agent:
directory = self.instance_dir(instance_id, observed_generation) directory = self.instance_dir(instance_id, observed_generation)
atomic_json(directory.parent / "current.json", {"generation": observed_generation}) atomic_json(directory.parent / "current.json", {"generation": observed_generation})
if kind == "instance.rebuild" and not self.inspect(instance_id, generation)["exists"]: if kind == "instance.rebuild" and not self.inspect(instance_id, generation)["exists"]:
self.settle_bank(instance_id, generation, operation["id"])
old = self.instance_dir(instance_id, generation) old = self.instance_dir(instance_id, generation)
if old.exists(): if old.exists():
shutil.rmtree(old) shutil.rmtree(old)
@@ -2899,6 +3333,7 @@ class Agent:
elif kind == "instance.remove": elif kind == "instance.remove":
proven = not health["exists"] proven = not health["exists"]
if proven: if proven:
self.settle_bank(instance_id, generation, operation["id"])
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
if directory.exists(): if directory.exists():
shutil.rmtree(directory) shutil.rmtree(directory)
+82 -32
View File
@@ -19,13 +19,16 @@ import re
import stat import stat
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
UPDATER_PROTOCOL_VERSION = 1 UPDATER_PROTOCOL_VERSION = 2
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
# This repository is the trust authority, independent of controller configuration.
RELEASE_KEY_URL = "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg"
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py") UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json") UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
@@ -36,6 +39,8 @@ UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json") AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service" AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024 MAX_MANIFEST = 16 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_KEYRING = 256 * 1024
MAX_SOURCE = 8 * 1024 * 1024 MAX_SOURCE = 8 * 1024 * 1024
HEALTH_TIMEOUT = 90 HEALTH_TIMEOUT = 90
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$") VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
@@ -150,6 +155,72 @@ def source_version(source):
return declarations[0] return declarations[0]
def fetch_url(url, limit, source):
request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError(f"The {source} did not return a complete download")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError(f"The {source} download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError(f"The {source} download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError(f"The {source} is unavailable; the running agent was left unchanged") from None
def fetch_release_key():
# Fetch afresh before every release check, including checks with no new
# version. There is deliberately no persistent keyring or offline fallback.
key = fetch_url(RELEASE_KEY_URL, MAX_KEYRING, "release-signing repository")
if not key:
raise IntegrityError("The release-signing repository returned an empty keyring")
return key
def verify_release_signature(manifest, signature, public_key):
if not public_key or len(public_key) > MAX_KEYRING:
raise IntegrityError("The repository release-signing keyring is empty or too large")
if not signature:
raise IntegrityError("The controller release requires a detached manifest signature")
trusted(UPDATE_DIR, directory=True)
# An isolated home excludes any ambient GnuPG keyring or configuration.
# The exact downloaded bytes are verified before manifest JSON is parsed.
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
home = Path(temporary)
keyring = home / "release-signing.gpg"
keyring.write_bytes(public_key)
manifest_path = home / "manifest.json"
signature_path = home / "manifest.json.sig"
manifest_path.write_bytes(manifest)
signature_path.write_bytes(signature)
try:
result = subprocess.run(["gpgv", "--homedir", str(home), "--keyring", str(keyring), str(signature_path), str(manifest_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):
raise IntegrityError("Release signature verification is unavailable; install or repair gpgv on this host") from None
if result.returncode:
raise IntegrityError("The release manifest signature is invalid or is not signed by a trusted release key")
@contextlib.contextmanager @contextlib.contextmanager
def exclusive_lock(path): def exclusive_lock(path):
trusted(path, missing=True) trusted(path, missing=True)
@@ -242,40 +313,19 @@ class Updater:
return state return state
def fetch(self, path, limit): def fetch(self, path, limit):
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"}) return fetch_url(self.installation["controller"] + path, limit, "controller release")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError("The controller did not return a complete agent release")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError("The controller release exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError("The controller release download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError("The controller release exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError("The controller release download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
def manifest(self): def manifest(self):
public_key = fetch_release_key()
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
try: try:
value = json.loads(self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)) signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
except ContactError:
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
self.ensure_directory()
verify_release_signature(manifest, signature, public_key)
try:
value = json.loads(manifest)
except (ValueError, UnicodeError): except (ValueError, UnicodeError):
raise IntegrityError("The controller release manifest is invalid") from None raise IntegrityError("The controller release manifest is invalid") from None
if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64": if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64":
+46 -6
View File
@@ -33,7 +33,7 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux gpgv
installation_preflight() { installation_preflight() {
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2 echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2
@@ -142,7 +142,7 @@ PY
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight installation_preflight
temporary_dir=$(mktemp -d) temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
updater_timer_was_active=false updater_timer_was_active=false
update_lock_held=false update_lock_held=false
activity_lock_held=false activity_lock_held=false
@@ -167,21 +167,61 @@ installation_cleanup() {
exit "$ec_install_status" exit "$ec_install_status"
} }
trap installation_cleanup EXIT trap installation_cleanup EXIT
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
-o "$temporary_dir/release-signing.gpg")
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
exit 1
}
# INSTALLATION_TRUST_END
controller="${controller%/}" controller="${controller%/}"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py" curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'PY' python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
# INSTALLATION_RELEASE: check both downloads before installing or executing either. # INSTALLATION_RELEASE: check both downloads before installing or executing either.
import ast, hashlib, json, pathlib, re, sys import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) manifest_path = pathlib.Path(sys.argv[1])
manifest_bytes = manifest_path.read_bytes()
signature = manifest_path.with_name('manifest.json.sig')
keyring = pathlib.Path(sys.argv[4])
# The key lives in our private root-owned mktemp directory under sticky /tmp.
# /tmp itself is intentionally writable; require protection of the directory
# and file we created, rather than accepting a pre-existing host keyring.
for path in (keyring.parent, keyring):
info = path.lstat()
expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR
if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SystemExit('Release signing key must be a regular root-owned file in protected directories.')
if not 0 < keyring.stat().st_size <= 256 * 1024:
raise SystemExit('Repository public key is empty or exceeds its size limit.')
if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024:
raise SystemExit('Release signing metadata exceeds its size limit.')
try:
with tempfile.TemporaryDirectory(prefix='gpgv-', dir=manifest_path.parent) as home:
verified = subprocess.run(['gpgv', '--homedir', home, '--keyring', str(keyring),
str(signature), '-'], input=manifest_bytes,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):
raise SystemExit('Release signature verification requires a working gpgv installation.') from None
if verified.returncode:
raise SystemExit('Release signature verification failed. The installed agent was left unchanged.')
manifest = json.loads(manifest_bytes)
if not isinstance(manifest, dict):
raise SystemExit('Unsupported agent or updater release manifest.')
agent = pathlib.Path(sys.argv[2]).read_bytes() agent = pathlib.Path(sys.argv[2]).read_bytes()
updater = pathlib.Path(sys.argv[3]).read_bytes() updater = pathlib.Path(sys.argv[3]).read_bytes()
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'): if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
raise SystemExit('Agent release checksum verification failed.') raise SystemExit('Agent release checksum verification failed.')
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'): if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
raise SystemExit('Updater release checksum verification failed.') raise SystemExit('Updater release checksum verification failed.')
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1 or manifest.get('updaterProtocolVersion') != 1: if manifest.get('platform') != 'debian-amd64' or type(manifest.get('protocolVersion')) is not int or manifest['protocolVersion'] != 1 or type(manifest.get('updaterProtocolVersion')) is not int or manifest['updaterProtocolVersion'] != 2:
raise SystemExit('Unsupported agent or updater release manifest.') raise SystemExit('Unsupported agent or updater release manifest.')
versions = [node.value.value for node in ast.parse(agent).body versions = [node.value.value for node in ast.parse(agent).body
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets) if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
+26 -10
View File
@@ -5,14 +5,11 @@ import hashlib
import json import json
from pathlib import Path from pathlib import Path
import re import re
import shutil
def package(source, target): def manifest_bytes(agent, updater, installer):
"""Read version metadata without importing or executing release code.""" """Return the deterministic bytes signed by the offline release signer."""
source, target = Path(source), Path(target) # Inspect source without importing or executing release code.
agent = (source / "ec_agent.py").read_bytes()
updater = (source / "ec_agent_update.py").read_bytes()
versions = [node.value.value for node in ast.parse(agent).body versions = [node.value.value for node in ast.parse(agent).body
if isinstance(node, ast.Assign) and any(isinstance(name, ast.Name) and name.id == "VERSION" for name in node.targets) if isinstance(node, ast.Assign) and any(isinstance(name, ast.Name) and name.id == "VERSION" for name in node.targets)
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)] and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)]
@@ -21,14 +18,33 @@ def package(source, target):
compile(agent, "ec_agent.py", "exec") compile(agent, "ec_agent.py", "exec")
compile(updater, "ec_agent_update.py", "exec") compile(updater, "ec_agent_update.py", "exec")
manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64", manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64",
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 1, "sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 2,
"updaterSha256": hashlib.sha256(updater).hexdigest()} "updaterSha256": hashlib.sha256(updater).hexdigest(),
"installerSha256": hashlib.sha256(installer).hexdigest()}
return (json.dumps(manifest, indent=2) + "\n").encode("utf-8")
def package(source, target):
"""Read version metadata without importing or executing release code."""
source, target = Path(source), Path(target)
agent = (source / "ec_agent.py").read_bytes()
updater = (source / "ec_agent_update.py").read_bytes()
installer = (source / "install.sh").read_bytes()
manifest = manifest_bytes(agent, updater, installer)
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
try:
previous_manifest = (target / "manifest.json").read_bytes()
except FileNotFoundError:
previous_manifest = None
if previous_manifest != manifest:
# Builds never sign. Keep an offline signature only across identical
# rebuilds; changed releases remain unsigned until reviewed and signed.
(target / "manifest.json.sig").unlink(missing_ok=True)
(target / "ec-agent.py").write_bytes(agent) (target / "ec-agent.py").write_bytes(agent)
(target / "ec-agent-update.py").write_bytes(updater) (target / "ec-agent-update.py").write_bytes(updater)
shutil.copyfile(source / "install.sh", target / "install.sh") (target / "install.sh").write_bytes(installer)
temporary_manifest = target / ".manifest.json.tmp" temporary_manifest = target / ".manifest.json.tmp"
temporary_manifest.write_text(json.dumps(manifest, indent=2) + "\n") temporary_manifest.write_bytes(manifest)
temporary_manifest.replace(target / "manifest.json") temporary_manifest.replace(target / "manifest.json")
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Sign a reviewed, packaged release using a separate offline GnuPG home.
This command is never invoked by controller builds or runtime. Keep
the signing key and its GnuPG home outside this checkout and Docker context.
"""
import argparse
from pathlib import Path
import re
import subprocess
import tempfile
from package_release import manifest_bytes
def reviewed_manifest(release):
manifest = (release / "manifest.json").read_bytes()
expected = manifest_bytes((release / "ec-agent.py").read_bytes(),
(release / "ec-agent-update.py").read_bytes(),
(release / "install.sh").read_bytes())
if manifest != expected:
raise ValueError("Release manifest does not match packaged artifacts; package and review the release again")
return manifest
def sign_release(release, gnupghome, fingerprint):
"""Sign exact manifest bytes, without importing any private key material."""
release, gnupghome = Path(release).resolve(), Path(gnupghome).resolve()
repository = Path(__file__).resolve().parent.parent
if not re.fullmatch(r"(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})", fingerprint):
raise ValueError("Signing requires a full OpenPGP key fingerprint (40 or 64 hexadecimal characters)")
if gnupghome.is_relative_to(repository) or gnupghome.is_relative_to(release):
raise ValueError("Keep the signing GnuPG home outside the repository and release directory")
if not gnupghome.is_dir():
raise ValueError("The offline signing GnuPG home must already exist")
manifest = reviewed_manifest(release)
with tempfile.TemporaryDirectory(prefix=".sign-release-", dir=release) as directory:
staged_manifest = Path(directory) / "manifest.json"
signature = Path(directory) / "manifest.json.sig"
staged_manifest.write_bytes(manifest)
subprocess.run([
"gpg", "--no-options", "--homedir", str(gnupghome), "--batch", "--yes",
"--armor", "--detach-sign", "--digest-algo", "SHA512",
"--local-user", fingerprint.upper() + "!", "--output", str(signature),
str(staged_manifest),
], check=True)
if not signature.read_bytes().startswith(b"-----BEGIN PGP SIGNATURE-----\n"):
raise ValueError("GnuPG did not produce an ASCII-armored detached signature")
if reviewed_manifest(release) != manifest:
raise ValueError("Release changed during signing; review and sign the release again")
signature.chmod(0o644)
signature.replace(release / "manifest.json.sig")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--release-dir", type=Path,
default=Path(__file__).resolve().parent.parent / "static" / "agent")
parser.add_argument("--gnupghome", required=True, type=Path,
help="Existing offline GnuPG home outside the checkout/build context")
parser.add_argument("--fingerprint", required=True,
help="Full fingerprint of the exact signing key (not a key ID or email)")
args = parser.parse_args()
try:
sign_release(args.release_dir, args.gnupghome, args.fingerprint)
except (OSError, ValueError, SyntaxError, subprocess.CalledProcessError) as cause:
parser.exit(1, f"Release signing failed: {cause}\n")
print(f"Signed {args.release_dir / 'manifest.json'}")
if __name__ == "__main__":
main()
+685
View File
@@ -778,6 +778,12 @@ class AgentTests(unittest.TestCase):
else: else:
self.assertEqual(inventory["cpuUsagePercent"], usage) self.assertEqual(inventory["cpuUsagePercent"], usage)
def test_inventory_reports_optional_temperature_capability_and_empty_samples(self):
with patch.object(self.agent, "temperatures", return_value=[]):
inventory = self.agent.inventory()
self.assertEqual(inventory["temperatures"], [])
self.assertIn("host.temperatures", inventory["capabilities"])
def resource_container(self): def resource_container(self):
return {"Name": self.agent.container_name(INSTANCE, 1), return {"Name": self.agent.container_name(INSTANCE, 1),
"Config": {"Labels": {module.LABEL + ".managed": "true", module.LABEL + ".instance": INSTANCE, module.LABEL + ".generation": "1"}}, "Config": {"Labels": {module.LABEL + ".managed": "true", module.LABEL + ".instance": INSTANCE, module.LABEL + ".generation": "1"}},
@@ -1644,6 +1650,685 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed") self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls)) self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
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"
target = app / value
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
target.write_bytes(secret)
files = [{"id": 9, "path": value, "delete": True, "contentBase64": None}]
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.stage(INSTANCE, 1, {"stageId": "private", "files": files}, "stage")
marker = self.agent.instance_dir(INSTANCE, 1) / "stages/private/manifest.json"
# A stage written by a previous agent must be rejected on activation too.
module.atomic_json(marker, {"state": "staged", "files": files})
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.activate(INSTANCE, 1, "private", override_safety=True)
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.rebuild_before(INSTANCE, 1, "private", {})
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.restore_files(app, [{"path": value, "contentBase64": None}])
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.file_operation("files.read", INSTANCE, 1, {"path": value}, "read")
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.read_log(INSTANCE, 1, {"path": value})
manifest = self.agent.file_operation("files.manifest", INSTANCE, 1,
{"roots": ["configs"], "paths": [value], "ignoreNames": []}, "manifest")
self.assertNotIn(value, [item["path"] for item in manifest["files"]])
self.assertEqual(target.read_bytes(), secret)
self.assertNotIn("before", json.loads(marker.read_text()))
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.initial_files({"files": [{"path": value, "contentBase64": base64.b64encode(secret).decode()}]})
def prepare_t6_library(self):
target = self.agent.library_dir("t6", self.library_id)
module.shutil.copytree(self.library, target)
manifest = json.loads((target / "manifest.json").read_text())
manifest["game"] = "t6"
module.atomic_json(target / "manifest.json", manifest)
def test_t6_create_materializes_private_bank_config(self):
self.prepare_t6_library()
self.test_create_materializes_private_bank_config_from_instance_credential(game="t6")
def test_create_materializes_private_bank_config_from_instance_credential(self, game="t5"):
secret = "ec_bank_" + "a" * 43
payload = {"name": "server", "game": game, "libraryId": self.library_id,
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960,
"bank": {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}}
operation = self.operation("instance.create", payload, "create")
self.assertEqual(self.agent.execute(operation)["state"], "completed")
path = self.agent.instance_dir(INSTANCE, 1) / "app/configs/electric_cherry_bank.cfg"
self.assertEqual(path.read_text(), f'set tws_bank_token "{secret}"\n')
self.assertEqual(self.agent.config["controller"], "https://controller.example")
self.assertIn("bank.settlement", self.agent.inventory()["capabilities"])
payload["bank"]["token"] = secret + '\nset bad "1"'
with self.assertRaises(AgentError):
self.agent.initial_files(payload)
def 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 afk_journal(self, game="t5", phase="active"):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-afk/{game}/scope.json"
reserve = {"action": "reserve", "server": "t5_zombie_test_1", "guid": "ace",
"match": "match-one", "request_id": "reserve-one", "session_id": "session-one", "seconds": 1800}
module.atomic_json(path, {"version": 1, "server": reserve["server"], "endpoint": module.AFK_GAME_ENDPOINT,
"requests": [json.dumps(reserve)], "sessions": [{"body": json.dumps(reserve), "phase": phase,
"authorized_seconds": 0 if phase == "prepared" else 1800,
"consumed_seconds": 42 if phase == "active" else 0}]})
return path
def afk_acknowledgment(self, method, path, payload):
self.assertEqual((method, path), ("POST", "/api/agent/afk/settle"))
self.assertFalse(self.agent.inspect(INSTANCE, 1)["running"])
transfer = payload["transfer"]
self.assertEqual(transfer["action"], "settle")
return {"state": "unresolved" if transfer["consumed_seconds"] is None else "settled",
"request_id": transfer["request_id"]}
def test_afk_remove_recovers_both_engines_without_fabricating_crash_duration(self):
self.create()
paths = [self.afk_journal(game) for game in ("t5", "t6")]
def acknowledge(*args):
transfer = args[2]["transfer"]
self.assertTrue(all(path.exists() for path in paths))
self.assertEqual(transfer["end_reason"], "crash")
self.assertIsNone(transfer["consumed_seconds"])
self.assertEqual(transfer["confirmed_seconds"], 42)
self.assertRegex(transfer["request_id"], r"^afk-stop-[0-9a-f]{16}$")
return self.afk_acknowledgment(*args)
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 2)
self.assertTrue(all(not path.exists() for path in paths))
def test_afk_recovery_retains_storage_and_reuses_decisions_after_lost_reply(self):
self.create()
path = self.afk_journal(phase="authorized")
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
original = path.read_bytes()
observed = []
def lost(method, endpoint, payload):
observed.append(payload["transfer"])
raise AgentError("Lost reply")
with patch.object(self.agent, "request", side_effect=lost):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertEqual(path.read_bytes(), original)
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
def acknowledge(method, endpoint, payload):
self.assertEqual(payload["transfer"], observed[0])
self.assertEqual(payload["transfer"]["consumed_seconds"], 0)
self.assertEqual(payload["transfer"]["end_reason"], "never_started")
return self.afk_acknowledgment(method, endpoint, payload)
with patch.object(self.agent, "request", side_effect=acknowledge):
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(path.exists())
def test_afk_remove_accepts_an_already_corrected_session(self):
self.create()
path = self.afk_journal()
def acknowledge(method, endpoint, payload):
response = self.afk_acknowledgment(method, endpoint, payload)
return {**response, "state": "corrected"}
with patch.object(self.agent, "request", side_effect=acknowledge):
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertFalse(path.exists())
def test_afk_recovery_rejects_running_process_corruption_and_untrusted_paths(self):
self.create()
path = self.afk_journal()
with self.assertRaisesRegex(AgentError, "stopped instance"):
self.agent.settle_afk(INSTANCE, 1, "remove")
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
original = json.loads(path.read_text())
for invalid in ("{broken", json.dumps({**original, "endpoint": "https://untrusted.example"}),
json.dumps({**original, "sessions": []}),
json.dumps({**original, "requests": original["requests"] * 2})):
path.write_text(invalid)
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
request.assert_not_called()
self.assertTrue(path.exists())
outside = Path(self.temp.name) / "afk-outside.json"
outside.write_text(json.dumps(original))
path.unlink()
path.symlink_to(outside)
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
request.assert_not_called()
self.assertTrue(outside.exists())
def test_afk_recovery_preserves_purchase_and_event_receipt_identity(self):
self.create()
path = self.afk_journal(phase="prepared")
value = json.loads(path.read_text())
for action in ("event", "purchase"):
value["requests"].append(json.dumps({"action": action, "server": value["server"], "guid": "ace",
"match": "match-one", "request_id": action + "-one", "confirmed": True}))
module.atomic_json(path, value)
expected = module.afk_recovery_requests(value)
def acknowledge(method, endpoint, payload):
self.assertEqual(endpoint, "/api/agent/afk/settle")
self.assertIn(payload["transfer"], expected)
return {"request_id": payload["transfer"]["request_id"],
"state": "settled" if payload["action"] == "settle" else "rejected"}
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 3)
def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json"
reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)]
module.atomic_json(path, {"version": 1, "server": "t5_zombie_test_1", "endpoint": endpoint, "reports": reports})
return path
def bank_acknowledgment(self, method, path, payload):
self.assertEqual((method, path), ("POST", "/api/agent/bank/settle"))
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
return {"state": "committed" if payload["action"] == "commit" else "cancelled", "request_id": payload["transfer"]["request_id"]}
def test_remove_settles_both_game_journals_after_stop_before_deleting_storage(self):
self.create()
paths = [self.bank_journal(game=game) for game in ("t5", "t6")]
def acknowledge(*args):
self.assertTrue(all(path.exists() for path in paths))
self.assertEqual(args[2]["operationId"], "remove")
return self.bank_acknowledgment(*args)
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 4)
self.assertTrue(all(not path.exists() for path in paths))
def test_remove_retains_bank_journal_during_outage_and_resumes_after_agent_restart(self):
self.create()
path = self.bank_journal()
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
with patch.object(self.agent, "request", side_effect=AgentError("Controller request failed")):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
calls = list(self.docker.calls)
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment):
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(path.exists())
self.assertEqual(self.docker.calls, calls)
def test_t6_rebuild_preserves_private_bank_configuration(self):
self.prepare_t6_library()
self.test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(game="t6")
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self, game="t5"):
secret = "ec_bank_" + "a" * 43
payload = self.create()
payload["game"] = game
payload["bank"] = {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}
paths = [self.bank_journal(game=game, endpoint=endpoint)
for game in ("t5", "t6")
for endpoint in (module.BANK_GAME_ENDPOINT, "https://controller.example/api/bank")]
originals = {path: path.read_bytes() for path in paths}
operation = self.operation("instance.rebuild", {"spec": payload, "nextGeneration": 2, "safetyOverride": True}, "rebuild")
applied, calls = {}, []
def acknowledge(method, path, data):
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
self.assertEqual(self.agent.config["controller"], "https://controller.example")
for journal, raw in originals.items():
self.assertEqual(journal.read_bytes(), raw)
transfer = data["transfer"]
request_id = transfer["request_id"]
calls.append(request_id)
if request_id in applied:
self.assertEqual(applied[request_id], transfer)
applied[request_id] = transfer
response = self.bank_acknowledgment(method, path, data)
if len(calls) == 1:
raise AgentError("Lost reply after settlement")
return response
with patch.object(self.agent, "request", side_effect=acknowledge):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
with patch.object(self.agent, "request", side_effect=acknowledge):
self.assertEqual(self.agent.execute(operation)["state"], "failed")
retry = self.operation("instance.rebuild", operation["payload"], "replacement")
result = self.agent.execute(retry)
self.assertEqual(result["state"], "completed", result)
# Reconciliation drains once, then the replacement operation drains
# again before deleting the unchanged journals. All retries keep IDs.
self.assertEqual(len(calls), 17)
self.assertEqual(len(applied), 4)
self.assertEqual({item["decision"] for item in applied.values()}, {"commit", "cancel"})
self.assertTrue(all(not path.exists() for path in paths))
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
config = self.agent.instance_dir(INSTANCE, 2) / "app/configs/electric_cherry_bank.cfg"
self.assertEqual(config.read_text(), f'set tws_bank_token "{secret}"\n')
def test_remove_drains_legacy_and_public_endpoints_without_following_journal_urls(self):
self.create()
paths = [self.bank_journal(game=game, endpoint=endpoint)
for game in ("t5", "t6")
for endpoint in (module.BANK_GAME_ENDPOINT, "https://controller.example/api/bank")]
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 8)
self.assertTrue(all(not path.exists() for path in paths))
def test_unrecognized_bank_endpoints_retain_storage_without_settlement(self):
self.create()
path = self.bank_journal()
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
for endpoint in ("https://untrusted.example/api/bank", "http://ec.thewrightserver.net/api/bank",
"https://ec.thewrightserver.net.evil/api/bank", module.BANK_GAME_ENDPOINT + "?redirect=evil",
"https://controller.example/other/api/bank", "", None):
with self.subTest(endpoint=endpoint):
journal = json.loads(path.read_text())
journal["endpoint"] = endpoint
module.atomic_json(path, journal)
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
request.assert_not_called()
self.assertTrue(path.exists())
def test_rebuild_settles_before_starting_replacement_and_retains_unknown_decisions(self):
payload = self.create()
path = self.bank_journal(("commit",))
operation = self.operation("instance.rebuild", {"spec": payload, "nextGeneration": 2, "safetyOverride": True}, "rebuild")
with patch.object(self.agent, "request", return_value={"state": "committed", "request_id": "wrong"}):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment):
# Existing rebuild recovery records the interrupted create as failed;
# a fresh operation can then rebuild without replaying the old command.
self.assertEqual(self.agent.execute(operation)["state"], "failed")
retry = self.operation("instance.rebuild", operation["payload"], "replacement")
self.assertEqual(self.agent.execute(retry)["state"], "completed")
self.assertFalse(path.exists())
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
def test_cancel_creation_retries_bank_settlement_before_deleting_runtime(self):
self.create()
path = self.bank_journal(("cancel",))
operation = self.operation("instance.cancel-create", {"createOperationId": "create"}, "cancel")
with patch.object(self.agent, "request", side_effect=AgentError("offline")):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment):
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(path.exists())
def test_malformed_or_symlinked_bank_journal_blocks_storage_deletion(self):
self.create()
path = self.bank_journal()
path.write_text('{broken')
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
outside = Path(self.temp.name) / "outside.json"
outside.write_text('{"reports": []}')
self.bank_journal()
journal = json.loads(path.read_text())
journal["endpoint"] = "https://untrusted.example/api/bank"
path.write_text(json.dumps(journal))
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
path.unlink()
path.symlink_to(outside)
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.is_symlink())
request.assert_not_called()
def staged_rebuild(self, deleting=False):
spec = self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
file = {"id": 9, "path": "configs/server.cfg", "contentBase64": None if deleting else base64.b64encode(b"replacement").decode(), "checkBefore": True, "expectedBeforeSha": module.digest(b"original")}
staged = self.agent.execute(self.operation("files.stage", {"stageId": "campaign", "files": [file]}, "stage"))
self.assertEqual(staged["state"], "completed")
spec["files"] = [] if deleting else [file]
return self.operation("instance.rebuild", {"spec": spec, "stageId": "campaign", "nextGeneration": 2}, "rebuild")
def test_file_rebuild_replaces_generation_and_retains_before_images(self):
operation = self.staged_rebuild()
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed", result)
receipt = json.loads(result["result"])
self.assertEqual(receipt["before"], [{"id": 9, "path": "configs/server.cfg", "contentBase64": base64.b64encode(b"original").decode()}])
self.assertEqual(receipt["generation"], 2)
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
self.assertEqual((self.agent.instance_dir(INSTANCE, 2) / "app/configs/server.cfg").read_bytes(), b"replacement")
self.assertIn("instance.rebuild-files", self.agent.inventory()["capabilities"])
calls = list(self.docker.calls)
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertEqual(self.docker.calls, calls)
def test_file_rebuild_removes_requested_files(self):
operation = self.staged_rebuild(deleting=True)
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed", result)
self.assertFalse((self.agent.instance_dir(INSTANCE, 2) / "app/configs/server.cfg").exists())
self.assertEqual(len(json.loads(result["result"])["before"]), 1)
def test_file_rebuild_rejects_changed_targets_and_mismatched_replacement(self):
operation = self.staged_rebuild()
target = self.agent.instance_dir(INSTANCE, 1) / "app/configs/server.cfg"
target.write_bytes(b"changed")
self.assertEqual(self.agent.execute(operation)["state"], "failed")
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
target.write_bytes(b"original")
operation["id"] = "mismatched"
operation["payload"]["spec"]["files"] = []
self.assertEqual(self.agent.execute(operation)["state"], "failed")
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
def test_file_rebuild_rechecks_players_after_snapshot_preparation(self):
operation = self.staged_rebuild()
with patch.object(self.agent, "rcon", side_effect=[{"ok": True, "players": 0}, {"ok": True, "players": 1}]):
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
def test_file_rebuild_reconciles_snapshots_after_old_storage_was_removed(self):
operation = self.staged_rebuild()
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.agent.journal.update(operation["id"], "unknown", error="Lost completion")
calls = list(self.docker.calls)
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed", result)
self.assertEqual(json.loads(result["result"])["before"][0]["contentBase64"], base64.b64encode(b"original").decode())
self.assertEqual(self.docker.calls, calls)
def artifact_rebuild(self, staged=False):
if staged:
operation = self.staged_rebuild()
else:
spec = self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
operation = self.operation("instance.rebuild", {"spec": spec, "nextGeneration": 2}, "rebuild")
spec = operation["payload"]["spec"]
contents = {"configs/server.cfg": b"replacement", "scripts/replacement.gsc": b"verified artifact bytes"}
spec["files"] = [{"path": path, "contentSha256": module.digest(content), "sha256": module.digest(content)} for path, content in contents.items()]
spec["files"].append({"path": "configs/empty.cfg", "contentBase64": ""})
spec["bank"] = {"configPath": "configs/electric_cherry_bank.cfg", "token": "ec_bank_" + "a" * 32}
return operation, contents
def assert_rebuild_uses_preflight_artifacts(self, staged):
operation, contents = self.artifact_rebuild(staged)
original_request = json.dumps(operation, sort_keys=True)
available = {module.digest(content): content for content in contents.values()}
def fetch(method, url):
# Connectivity disappears as soon as the complete preflight set has
# been read, before staged comparison or destructive replacement.
if not available:
raise module.urllib.error.URLError(TimeoutError())
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
return Response(available.pop(url.rsplit("/", 1)[-1]))
with patch.object(self.agent, "artifact_open", side_effect=fetch) as downloads:
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed", result)
self.assertEqual(downloads.call_count, len(contents))
self.assertFalse(available)
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
app = self.agent.instance_dir(INSTANCE, 2) / "app"
for path, content in contents.items():
self.assertEqual((app / path).read_bytes(), content)
self.assertEqual((app / "configs/empty.cfg").read_bytes(), b"")
self.assertEqual((app / "configs/electric_cherry_bank.cfg").read_text(), 'set tws_bank_token "ec_bank_' + "a" * 32 + '"\n')
self.assertEqual(json.dumps(operation, sort_keys=True), original_request)
self.assertEqual(json.loads(result["request"]), operation)
self.assertEqual(self.agent.spec(INSTANCE, 2), operation["payload"]["spec"])
labels = self.docker.inspect(self.agent.container_name(INSTANCE, 2))["Config"]["Labels"]
self.assertEqual(labels[module.LABEL + ".operation"], operation["id"])
self.assertEqual(labels[module.LABEL + ".spec-sha256"], module.digest(json.dumps(operation["payload"]["spec"], sort_keys=True, separators=(",", ":")).encode()))
receipt = json.loads(result["result"])
self.assertEqual(receipt["before"], [{"id": 9, "path": "configs/server.cfg", "contentBase64": base64.b64encode(b"original").decode()}] if staged else [])
for content in contents.values():
self.assertNotIn(base64.b64encode(content).decode(), json.dumps(result))
# A later operation with the same artifacts must download its own set.
later = self.operation("instance.rebuild", {"spec": operation["payload"]["spec"], "nextGeneration": 3}, "later-rebuild", generation=2)
with patch.object(self.agent, "artifact_open", side_effect=module.urllib.error.URLError(TimeoutError())) as downloads:
self.assertEqual(self.agent.execute(later)["state"], "failed")
downloads.assert_called_once()
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
self.assertFalse(self.agent.inspect(INSTANCE, 3)["exists"])
def test_unstaged_rebuild_succeeds_without_artifact_connectivity_after_preflight(self):
self.assert_rebuild_uses_preflight_artifacts(staged=False)
def test_staged_rebuild_succeeds_without_artifact_connectivity_after_preflight(self):
self.assert_rebuild_uses_preflight_artifacts(staged=True)
def test_rebuild_preflight_download_failure_preserves_original_and_discards_partial_set(self):
operation, contents = self.artifact_rebuild()
with patch.object(self.agent, "artifact_open", side_effect=[Response(contents["configs/server.cfg"]), module.urllib.error.URLError(TimeoutError())]) as downloads:
result = self.agent.execute(operation)
self.assertEqual(result["state"], "failed", result)
self.assertEqual(downloads.call_count, 2)
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(self.agent.instance_dir(INSTANCE, 2).exists())
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
operation["id"] = "retry-rebuild"
with patch.object(self.agent, "artifact_open", side_effect=module.urllib.error.URLError(TimeoutError())) as downloads:
self.assertEqual(self.agent.execute(operation)["state"], "failed")
self.assertTrue(downloads.call_args.args[1].endswith(module.digest(contents["configs/server.cfg"])))
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
def test_held_rebuild_discards_prepared_artifacts_before_retry(self):
operation, contents = self.artifact_rebuild(staged=True)
with patch.object(self.agent, "artifact_open", side_effect=[Response(content) for content in contents.values()]), patch.object(self.agent, "rcon", return_value={"ok": True, "players": 1}):
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
with patch.object(self.agent, "artifact_open", side_effect=module.urllib.error.URLError(TimeoutError())) as downloads:
self.assertEqual(self.agent.execute(operation)["state"], "failed")
downloads.assert_called_once()
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
def test_rebuild_preflight_rejects_corrupt_and_oversized_artifacts(self):
operation, contents = self.artifact_rebuild()
for index, content in enumerate((b"corrupt", b"x" * (module.MAX_FILE + 1))):
with self.subTest(index=index):
operation["id"] = f"invalid-artifact-{index}"
with patch.object(self.agent, "artifact_open", return_value=Response(content)):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "failed", result)
self.assertIn("checksum mismatch" if index == 0 else "size limit", result["error"])
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
def test_rebuild_preflight_bounds_complete_file_set_at_64_mib(self):
operation, _ = self.artifact_rebuild()
spec = operation["payload"]["spec"]
content = b"x" * module.MAX_FILE
spec["files"] = [{"path": f"scripts/{index}.gsc", "contentSha256": module.digest(content)} for index in range(2)]
# The generated private bank file also counts toward the operation limit.
with patch.object(self.agent, "artifact_open", side_effect=lambda *args: Response(content)):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "failed", result)
self.assertIn("64 MiB operation limit", result["error"])
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
del spec["bank"]
operation["id"] = "at-limit"
with patch.object(self.agent, "artifact_open", side_effect=lambda *args: Response(content)), patch.object(self.agent, "rcon", return_value={"ok": True, "players": 1}):
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
def test_replacement_validation_rejects_incomplete_or_mismatched_prepared_bytes_without_downloads(self):
operation, contents = self.artifact_rebuild()
spec = operation["payload"]["spec"]
del spec["bank"]
prepared = {**contents, "configs/empty.cfg": b""}
invalid_sets = [
{path: content for path, content in prepared.items() if path != "configs/server.cfg"},
{**prepared, "configs/extra.cfg": b"unexpected"},
{**prepared, "configs/server.cfg": b"wrong artifact"},
{**prepared, "configs/empty.cfg": b"wrong inline bytes"},
{**prepared, "configs/server.cfg": None},
]
with patch.object(self.agent, "artifact_open", side_effect=AssertionError("Prepared validation must not download")) as downloads:
for index, files in enumerate(invalid_sets):
with self.subTest(index=index), self.assertRaises(AgentError):
self.agent.validate_spec(spec, prepared_files=files)
downloads.assert_not_called()
def test_rebuild_preflight_preserves_file_path_and_duplicate_checks(self):
operation, _ = self.artifact_rebuild()
spec = operation["payload"]["spec"]
for index, paths in enumerate((["../escape.cfg"], ["configs/electric_cherry_bank.cfg"], ["configs/same.cfg"] * 2)):
with self.subTest(paths=paths):
operation["id"] = f"invalid-paths-{index}"
spec["files"] = [{"path": path, "contentBase64": ""} for path in paths]
self.assertEqual(self.agent.execute(operation)["state"], "failed")
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
def test_staged_rebuild_rejects_verified_artifacts_that_differ_from_the_stage(self):
operation, contents = self.artifact_rebuild(staged=True)
contents["configs/server.cfg"] = b"different verified replacement"
operation["payload"]["spec"]["files"][0].update(contentSha256=module.digest(contents["configs/server.cfg"]), sha256=module.digest(contents["configs/server.cfg"]))
with patch.object(self.agent, "artifact_open", side_effect=[Response(content) for content in contents.values()]):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "failed", result)
self.assertIn("does not match staged file content", result["error"])
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
def test_rebuild_moves_generation_and_rejects_old_commands(self): def test_rebuild_moves_generation_and_rejects_old_commands(self):
payload = self.create() payload = self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0} self.agent.rcon = lambda *_: {"ok": True, "players": 0}
+98 -7
View File
@@ -11,7 +11,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import Mock, patch
SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text() SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
@@ -33,6 +33,8 @@ class InstallerTests(unittest.TestCase):
self.root = self.base / "managed" self.root = self.base / "managed"
self.config = self.base / "configuration" / "agent.json" self.config = self.base / "configuration" / "agent.json"
self.config.parent.mkdir() self.config.parent.mkdir()
self.keyring = self.config.with_name('release-signing.gpg')
self.keyring.write_bytes(b'publisher public key')
self.controller = "https://controller.example" self.controller = "https://controller.example"
self.saved = {"root": str(self.root), "controller": self.controller, self.saved = {"root": str(self.root), "controller": self.controller,
"hostId": "host-one", "credential": "private"} "hostId": "host-one", "credential": "private"}
@@ -80,6 +82,10 @@ class InstallerTests(unittest.TestCase):
self.preflight() self.preflight()
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep") self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
def test_no_preprovisioned_signing_key_is_required(self):
self.keyring.unlink()
self.preflight()
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self): def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
self.legacy_installation() self.legacy_installation()
original_config = self.config.read_bytes() original_config = self.config.read_bytes()
@@ -334,23 +340,74 @@ docker() {
self.assertNotIn("Docker is installed but unavailable", result.stderr) self.assertNotIn("Docker is installed but unavailable", result.stderr)
class RepositoryTrustTests(unittest.TestCase):
def fetch(self, status="200", body="public key", curl_exit=0):
block = SOURCE[SOURCE.index("# INSTALLATION_TRUST:"):SOURCE.index("# INSTALLATION_TRUST_END")]
with tempfile.TemporaryDirectory() as temporary:
script = r'''set -euo pipefail
curl() {
printf '%s\n' "$@" > "$temporary_dir/curl-args"
printf '%s' "$EC_TEST_BODY" > "$temporary_dir/release-signing.gpg"
printf '%s' "$EC_TEST_STATUS"
return "$EC_TEST_EXIT"
}
''' + block + "\nprintf 'verified-fetch'"
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
env={**os.environ, "temporary_dir": temporary, "EC_TEST_BODY": body,
"EC_TEST_STATUS": status, "EC_TEST_EXIT": str(curl_exit)})
args = (Path(temporary) / "curl-args").read_text().splitlines()
return result, args
def test_installer_fetches_fixed_repository_key_with_bounds_and_tls(self):
result, args = self.fetch()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "verified-fetch")
self.assertIn("https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg", args)
for option, value in (("--proto", "=https"), ("--max-filesize", "262144"),
("--max-time", "120"), ("--connect-timeout", "25"),
("--header", "Cache-Control: no-cache")):
self.assertEqual(args[args.index(option) + 1], value)
self.assertNotIn("--location", args)
self.assertNotIn("--insecure", args)
self.assertLess(SOURCE.index("# INSTALLATION_TRUST:"), SOURCE.index('$controller/api/agent/release/manifest.json'))
def test_repository_failure_redirect_or_empty_key_stops_installation(self):
for status, body, code in (("302", "redirect", 0), ("404", "missing", 22),
("200", "", 0), ("200", "partial", 28), ("200", "oversized", 63)):
with self.subTest(status=status, body=body):
result, _ = self.fetch(status, body, code)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("verified-fetch", result.stdout)
class ReleaseTests(unittest.TestCase): class ReleaseTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
self.source = Path(self.temporary.name) / "source" self.source = Path(self.temporary.name).resolve() / "source"
self.target = Path(self.temporary.name) / "release" self.target = Path(self.temporary.name).resolve() / "release"
self.source.mkdir() self.source.mkdir()
(self.source / "ec_agent.py").write_text('VERSION = "3.12.7"\nraise RuntimeError("must never execute during packaging")\n') (self.source / "ec_agent.py").write_text('VERSION = "3.12.7"\nraise RuntimeError("must never execute during packaging")\n')
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n') (self.source / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n')
(self.source / "install.sh").write_text("#!/bin/bash\n") (self.source / "install.sh").write_text("#!/bin/bash\n")
PACKAGER.package(self.source, self.target) PACKAGER.package(self.source, self.target)
self.keyring = self.source.parent / 'release-signing.gpg'
self.keyring.write_bytes(b'publisher public key')
(self.target / 'manifest.json.sig').write_bytes(b'detached signature')
self.gpgv = Mock(return_value=subprocess.CompletedProcess([], 0))
def tearDown(self): def tearDown(self):
self.temporary.cleanup() self.temporary.cleanup()
def verify(self): def verify(self):
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py")] argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py"), str(self.keyring)]
with patch.object(sys, "argv", argv): original_lstat = Path.lstat
def root_owned(path):
fields = list(original_lstat(path))
fields[4] = 0
if stat.S_ISDIR(fields[0]):
fields[0] &= ~0o022
return os.stat_result(fields)
with patch.object(sys, "argv", argv), patch.object(Path, 'lstat', root_owned), patch.object(subprocess, 'run', self.gpgv):
exec(compile(RELEASE, "installer-release", "exec"), {}) exec(compile(RELEASE, "installer-release", "exec"), {})
def manifest(self, **changes): def manifest(self, **changes):
@@ -363,7 +420,7 @@ class ReleaseTests(unittest.TestCase):
def test_package_derives_version_and_hashes_both_sources_without_executing_them(self): def test_package_derives_version_and_hashes_both_sources_without_executing_them(self):
manifest = self.manifest() manifest = self.manifest()
self.assertEqual(manifest["version"], "3.12.7") self.assertEqual(manifest["version"], "3.12.7")
self.assertEqual(manifest["updaterProtocolVersion"], 1) self.assertEqual(manifest["updaterProtocolVersion"], 2)
self.assertEqual(manifest["sha256"], hashlib.sha256((self.source / "ec_agent.py").read_bytes()).hexdigest()) self.assertEqual(manifest["sha256"], hashlib.sha256((self.source / "ec_agent.py").read_bytes()).hexdigest())
self.assertEqual(manifest["updaterSha256"], hashlib.sha256((self.source / "ec_agent_update.py").read_bytes()).hexdigest()) self.assertEqual(manifest["updaterSha256"], hashlib.sha256((self.source / "ec_agent_update.py").read_bytes()).hexdigest())
self.assertEqual((self.target / "ec-agent-update.py").read_bytes(), (self.source / "ec_agent_update.py").read_bytes()) self.assertEqual((self.target / "ec-agent-update.py").read_bytes(), (self.source / "ec_agent_update.py").read_bytes())
@@ -380,11 +437,45 @@ class ReleaseTests(unittest.TestCase):
path.write_bytes(original) path.write_bytes(original)
def test_installer_rejects_missing_or_unsupported_updater_protocol(self): def test_installer_rejects_missing_or_unsupported_updater_protocol(self):
for protocol in (None, 0, 2, "1"): for protocol in (None, 0, 1, True, "2"):
self.manifest(updaterProtocolVersion=protocol) self.manifest(updaterProtocolVersion=protocol)
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"): with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
self.verify() self.verify()
def test_empty_or_oversized_downloaded_key_cannot_authorize_installation(self):
for value in (b"", b"x" * (256 * 1024 + 1)):
self.keyring.write_bytes(value)
with self.subTest(size=len(value)), self.assertRaisesRegex(SystemExit, "public key is empty or exceeds"):
self.verify()
self.gpgv.assert_not_called()
def test_signature_is_verified_against_downloaded_key_before_manifest_parsing(self):
self.verify()
args, kwargs = self.gpgv.call_args
self.assertEqual(args[0][0], 'gpgv')
self.assertEqual(args[0][-4:], ['--keyring', str(self.keyring), str(self.target / 'manifest.json.sig'), '-'])
self.assertEqual(kwargs['input'], (self.target / 'manifest.json').read_bytes())
self.assertEqual(kwargs['timeout'], 30)
self.assertFalse(Path(args[0][2]).exists())
(self.target / 'manifest.json').write_bytes(b'not even JSON')
self.gpgv.return_value = subprocess.CompletedProcess([], 1)
with self.assertRaisesRegex(SystemExit, 'signature verification failed'):
self.verify()
def test_missing_signature_key_or_gpgv_prevents_installation(self):
for path in (self.keyring, self.target / 'manifest.json.sig'):
original = path.read_bytes()
path.unlink()
self.gpgv.reset_mock()
with self.subTest(path=path), self.assertRaises(FileNotFoundError):
self.verify()
self.gpgv.assert_not_called()
path.write_bytes(original)
for failure in (FileNotFoundError(), subprocess.TimeoutExpired('gpgv', 30)):
self.gpgv.side_effect = failure
with self.subTest(failure=failure), self.assertRaisesRegex(SystemExit, 'working gpgv'):
self.verify()
def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self): def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self):
self.manifest(version="3.12.8") self.manifest(version="3.12.8")
with self.assertRaisesRegex(SystemExit, "version does not match"): with self.assertRaisesRegex(SystemExit, "version does not match"):
+243
View File
@@ -0,0 +1,243 @@
"""Release packaging is unsigned; only the explicit offline command may sign."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
AGENT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("package_release", AGENT / "package_release.py")
PACKAGER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKAGER)
SPEC = importlib.util.spec_from_file_location("sign_release", AGENT / "sign_release.py")
SIGNER = importlib.util.module_from_spec(SPEC)
with patch.dict(sys.modules, {"package_release": PACKAGER}):
SPEC.loader.exec_module(SIGNER)
SPEC = importlib.util.spec_from_file_location("signed_pipeline_updater", AGENT / "ec_agent_update.py")
UPDATER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(UPDATER)
class ReleaseFixture:
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.base = Path(self.temporary.name).resolve()
self.source = self.base / "source"
self.source.mkdir()
self.target = self.base / "release"
self.gnupghome = self.base / "offline-keyring"
self.gnupghome.mkdir(mode=0o700)
self.fingerprint = "A" * 40
(self.source / "ec_agent.py").write_text('VERSION = "4.2.0"\nraise RuntimeError("Do not run")\n')
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("Do not run")\n')
(self.source / "install.sh").write_text("#!/bin/bash\nexit 1\n")
PACKAGER.package(self.source, self.target)
def tearDown(self):
self.temporary.cleanup()
class PackagedReleaseTests(ReleaseFixture, unittest.TestCase):
def test_manifest_is_deterministic_and_covers_every_executable(self):
first = (self.target / "manifest.json").read_bytes()
PACKAGER.package(self.source, self.target)
self.assertEqual(first, (self.target / "manifest.json").read_bytes())
manifest = json.loads(first)
self.assertEqual(manifest["updaterProtocolVersion"], 2)
for field, filename in (("sha256", "ec-agent.py"),
("updaterSha256", "ec-agent-update.py"),
("installerSha256", "install.sh")):
self.assertEqual(manifest[field], hashlib.sha256((self.target / filename).read_bytes()).hexdigest())
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_packaging_preserves_signature_bytes_only_for_identical_manifest(self):
signature = self.target / "manifest.json.sig"
for filename in ("ec_agent.py", "ec_agent_update.py", "install.sh"):
with self.subTest(filename=filename):
signature.write_bytes(b"offline signature")
with patch.object(subprocess, "run", side_effect=AssertionError("Builds must never sign")):
PACKAGER.package(self.source, self.target)
self.assertEqual(signature.read_bytes(), b"offline signature")
source = self.source / filename
source.write_bytes(source.read_bytes() + b"# reviewed change\n")
PACKAGER.package(self.source, self.target)
self.assertFalse(signature.exists())
def test_orphaned_signature_is_removed_when_manifest_was_missing(self):
(self.target / "manifest.json").unlink()
(self.target / "manifest.json.sig").write_bytes(b"orphaned signature")
PACKAGER.package(self.source, self.target)
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_signer_requires_full_fingerprint_before_invoking_gpg(self):
for fingerprint in ("", "release@example.com", "A" * 16, "Z" * 40, "A" * 41):
with self.subTest(fingerprint=fingerprint), patch.object(subprocess, "run") as run:
with self.assertRaisesRegex(ValueError, "full OpenPGP key fingerprint"):
SIGNER.sign_release(self.target, self.gnupghome, fingerprint)
run.assert_not_called()
def test_signer_rejects_private_key_homes_in_repository_or_release(self):
for home in (AGENT, self.target, self.target / "private"):
with self.subTest(home=home), patch.object(subprocess, "run") as run:
with self.assertRaisesRegex(ValueError, "outside the repository and release"):
SIGNER.sign_release(self.target, home, self.fingerprint)
run.assert_not_called()
def test_signer_refuses_unreviewed_or_inconsistent_release_bytes(self):
for filename in ("ec-agent.py", "ec-agent-update.py", "install.sh", "manifest.json"):
with self.subTest(filename=filename), patch.object(subprocess, "run") as run:
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"\n")
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
run.assert_not_called()
path.write_bytes(original)
def test_failed_signing_does_not_replace_existing_signature(self):
signature = self.target / "manifest.json.sig"
signature.write_bytes(b"previous valid signature")
with patch.object(subprocess, "run", side_effect=subprocess.CalledProcessError(2, "gpg")):
with self.assertRaises(subprocess.CalledProcessError):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
self.assertEqual(signature.read_bytes(), b"previous valid signature")
self.assertEqual(list(self.target.glob(".sign-release-*")), [])
def test_release_mutation_during_signing_never_publishes_a_signature(self):
def mutate(command, **kwargs):
Path(command[command.index("--output") + 1]).write_bytes(b"-----BEGIN PGP SIGNATURE-----\nstaged\n")
(self.target / "ec-agent.py").write_bytes(b'VERSION = "9.0.0"\n')
with patch.object(subprocess, "run", side_effect=mutate):
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
self.assertFalse((self.target / "manifest.json.sig").exists())
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG required for real offline signing tests")
class OfflineSigningTests(ReleaseFixture, unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.keys = tempfile.TemporaryDirectory(prefix="ec-sign-")
cls.keyhome = Path(cls.keys.name)
cls.keyhome.chmod(0o700)
command = ["gpg", "--no-options", "--homedir", str(cls.keyhome), "--batch"]
subprocess.run(command + ["--pinentry-mode", "loopback", "--passphrase", "",
"--quick-generate-key", "Ephemeral release test <release@example.invalid>",
"ed25519", "sign", "0"], check=True, capture_output=True)
listing = subprocess.run(command + ["--with-colons", "--list-secret-keys"],
check=True, capture_output=True, text=True).stdout
cls.key_fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:"))
cls.public_key = subprocess.run(command + ["--export", cls.key_fingerprint],
check=True, capture_output=True).stdout
@classmethod
def tearDownClass(cls):
if shutil.which("gpgconf"):
subprocess.run(["gpgconf", "--homedir", str(cls.keyhome), "--kill", "gpg-agent"],
check=False, capture_output=True)
cls.keys.cleanup()
def setUp(self):
super().setUp()
self.keyring = self.base / "release-signing.gpg"
self.keyring.write_bytes(self.public_key)
self.update_dir = self.base / "update"
self.update_dir.mkdir(mode=0o700)
original_lstat = Path.lstat
protected_paths = {self.keyring, *self.keyring.parents, self.update_dir}
def root_owned(path):
info = original_lstat(path)
if path not in protected_paths:
return info
fields = list(info)
fields[4] = 0
if stat.S_ISDIR(fields[0]):
fields[0] &= ~0o022
return os.stat_result(fields)
self.root_owned = root_owned
def verify_installer(self):
blocks = re.findall(r"<<'PY'\n(.*?)\nPY", (AGENT / "install.sh").read_text(), re.DOTALL)
release = next(block for block in blocks if "# INSTALLATION_RELEASE:" in block)
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"),
str(self.target / "ec-agent-update.py"), str(self.keyring)]
# Only filesystem ownership is simulated; real GnuPG verifies the
# separately exported public key and the offline signer's exact bytes.
with patch.object(sys, "argv", argv), patch.object(Path, "lstat", self.root_owned):
exec(compile(release, "installer-release", "exec"), {})
def verify_updater(self):
with patch.object(UPDATER, "UPDATE_DIR", self.update_dir), \
patch.object(Path, "lstat", self.root_owned):
UPDATER.verify_release_signature((self.target / "manifest.json").read_bytes(),
(self.target / "manifest.json.sig").read_bytes(), self.public_key)
def verify(self):
return subprocess.run([
"gpg", "--no-options", "--homedir", str(self.keyhome), "--batch", "--verify",
str(self.target / "manifest.json.sig"), str(self.target / "manifest.json"),
], capture_output=True)
def test_offline_signature_verifies_exact_bytes_and_survives_identical_build(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
signature = (self.target / "manifest.json.sig").read_bytes()
self.assertTrue(signature.startswith(b"-----BEGIN PGP SIGNATURE-----\n"))
self.assertEqual(self.verify().returncode, 0)
PACKAGER.package(self.source, self.target)
self.assertEqual((self.target / "manifest.json.sig").read_bytes(), signature)
self.assertEqual(self.verify().returncode, 0)
with (self.target / "manifest.json").open("ab") as manifest:
manifest.write(b" ")
self.assertNotEqual(self.verify().returncode, 0)
def test_unknown_signing_key_fails_without_publishing_a_signature(self):
with self.assertRaises(subprocess.CalledProcessError):
SIGNER.sign_release(self.target, self.keyhome, "F" * 40)
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_offline_signed_release_is_accepted_by_installer_and_updater(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
self.verify_installer()
self.verify_updater()
def test_controller_cannot_authorize_changed_code_by_rehashing_manifest(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
for filename in ("ec-agent.py", "ec-agent-update.py"):
with self.subTest(filename=filename):
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"# controller's unauthorized change\n")
(self.target / "manifest.json").write_bytes(PACKAGER.manifest_bytes(
(self.target / "ec-agent.py").read_bytes(),
(self.target / "ec-agent-update.py").read_bytes(),
(self.target / "install.sh").read_bytes()))
with self.assertRaisesRegex(SystemExit, "signature verification failed"):
self.verify_installer()
with self.assertRaisesRegex(UPDATER.IntegrityError, "signature is invalid"):
self.verify_updater()
path.write_bytes(original)
def test_signed_manifest_rejects_changed_agent_and_updater_downloads(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
for filename, message in (("ec-agent.py", "Agent release checksum"),
("ec-agent-update.py", "Updater release checksum")):
with self.subTest(filename=filename):
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"# controller's unauthorized change\n")
with self.assertRaisesRegex(SystemExit, message):
self.verify_installer()
path.write_bytes(original)
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
import errno
import importlib.util
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("ec_agent_temperatures", Path(__file__).resolve().parents[1] / "ec_agent.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class TemperatureTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.hwmon = self.root / "class"
self.hwmon.mkdir()
self.patch = patch.object(module, "HWMON_ROOT", self.hwmon)
self.patch.start()
self.addCleanup(self.patch.stop)
self.agent = object.__new__(module.Agent)
def chip(self, number, device, readings):
physical = self.root / (device + str(number)) / "hwmon" / ("hwmon" + str(number))
physical.mkdir(parents=True)
(self.hwmon / physical.name).symlink_to(physical, target_is_directory=True)
(physical / "name").write_text(device)
for channel, label, value in readings:
(physical / (channel + "_input")).write_text(str(value))
if label is not None:
(physical / (channel + "_label")).write_text(label)
return physical
def test_ec2_sensors_and_units_without_guessing_unknown_devices(self):
self.chip(0, "nvme", [("temp1", "Composite", 50900), ("temp2", "Sensor 1", 50900)])
self.chip(1, "pch_cometlake", [("temp1", None, 62000)])
self.chip(2, "coretemp", [("temp1", "Package id 0", 65000), ("temp2", "Core 0", 63000)])
self.chip(3, "iwlwifi_1", [("temp1", None, "unavailable")])
samples = self.agent.temperatures()
self.assertEqual([s["celsius"] for s in samples], [50.9, 50.9, 62.0, 65.0, 63.0])
self.assertEqual([s["kind"] for s in samples], ["nvme", "nvme", "chipset", "cpu-package", "cpu-core"])
self.assertEqual(len({s["id"] for s in samples}), len(samples))
def test_fault_disabled_bad_value_and_enodata_do_not_lose_other_sensors(self):
chip = self.chip(0, "coretemp", [("temp" + str(i), "Core " + str(i), value)
for i, value in enumerate([65000, 60000, "bad", 500000, 61000, 63000, 0], 1)])
(chip / "temp1_fault").write_text("1")
(chip / "temp2_enable").write_text("0")
original = Path.read_text
def read(path, *args, **kwargs):
if path.name == "temp5_input":
raise OSError(errno.ENODATA, "No data available")
return original(path, *args, **kwargs)
with patch.object(Path, "read_text", read):
self.assertEqual([s["celsius"] for s in self.agent.temperatures()], [63.0, 0.0])
def test_amd_keeps_physical_die_readings_and_excludes_control_offsets(self):
self.chip(0, "k10temp", [("temp1", "Tctl", 85000), ("temp2", "Tdie", 65000), ("temp3", "Tccd1", 64000)])
self.assertEqual([s["label"] for s in self.agent.temperatures()], ["Tdie", "Tccd1"])
def test_sensor_identity_survives_hwmon_renumbering_and_separates_drives(self):
physical = self.chip(1, "nvme", [("temp1", "Composite", 50000)])
self.chip(2, "nvme", [("temp1", "Composite", 51000)])
before = {s["celsius"]: s["id"] for s in self.agent.temperatures()}
renamed = physical.with_name("hwmon9")
physical.rename(renamed)
(self.hwmon / "hwmon1").unlink()
(self.hwmon / "hwmon9").symlink_to(renamed, target_is_directory=True)
self.assertEqual(before, {s["celsius"]: s["id"] for s in self.agent.temperatures()})
self.assertEqual(len(set(before.values())), 2)
def test_missing_hwmon_and_disappearing_devices_are_optional(self):
self.assertEqual(self.agent.temperatures(), [])
(self.hwmon / "hwmon0").symlink_to(self.root / "missing")
self.assertEqual(self.agent.temperatures(), [])
with patch.object(Path, "glob", side_effect=PermissionError()):
self.assertEqual(self.agent.temperatures(), [])
def test_direct_hwmon_child_keeps_identity_when_renumbered(self):
physical = self.chip(1, "nvme", [("temp1", "Composite", 50000)])
direct = physical.parent.parent / "hwmon8"
physical.rename(direct)
(self.hwmon / "hwmon1").unlink()
(self.hwmon / "hwmon8").symlink_to(direct, target_is_directory=True)
before = self.agent.temperatures()[0]["id"]
renamed = direct.with_name("hwmon9")
direct.rename(renamed)
(self.hwmon / "hwmon8").unlink()
(self.hwmon / "hwmon9").symlink_to(renamed, target_is_directory=True)
self.assertEqual(self.agent.temperatures()[0]["id"], before)
def test_inventory_size_is_bounded(self):
self.chip(0, "coretemp", [("temp" + str(i), "Core " + str(i), 65000) for i in range(1, 200)])
self.assertEqual(len(self.agent.temperatures()), 128)
+241 -6
View File
@@ -5,7 +5,9 @@ import io
import json import json
import os import os
from pathlib import Path from pathlib import Path
import shutil
import stat import stat
import subprocess
import tempfile import tempfile
import time import time
import unittest import unittest
@@ -62,9 +64,16 @@ class UpdateTests(unittest.TestCase):
if directory and not path.is_dir() or not directory and not path.is_file(): if directory and not path.is_dir() or not directory and not path.is_file():
raise module.UpdateError("Wrong file type") raise module.UpdateError("Wrong file type")
return path return path
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl)]) self.public_key = b"repository public key"
key_patch = patch.object(module, "fetch_release_key", return_value=self.public_key)
self.fetch_key = key_patch.start()
self.addCleanup(key_patch.stop)
self.signature_patch = patch.object(module, "verify_release_signature")
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl), self.signature_patch])
for item in self.patches: for item in self.patches:
item.start() mocked = item.start()
if item is self.signature_patch:
self.signature_verifier = mocked
self.config = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"} self.config = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"}
self.config_bytes = json.dumps(self.config).encode() self.config_bytes = json.dumps(self.config).encode()
module.AGENT_CONFIG.write_bytes(self.config_bytes) module.AGENT_CONFIG.write_bytes(self.config_bytes)
@@ -78,7 +87,8 @@ class UpdateTests(unittest.TestCase):
self.write_health = True self.write_health = True
self.downloads = [] self.downloads = []
self.source = CANDIDATE self.source = CANDIDATE
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 1, "platform": "debian-amd64"} self.signature = b"detached signature"
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 2, "platform": "debian-amd64"}
def tearDown(self): def tearDown(self):
for item in reversed(self.patches): for item in reversed(self.patches):
@@ -108,6 +118,10 @@ class UpdateTests(unittest.TestCase):
self.downloads.append(path) self.downloads.append(path)
if path.endswith("manifest.json"): if path.endswith("manifest.json"):
return json.dumps(self.release).encode() return json.dumps(self.release).encode()
if path.endswith("manifest.json.sig"):
if isinstance(self.signature, Exception):
raise self.signature
return self.signature
if path.endswith("ec-agent.py"): if path.endswith("ec-agent.py"):
return self.source return self.source
self.fail("Unexpected download " + path) self.fail("Unexpected download " + path)
@@ -128,6 +142,7 @@ class UpdateTests(unittest.TestCase):
def test_verified_upgrade_waits_for_authenticated_candidate_health(self): def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
self.assertEqual(self.updater().run(), "updated") self.assertEqual(self.updater().run(), "updated")
self.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature, self.public_key)
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE) self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE) self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes) self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
@@ -137,6 +152,36 @@ class UpdateTests(unittest.TestCase):
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists()) self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE)) self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE))
def test_invalid_signature_rejects_attacker_source_and_matching_manifest_checksum(self):
self.source = b'VERSION = "1.1.0"\nraise RuntimeError("attacker code")\n'
self.release["sha256"] = module.sha256(self.source)
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
self.assertEqual(self.commands, [])
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
self.assertFalse(module.UPDATE_JOURNAL.exists())
self.assertFalse((module.UPDATE_DIR / "failed-release.json").exists())
self.assertNotIn("targetVersion", self.status())
def test_signature_is_verified_before_manifest_json_is_parsed(self):
updater = self.updater()
updater.fetch = lambda path, limit: b"untrusted invalid JSON" if path.endswith("manifest.json") else self.signature
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
with patch.object(module.json, "loads", side_effect=AssertionError("Parsed unsigned metadata")):
with self.assertRaisesRegex(module.IntegrityError, "Invalid release signature"):
updater.manifest()
self.signature_verifier.assert_called_once_with(b"untrusted invalid JSON", self.signature, self.public_key)
def test_missing_signature_never_downloads_code_or_stops_agent(self):
self.signature = module.ContactError("No signature is published")
self.assertEqual(self.updater().run(), "failed")
self.assertIn("signature", self.status()["error"])
self.assert_unchanged()
self.assertEqual(self.commands, [])
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
self.signature_verifier.assert_not_called()
def test_equal_or_older_versions_do_not_download_or_restart(self): def test_equal_or_older_versions_do_not_download_or_restart(self):
for target in ("1.0.0", "0.9.9"): for target in ("1.0.0", "0.9.9"):
with self.subTest(target=target): with self.subTest(target=target):
@@ -146,6 +191,32 @@ class UpdateTests(unittest.TestCase):
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads)) self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
self.assertEqual(self.commands, []) self.assertEqual(self.commands, [])
def test_every_check_fetches_key_before_controller_even_without_a_new_version(self):
self.release["version"] = "1.0.0"
keys = [b"first public key", b"replacement public key"]
self.fetch_key.side_effect = keys
updater = self.updater()
def controller_fetch(path, limit):
self.assertGreater(self.fetch_key.call_count, self.signature_verifier.call_count)
return self.fetch(path, limit)
updater.fetch = controller_fetch
for _ in keys:
self.assertEqual(updater.run(), "current")
self.assertEqual(self.fetch_key.call_count, 2)
self.assertEqual([call.args[2] for call in self.signature_verifier.call_args_list], keys)
self.assert_unchanged()
self.assertEqual(self.commands, [])
def test_repository_failure_never_uses_old_host_key_or_contacts_controller(self):
(self.config_dir / "release-signing.gpg").write_bytes(b"old trusted key")
self.fetch_key.side_effect = module.ContactError("Repository unavailable")
self.assertEqual(self.updater().run(), "deferred")
self.assert_unchanged()
self.assertEqual(self.downloads, [])
self.assertEqual(self.commands, [])
self.assertTrue(self.active)
self.signature_verifier.assert_not_called()
def test_busy_operation_defers_update_without_stopping_agent(self): def test_busy_operation_defers_update_without_stopping_agent(self):
with module.exclusive_lock(self.root / "agent" / "activity.lock"): with module.exclusive_lock(self.root / "agent" / "activity.lock"):
self.assertEqual(self.updater().run(), "deferred") self.assertEqual(self.updater().run(), "deferred")
@@ -207,7 +278,7 @@ class UpdateTests(unittest.TestCase):
self.downloads.clear() self.downloads.clear()
self.source = CANDIDATE self.source = CANDIDATE
self.assertEqual(self.updater().run(), "failed") self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"]) self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
def test_invalid_or_mismatched_python_version_is_never_installed(self): def test_invalid_or_mismatched_python_version_is_never_installed(self):
for source in (b'VERSION = "1.1.0"\ninvalid syntax ?\n', b'VERSION = "5.0.0"\n'): for source in (b'VERSION = "1.1.0"\ninvalid syntax ?\n', b'VERSION = "5.0.0"\n'):
@@ -220,7 +291,7 @@ class UpdateTests(unittest.TestCase):
def test_incompatible_protocol_platform_or_version_never_downloads_code(self): def test_incompatible_protocol_platform_or_version_never_downloads_code(self):
original = dict(self.release) original = dict(self.release)
for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 2)): for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 1), ("updaterProtocolVersion", 3)):
with self.subTest(key=key, value=value): with self.subTest(key=key, value=value):
self.release = {**original, key: value} self.release = {**original, key: value}
self.assertIn(self.updater().run(), ("failed", "deferred")) self.assertIn(self.updater().run(), ("failed", "deferred"))
@@ -239,7 +310,7 @@ class UpdateTests(unittest.TestCase):
self.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2) self.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2)
self.downloads.clear() self.downloads.clear()
self.assertEqual(self.updater().run(), "failed") self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"]) self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
def test_changed_artifact_of_failed_target_can_be_retried(self): def test_changed_artifact_of_failed_target_can_be_retried(self):
updater = self.updater() updater = self.updater()
@@ -266,9 +337,15 @@ class UpdateTests(unittest.TestCase):
def test_crash_between_executable_replacements_recovers_from_backups(self): def test_crash_between_executable_replacements_recovers_from_backups(self):
self.crash_during_second_replacement() self.crash_during_second_replacement()
self.downloads.clear() self.downloads.clear()
self.signature_verifier.reset_mock()
self.fetch_key.reset_mock()
self.fetch_key.side_effect = module.ContactError("Repository offline during recovery")
self.signature_verifier.side_effect = module.IntegrityError("Signing keyring unavailable during recovery")
self.assertEqual(self.updater().run(), "failed") self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged() self.assert_unchanged()
self.assertEqual(self.downloads, []) self.assertEqual(self.downloads, [])
self.signature_verifier.assert_not_called()
self.fetch_key.assert_not_called()
self.assertTrue(self.active) self.assertTrue(self.active)
self.assertFalse(module.UPDATE_JOURNAL.exists()) self.assertFalse(module.UPDATE_JOURNAL.exists())
@@ -414,5 +491,163 @@ class UpdateSecurityTests(unittest.TestCase):
module.version(value) module.version(value)
class RepositoryKeyDownloadTests(unittest.TestCase):
def test_fixed_https_url_no_credentials_no_cache_and_no_redirects(self):
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"public keys")
self.assertEqual(module.fetch_release_key(), b"public keys")
request = opener.return_value.open.call_args.args[0]
self.assertEqual(request.full_url, "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg")
self.assertIsNone(request.get_header("Authorization"))
self.assertIsNone(request.get_header("Cookie"))
self.assertEqual(request.get_header("Cache-control"), "no-cache")
self.assertEqual(opener.return_value.open.call_args.kwargs["timeout"], 25)
redirect = opener.call_args.args[0]
for destination in ("https://attacker.example/key.gpg", "http://gitea.thewrightserver.net/key.gpg"):
with self.subTest(destination=destination), self.assertRaises(module.ContactError):
redirect.redirect_request(None, None, 302, "redirect", {}, destination)
def test_missing_empty_oversized_and_truncated_keys_fail_closed(self):
cases = [Response(b""), Response(b"x", {"Content-Length": str(module.MAX_KEYRING + 1)}),
Response(b"x" * (module.MAX_KEYRING + 1)),
Response(b"partial", {"Content-Length": "100"}),
Response(b"key", {"Content-Length": "invalid"})]
for status in (301, 304, 404, 500):
response = Response(b"public key")
response.status = status
cases.append(response)
for response in cases:
with self.subTest(status=response.status, headers=response.headers), patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = response
with self.assertRaises(module.UpdateError):
module.fetch_release_key()
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.side_effect = OSError("TLS or network failure")
with self.assertRaisesRegex(module.ContactError, "release-signing repository is unavailable"):
module.fetch_release_key()
def test_trickling_repository_download_has_a_deadline(self):
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"public key")
with patch.object(module.time, "monotonic", side_effect=[0, 0, 121]):
with self.assertRaisesRegex(module.ContactError, "timed out"):
module.fetch_release_key()
class SignatureVerificationFixture:
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.base = Path(self.temporary.name)
self.keyring = self.base / "release-signing.gpg"
self.keyring.write_bytes(b"trusted host public keys")
self.update_dir = self.base / "update"
self.update_dir.mkdir(mode=0o700)
entry = patch.object(module, "UPDATE_DIR", self.update_dir)
entry.start()
self.addCleanup(entry.stop)
# Test fixtures are owned by the test runner rather than system root.
# Root ownership and unsafe modes are checked independently above.
entry = patch.object(module, "trusted", side_effect=lambda path, **_: Path(path))
self.trusted = entry.start()
self.addCleanup(entry.stop)
class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
def test_gpgv_uses_only_downloaded_keyring_and_exact_bytes_with_an_isolated_home(self):
manifest = b'{"version": "1.2.3"}\n'
signature = b"binary detached signature\x00\xff"
def verify(command, **kwargs):
home = Path(command[2])
self.assertEqual(command[:2], ["gpgv", "--homedir"])
self.assertEqual(command[3:5], ["--keyring", str(home / "release-signing.gpg")])
self.assertEqual(Path(command[4]).read_bytes(), self.keyring.read_bytes())
self.assertEqual(home.parent, self.update_dir)
self.assertEqual(stat.S_IMODE(home.stat().st_mode), 0o700)
self.assertEqual(Path(command[5]).read_bytes(), signature)
self.assertEqual(Path(command[6]).read_bytes(), manifest)
self.assertEqual(kwargs["timeout"], 30)
self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
return subprocess.CompletedProcess(command, 0)
with patch.object(module.subprocess, "run", side_effect=verify) as run:
module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
run.assert_called_once()
self.assertEqual(self.trusted.call_args_list[0].args, (self.update_dir,))
self.assertEqual(list(self.update_dir.iterdir()), [])
def test_empty_or_oversized_keyring_or_missing_signature_never_runs_a_verifier(self):
with patch.object(module.subprocess, "run") as run:
for key in (b"", b"x" * (module.MAX_KEYRING + 1)):
with self.subTest(size=len(key)), self.assertRaisesRegex(module.IntegrityError, "keyring"):
module.verify_release_signature(b"manifest", b"signature", key)
with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"):
module.verify_release_signature(b"manifest", b"", self.keyring.read_bytes())
run.assert_not_called()
def test_bad_unknown_or_unavailable_verifier_fails_closed_and_cleans_temporary_files(self):
for result in (subprocess.CompletedProcess([], 1), subprocess.CompletedProcess([], 2), FileNotFoundError("gpgv missing"), subprocess.TimeoutExpired("gpgv", 30)):
with self.subTest(result=result):
kwargs = {"side_effect": result} if isinstance(result, Exception) else {"return_value": result}
with patch.object(module.subprocess, "run", **kwargs):
with self.assertRaises(module.IntegrityError):
module.verify_release_signature(b"manifest", b"signature", self.keyring.read_bytes())
self.assertEqual(list(self.update_dir.iterdir()), [])
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG signing and verification tools are required")
class RealSignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.keys = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.keys.cleanup)
cls.homes = []
for index in range(2):
home = Path(cls.keys.name) / str(index)
home.mkdir(mode=0o700)
cls.homes.append(home)
subprocess.run(["gpg", "--homedir", str(home), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", f"Release Test {index} <release-{index}@example.test>", "ed25519", "sign", "0"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if shutil.which("gpgconf"):
cls.addClassCleanup(subprocess.run, ["gpgconf", "--homedir", str(home), "--kill", "gpg-agent"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
cls.public_key = subprocess.run(["gpg", "--homedir", str(cls.homes[0]), "--batch", "--export"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
def sign(self, manifest, index=0):
return subprocess.run(["gpg", "--homedir", str(self.homes[index]), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--digest-algo", "SHA512", "--detach-sign"], input=manifest, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True).stdout
def test_repository_key_rotation_removes_old_signer_on_the_next_check(self):
replacement = subprocess.run(["gpg", "--homedir", str(self.homes[1]), "--batch", "--export"],
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
manifest = json.dumps({"version": "1.1.0", "sha256": module.sha256(CANDIDATE),
"protocolVersion": 1, "updaterProtocolVersion": 2,
"platform": "debian-amd64",
"keyUrl": "https://controller.example/attacker-key"}).encode()
old_signature, new_signature = self.sign(manifest), self.sign(manifest, 1)
updater = module.Updater()
updater.installation = {"controller": "https://controller.example"}
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.side_effect = [Response(value) for value in (
self.public_key, manifest, old_signature,
replacement, manifest, old_signature,
replacement, manifest, new_signature)]
self.assertEqual(updater.manifest()["version"], "1.1.0")
with self.assertRaisesRegex(module.IntegrityError, "not signed by a trusted release key"):
updater.manifest()
self.assertEqual(updater.manifest()["version"], "1.1.0")
urls = [call.args[0].full_url for call in opener.return_value.open.call_args_list]
self.assertEqual(urls[::3], [module.RELEASE_KEY_URL] * 3)
self.assertNotIn("https://controller.example/attacker-key", urls)
self.assertEqual(list(self.update_dir.iterdir()), [])
def test_real_signatures_authenticate_exact_manifest_and_reject_unknown_keys(self):
self.keyring.write_bytes(self.public_key)
manifest = b'{"version":"1.1.0","sha256":"trusted-code-checksum"}\n'
signature = self.sign(manifest)
module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
for changed_manifest, changed_signature in ((manifest + b" ", signature), (manifest.replace(b"trusted-code-checksum", b"attacker-code-checksum"), signature), (manifest, signature[:-1]), (manifest, self.sign(manifest, 1))):
with self.subTest(manifest=changed_manifest, signature=changed_signature):
with self.assertRaises(module.IntegrityError):
module.verify_release_signature(changed_manifest, changed_signature, self.keyring.read_bytes())
self.assertEqual(list(self.update_dir.iterdir()), [])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+7
View File
@@ -15,7 +15,14 @@ services:
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000} ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-} EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-} EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_DISCORD_APPLICATION_ID: ${EC_DISCORD_APPLICATION_ID:-}
EC_DISCORD_PUBLIC_KEY: ${EC_DISCORD_PUBLIC_KEY:-}
EC_DISCORD_BOT_TOKEN: ${EC_DISCORD_BOT_TOKEN:-}
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false} EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
# Enable only behind a trusted proxy that sets the client-address header.
ADDRESS_HEADER: ${ADDRESS_HEADER:-}
XFF_DEPTH: ${XFF_DEPTH:-1}
# Legacy host imports stream whole game files; buffering routes enforce their own caps. # Legacy host imports stream whole game files; buffering routes enforce their own caps.
BODY_SIZE_LIMIT: 'Infinity' BODY_SIZE_LIMIT: 'Infinity'
DB_PATH: /data/cherry.db DB_PATH: /data/cherry.db
+4
View File
@@ -11,6 +11,7 @@ and launch your first server.
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery. - [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery. - [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup. - [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
- [Discord companion](guides/discord.md) — live server boards, private browsing, leaderboards, and availability alerts.
### Fleet operations ### Fleet operations
@@ -25,6 +26,9 @@ and launch your first server.
- [Beta test plans](guides/beta-test-plans.md) — game reporters, test requirements, coverage, and outcomes. - [Beta test plans](guides/beta-test-plans.md) — game reporters, test requirements, coverage, and outcomes.
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports. - [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments. - [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
- [AFK reserve](guides/afk.md) — persistent break time, gameplay rewards, purchases and session 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. - [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API.
## Development ## Development
+43 -6
View File
@@ -2,7 +2,8 @@
The controller uses SvelteKit, TypeScript and SQLite (`better-sqlite3`), with the The controller uses SvelteKit, TypeScript and SQLite (`better-sqlite3`), with the
Node adapter and server-sent events. The host agent uses Python 3's standard Node adapter and server-sent events. The host agent uses Python 3's standard
library and runs as a systemd service; hosts do not need Node.js. library and runs as a systemd service; release verification calls `gpgv`.
Hosts do not need Node.js.
Agents connect outbound to the controller over HTTPS and manage Docker/Plutainer Agents connect outbound to the controller over HTTPS and manage Docker/Plutainer
and local RCON on each host. The controller owns fleet metadata and queues; it and local RCON on each host. The controller owns fleet metadata and queues; it
@@ -10,7 +11,9 @@ does not require a Docker socket or direct access to host filesystems.
## Run locally ## Run locally
Use Node.js 22.13+ (the container uses Node.js 22), npm and Python 3. Use Node.js 22.13+ (the container uses Node.js 22), npm and Python 3. Install
GnuPG's `gpg`, `gpg-agent` and `gpgv` to run real release-signing integration tests; local test
runs skip those tests when either executable is absent.
```sh ```sh
npm ci npm ci
@@ -57,7 +60,7 @@ snapshots naturally show stale observations when viewed against today's clock.
The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match
records: 494 historical and 6 live. It includes solo through four-player squads, records: 494 historical and 6 live. It includes solo through four-player squads,
unknown starting counts, enough players and matches to exercise pagination, unknown starting counts, enough players and matches to exercise pagination,
rosters, seven days of utilization and capacity samples, and retained Activity rosters, seven days of occupancy samples with unknown player limits, and retained Activity
from campaigns, betas, host operations, maintenance, recovery, and autoscaling. from campaigns, betas, host operations, maintenance, recovery, and autoscaling.
The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems: The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems:
a stopped server, a server not responding, and a server on the offline host. a stopped server, a server not responding, and a server on the offline host.
@@ -89,8 +92,10 @@ npm run build
The [Gitea Actions workflow](../../.gitea/workflows/ci.yml) runs these checks on The [Gitea Actions workflow](../../.gitea/workflows/ci.yml) runs these checks on
every push and pull request after `npm ci`. It uses a Node.js 22 Debian container every push and pull request after `npm ci`. It uses a Node.js 22 Debian container
with Python 3 and native build tools for `better-sqlite3`. The runner must support with Python 3, `gpg`, `gpg-agent`, `gpgv` and native build tools for `better-sqlite3`. Signing
Docker jobs with the `ubuntu-latest` label. `npm run check` runs before the tests tests create disposable keys in temporary directories; CI never receives release
private keys. The runner must support Docker jobs with the `ubuntu-latest` label.
`npm run check` runs before the tests
to generate the SvelteKit configuration needed in a fresh checkout. Application to generate the SvelteKit configuration needed in a fresh checkout. Application
tests use two workers in CI to bound resource use for database-heavy fixtures. tests use two workers in CI to bound resource use for database-heavy fixtures.
The build step runs the same `npm run build` command used by the Dockerfile, The build step runs the same `npm run build` command used by the Dockerfile,
@@ -118,7 +123,23 @@ Dependency security checks must include development dependencies: use `npm audit
without `--omit=dev`. without `--omit=dev`.
`npm run dev` and `npm run build` regenerate the downloadable agent, installer, `npm run dev` and `npm run build` regenerate the downloadable agent, installer,
standalone updater and checksum manifest. Builds also bundle report workers standalone updater and checksum manifest. Packaging preserves an existing
`static/agent/manifest.json.sig` only when its manifest bytes remain identical;
changes to the agent, updater or installer remove the stale signature. Builds
never sign, and hosts reject unsigned releases while retaining installed code.
Finalize and review release code, run `npm run agent:package`, and explicitly
sign it on the offline publisher machine:
```sh
python3 agent/sign_release.py --gnupghome /secure/electric-cherry-signing --fingerprint FULL_SIGNING_KEY_FINGERPRINT
```
Commit or distribute the signed public artifacts before building the controller
image from the same source revision. Keep the signing home outside the repository, Docker context, controller
and CI. Follow [Publishing signed releases](../guides/native-hosts.md#publishing-signed-releases)
for key creation, host provisioning and rotation.
Builds also bundle report workers
under `build/reports` and launch preparation under `build/launch`. Development under `build/reports` and launch preparation under `build/launch`. Development
and test commands compile those workers automatically. Restart `npm run dev` and test commands compile those workers automatically. Restart `npm run dev`
after changing agent or worker code, or run `npm run agent:package` to refresh after changing agent or worker code, or run `npm run agent:package` to refresh
@@ -199,6 +220,22 @@ retained across restarts; interrupted transactions roll back and retry on startu
The one-time vacuum needs disk space for a temporary database copy. **Do not The one-time vacuum needs disk space for a temporary database copy. **Do not
remove `cherry.db-wal` to recover from an interrupted upgrade.** remove `cherry.db-wal` to recover from an interrupted upgrade.**
Database upgrades are ordered in `src/lib/server/db/migrations.ts` and tracked by
SQLite's `PRAGMA user_version`. Startup applies only pending versions, including
host, authentication and game-file schemas, before serving requests or starting
workers. Databases from older releases begin at version 0; the compatibility
steps honor their existing migration markers so completed backfills are preserved.
An application cannot open a database with a newer version than it supports.
For a schema or data upgrade, append a consecutive version to that list. Do not
edit an applied migration or rely on changing `schema.sql` to upgrade databases.
Each step and its version commit in one transaction. Table rebuilds can select
`foreign-keys-off`, which disables enforcement before the transaction and checks
references before committing. The resumable incremental-vacuum step uses
`autocommit` because SQLite forbids `VACUUM` inside a transaction; its version is
recorded only after the rebuild and host preparation boundary recovery succeed.
Connection tuning and query optimization still run on every open.
The controller uses a 128 MiB page cache; each reader worker uses 32 MiB. The controller uses a 128 MiB page cache; each reader worker uses 32 MiB.
Main-database migrations use 16 MiB with memory mapping disabled. Temporary Main-database migrations use 16 MiB with memory mapping disabled. Temporary
storage stays disk-backed. Containers use `/data/.sqlite-tmp` for scratch files; storage stays disk-backed. Containers use `/data/.sqlite-tmp` for scratch files;
+35 -1
View File
@@ -8,7 +8,18 @@ Primitives live in its `primitives` directory.
- Use `SectionHeader` for page titles and primary actions. The page owns spacing - Use `SectionHeader` for page titles and primary actions. The page owns spacing
between sections. between sections.
- Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors. - Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors.
Give controls distinct `ariaLabel` values when several share one field. Give controls distinct `ariaLabel` values when several share one field. Use
`hiddenLabel` on `Field` for compact filters with a visually hidden label.
`Input` and `Select` forward native validation, accessibility and event props;
use `bind:element` when a workflow needs to focus the native control. `Select`
accepts either `options` or child `option` / `optgroup` elements and preserves
the bound value's type. Keep layout on the field or its parent; controls own
their appearance.
- `npm run check:styles` rejects raw selects and text-like inputs outside their
primitives, including search, password, numeric and date/time fields. Native
checkboxes, radios, file pickers, hidden inputs, range/color pickers and input
buttons are allowed. The check parses Svelte markup, so comments and examples
in scripts are ignored.
- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists. - Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists.
- Use `StatusBadge` for workflow states and `ServerIdentity` for server names. - Use `StatusBadge` for workflow states and `ServerIdentity` for server names.
Keep raw IDs available for copying and troubleshooting. Keep raw IDs available for copying and troubleshooting.
@@ -39,6 +50,29 @@ and action targets. Avoid nested buttons or placing a button inside a link.
Allow long server identities to wrap and tab/filter rows to scroll on narrow Allow long server identities to wrap and tab/filter rows to scroll on narrow
screens. screens.
### Responsive widths
Use the shared breakpoint scale in `src/lib/ui/styles/tokens.css`:
| Token | Width | Use |
| ----------------- | ------- | ------------------------------------------ |
| `--breakpoint-xs` | 360 px | Extra-small screen adjustments |
| `--breakpoint-sm` | 640 px | Compact controls, cards and page summaries |
| `--breakpoint-md` | 960 px | Sidebar switches to the navigation drawer |
| `--breakpoint-lg` | 1100 px | Dense, multi-column layouts reflow |
CSS custom properties cannot be used inside media conditions. Write the literal
value, for example `@media (max-width: 640px)`, and choose the shared width that
fits the layout. Related page and component changes should use the same width.
For JavaScript behavior, use the identical query and invert `matches` when you
need its complement; avoid adding a pixel to the boundary.
`npm run check:styles` reads this scale and rejects other widths in Svelte and
CSS `@media` queries. The file-and-width allowlist in `scripts/check-ui-styles.mjs`
records existing layouts awaiting migration. Remove entries as those layouts
move to the shared scale; do not add exceptions for new components. The app
shell, Servers page and server cards use only shared widths.
## Navigation ## Navigation
Use the shared navigation metadata for Fleet, Releases, Activity, Players, and Use the shared navigation metadata for Fleet, Releases, Activity, Players, and
+144
View File
@@ -0,0 +1,144 @@
# Persistent AFK time
AFK time is a GUID account shared by T5 and T6, separate from the points bank. The scripts own `.afk`, `.back`, explicit purchase confirmation, actual gameplay contribution detection, protection and controls, the center-screen countdown, and all-player-AFK / match-ending behavior. Electric Cherry owns policy, durable receipts, atomic points purchases, reservations, and reporting. The immutable native instance's existing bank credential authenticates both APIs; the shared telemetry token and browser sessions cannot authorize game requests.
## Policy and accounting
All balances are integer seconds. Defaults:
| Setting | Environment variable | Default |
| ---------------------------------------------- | ---------------------------------------------------------------- | ----------- |
| Free rolling allowance | `EC_AFK_FREE_SECONDS` | 1800 |
| Combined earned/purchased capacity | `EC_AFK_CAPACITY_SECONDS` | 21600 |
| Active play interval / reward | `EC_AFK_ACTIVE_INTERVAL_SECONDS`, `EC_AFK_ACTIVE_REWARD_SECONDS` | 300 / 60 |
| Participating round reward | `EC_AFK_ROUND_REWARD_SECONDS` | 15 |
| Kill interval / reward | `EC_AFK_KILL_INTERVAL`, `EC_AFK_KILL_REWARD_SECONDS` | 100 / 30 |
| Headshot interval / reward | `EC_AFK_HEADSHOT_INTERVAL`, `EC_AFK_HEADSHOT_REWARD_SECONDS` | 25 / 15 |
| Successful teammate revive | `EC_AFK_REVIVE_REWARD_SECONDS` | 30 |
| Performance reward cap per rolling active hour | `EC_AFK_BONUS_HOURLY_SECONDS` | 480 |
| Points / seconds per purchase pack | `EC_AFK_PURCHASE_POINTS`, `EC_AFK_PURCHASE_SECONDS` | 10000 / 600 |
Free time is spent first. Its available amount is the allowance minus outstanding free reservations and confirmed free consumption charged within the preceding 24 hours. Consumption is charged at the controller's receipt of a durable stop, so free time replenishes 24 hours after that receipt. This deliberately conservative rolling window cannot grant more than the allowance in a rolling day, even with delayed delivery or inaccurate server clocks. Each charge expires independently; unused days never accumulate. Reads, reconnects, map changes, and changing game/server do not reset anything. An unclosed reservation never expires, even after 24 hours. Profile `freeRenewsAt` is the next known charge expiration, not a promise to release unresolved authorization.
Active seconds and partial kill/headshot thresholds persist by GUID across matches and servers. Cumulative snapshots contribute only their increase over the last accepted snapshot for that originating immutable instance, match key, and GUID. Active play rewards use every completed interval. Participating rounds, kill thresholds, and headshot thresholds share one rolling 3600-_active-second_ budget: each performance grant occupies budget until another 3600 verified active seconds elapses. Kill and headshot rewards can stack. Spectating, AFK, idle connections, wall-clock waiting, and raw score increases do not advance this clock. Each legitimate successful teammate revive earns the full configured revive reward, including repeated revives of the same teammate; there is no revive count limit or performance cap. Only overall saved capacity applies.
Capacity includes saved seconds currently reserved by sessions. Overflow rewards and performance bonuses above the rolling budget are discarded, while partial threshold progress persists; capacity does not bank deferred whole rewards. The scripts report only actual gameplay events, eligible active seconds, and rounds with participation. The backend requires `activity:"playing"`, rejects current reward submissions while a session is reserved or started (an unresolved, stopped crash may resume gameplay), enforces monotonic counters, and rejects cumulative active time above the match's wall duration plus a 60-second telemetry tolerance. This scoped trusted-server API cannot independently observe a game engine's successful revive or verify its eligibility. The native/GSC collection paths are part of the trust boundary.
## Machine protocol
Use `POST /api/afk/{balance,event,purchase,reserve,start,settle}` with `Content-Type: application/json` and `Authorization: Bearer <instance bank credential>`. The body limit is 4096 bytes; responses have `Cache-Control: no-store`. Purchases, reservations, start acknowledgments, and balance reads require the exact current active telemetry match and a present GUID on the authenticated server. Gameplay backlog can additionally reference a retained historical match containing that GUID when the immutable instance lifetime proves origin. Historical active time is bounded to the recorded match end or last received telemetry, and its grants still respect held saved capacity. Gameplay whose historical membership evidence has been removed is rejected without granting time. Settlement remains valid for the original instance after disconnect, match completion, or match/server history deletion.
All requests contain `server`, `guid`, and `match` (the existing telemetry `reporter_match_key`, not a separate AFK match identifier). Mutations additionally require `request_id`. GUID, match, request and session IDs are 1–64 characters from `[A-Za-z0-9_.:-]`. Optional `event_time` is a parseable timestamp no more than five minutes in the future; it records the reported event time, while `created_at` records controller receipt time. Keep the complete body immutable across retries, including `event_time`.
| Action | Additional request fields |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `balance` | None |
| `event` | `seq` positive integer; `active_seconds`, `kills`, `headshots`, `rounds`, `revives` nonnegative cumulative integer counters (maximum 2147483647); `activity:"playing"` |
| `purchase` | `packs` integer 1–1000; `confirmed:true`; `expected_purchase_points` and `expected_purchase_seconds`, copied from the displayed quote, per pack |
| `reserve` | `session_id`; `seconds` integer 1–604800, the requested maximum authorization |
| `start` | `session_id` |
| `settle` | `session_id`; `consumed_seconds` integer 0–604800 or `null`; `end_reason`; optional `confirmed_seconds` checkpoint |
`end_reason` is one of `manual`, `disconnect`, `death`, `match_end`, `expired`, `never_started`, or `crash`. `expired` requires exact consumption of the authorization. `never_started` requires zero. An unknown crash duration uses `consumed_seconds:null,end_reason:"crash",confirmed_seconds:<last durable elapsed lower bound>`. Confirmed consumption must fit the original authorization and cannot fall below an already-recorded checkpoint.
Every application result has:
```json
{
"ok": true,
"state": "reserved",
"code": "ok",
"request_id": "request-123",
"free_seconds": 0,
"saved_seconds": 600,
"available_seconds": 600,
"reserved_seconds": 1800,
"capacity_seconds": 21600,
"authorized_seconds": 1800,
"bank_balance": 20000,
"purchase_points": 10000,
"purchase_seconds": 600,
"max_purchase_packs": 2,
"session_id": "session-123",
"consumed_seconds": null
}
```
`available_seconds` excludes held authorizations; `reserved_seconds` is held time for this GUID across all servers. `authorized_seconds` is meaningful for session results and is the original authorization, never an extension. `session_id` and `consumed_seconds` appear on session results; `awarded_seconds` appears on reward and purchase results. `max_purchase_packs` is the smaller of affordable packs and capacity, or zero when a bank transfer is prepared. Recheck the quote through `balance` before confirmation; changed pricing rejects with `price_changed` without spending points. There is no automatic purchase.
States are `balance`, `rewarded`, `purchased`, `reserved`, `started`, `settled`, `unresolved`, `corrected`, and `rejected`. HTTP 200 can contain an application rejection (`ok:false`), such as `empty`, `session_pending`, `bank_pending`, `insufficient_balance`, `reserve_capacity`, `price_changed`, `invalid_active_time`, `invalid_consumption`, `progress_regression`, or `progress_conflict`. Authentication failures are 401/403; invalid syntax/amounts are 400. `not_in_match` is a nonreceipted HTTP 409 for unknown/current membership publication lag on new work, allowing the same immutable request to retry after telemetry catches up. Known completed/obsolete matches and recorded players who have departed (`present=0`) are terminal HTTP 200 rejections for new work. Only never-observed membership remains eligible for the publication-lag retry. Gameplay missing historical membership/origin proof is also a terminal HTTP 200 rejection so an unrecoverable old snapshot cannot block the durable queue; subsequent cumulative snapshots can still include its counters if membership is established. Changed request identity or ownership is HTTP 409 `request_conflict`/`session_conflict`. Transaction failures are 503 and can retry unchanged. Early authentication/body failures may contain only `ok` and `code`.
Purchase debits the existing bank account and grants saved time in the same immediate SQLite transaction, with linked point/time ledger entries. A prepared bank transfer blocks purchase until its own existing commit/cancel workflow completes. No second points bank is created.
## Ordering, authorization, and outages
1. Native code durably writes the immutable request before sending it. Use one stable request ID per operation, with separate IDs for reserve, start, and settle. Stable session IDs bind all session operations.
2. `reserve` atomically takes free time first and saved time second, authorizing `min(requested,available)` seconds. Only one outstanding session can hold a GUID's funds. An identical retry returns the saved result. A new operation referring to an already-known session cannot reset or extend it.
3. Durable native state records the reservation and the start intent. `start` must return the `started` acknowledgment before protection is enabled. Scripts additionally recheck current player/map eligibility and activate only once. A delayed acknowledgment cannot reactivate an ended local session. The countdown uses the locally recorded authorization and elapsed clock; it does not require heartbeat renewal.
4. Manual return, disconnect, death, match end, or zero countdown removes protection and restores controls before producing a durable stop. Native code records the confirmed elapsed seconds and immutable `settle` request, then retries delivery until acknowledged. Round elapsed time up to whole seconds and cap it to authorization. At zero the scripts immediately restore normal vulnerability and controls; there is no grace, automatic buy, or spectator transition. Manual `.back` retains its separate gameplay behavior.
5. `settle` records confirmed consumption and releases unused reserved seconds exactly once. It accepts legitimate delayed reports after membership disappears. `never_started` settlement can arrive before its reserve and installs a tombstone; delayed reserve/start cannot reopen that session. Completed/corrected sessions remain terminal. A retry of an earlier operation returns that operation's original receipt, which may predate settlement; native terminal journal state must win over such an old response.
6. If the controller is unavailable, an already-started session keeps its locally authorized countdown to zero. If reserve/start was never acknowledged, do not enable protection. A crashed process must first be proven stopped before recovery. A checkpoint is only a lower bound on elapsed time; an absent heartbeat or old checkpoint is never evidence for a refund. Recover unknown elapsed time as `unresolved`, retaining authorization until reliable stop evidence or an explicit administrator correction resolves it.
Receipts bind the complete body and action to server, immutable instance, and request ID. Reusing an ID with any changed parameter is a conflict. Accepted same/lower-sequence gameplay counters award no additional time; same sequence with changed counters conflicts, a later sequence with a decreased total is rejected, and a lower sequence cannot introduce a higher counter. Native/GSC code must preserve cumulative per-GUID totals across reconnects within the same match. A distinct revive increments `revives` even for the same teammate; retrying a snapshot never increments it.
For host teardown, `POST /api/agent/afk/settle` uses the assigned host credential and `{operationId,action,transfer}`. Only an unfinished `instance.remove`, `instance.rebuild`, or `instance.cancel-create` for that host/immutable instance is accepted. `action:"settle"` reconciles session evidence; `event` and `purchase` are receipt-only recovery and return `not_applied` if the original request never committed. Teardown cannot create a new purchase or reward. Instance retirement revokes normal game API credentials, including reads; the assigned host recovery path permits settlement after retirement without granting a replacement instance ownership of old sessions.
## Crash correction and reporting
An administrator can reconcile an explicitly stopped `unresolved` session with `POST /api/afk/corrections` using their browser session and same-origin protections:
```json
{
"session_id": "session-123",
"request_id": "admin-case-456",
"consumed_seconds": 120,
"reason": "Confirmed process stopped; reviewed native checkpoint and host logs.",
"stopped": true
}
```
The selected charge must be at least the confirmed checkpoint and no more than authorization. Correction cannot release a live reserved/started session, and does not convert an uncertain duration into an exact one: `state` becomes `corrected`, `consumedSeconds` remains null, `chargedSeconds` records the accounting decision, and `confirmedSeconds` retains the evidence lower bound. The audit ledger records the actor, reason, charge, and release. The correction ID is idempotent and rejects changed parameters. A true confirmed stop report can instead settle an unresolved session with its verified exact duration.
Open **Players → AFK** (`/afk`) to browse all AFK accounts, sessions, and time-ledger entries. The accounts view supports player-name and exact-GUID searches and links each account to its player profile. Session history includes unresolved reservations; session and ledger history can be filtered by game, server, and date. Filters and pagination remain in the URL when opening a player or retained match, so the back link restores the same overview. Refresh reads current balances without issuing grants, releasing reservations, or otherwise changing accounts. This reporting page has no purchase or correction controls.
The AFK overview and player profiles use the same signed-in viewer access as Bank. Player profiles also retain balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion.
## Coordinated rollout and validation
Back up the controller database and persistent game journals. Deploy EC first;
migration 30 creates the AFK accounts, operations, progress, sessions, bonus budget
and ledger tables without match/server cascading deletion. Install host agent
**1.12.0+**, including its `afk.settlement` capability, before activating the game
scripts. Package and offline-sign the agent release using the normal host update
workflow; a local build does not sign or deploy it.
Install TWS AFK scripts **2.0.0** with matching native plugins **1.4.0+** for both
engines, common **1.4.0+**, telemetry **1.0.0+**, and Plutonium **r5334+**. Fully
restart each game-server process for the plugin update. Keep the existing private
bank credential configuration and persistent `tws-afk/t5/` / `tws-afk/t6/` storage.
See the [scripts AFK guide](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/src/branch/main/docs/afk.md)
for commands, active-play criteria, protection ownership, game dvars and the
in-game validation matrix.
Run `npx vitest run tests/unit/afk.test.ts tests/unit/bank.test.ts tests/unit/ui-afk-profile.test.ts`,
the normal EC checks/build, and `python3 -m unittest discover -s agent/tests -v`.
The scripts repository supplies `node tools/check_afk.mjs` and
`python3 tools/test_plugin_afk.py` with the pinned native JSON dependency headers.
Portable native tests use sanitizers and engine adapters; script tests use engine
shims. Neither proves Windows DLL linking or live game behavior.
Before production rollout, exercise both T5 and T6 with a short test allowance:
manual return, immediate zero-time expiry, downed/dead/spectator states, `.hud`
hidden, repeated revives, partial reward thresholds across reconnects, explicit
purchase confirmation and capacity rejection. Compare the center countdown to
the authorized seconds, ensure controls/weapons and normal vulnerability return
at zero, and confirm active-play metrics exclude AFK time (manual `.back` restores reward eligibility immediately, including during its separate protection grace). Test last-active
death/disconnect and stock afterlife/Grief recovery on deployed maps.
Drop reserve/start/purchase/settle responses, reorder deliveries, disconnect EC
after authorization and restart game/controller/host processes. Confirm the local
deadline survives the outage, durable stops release unused time once, and ambiguous
crashes remain unconfirmed until an audited correction. Rebuild/remove an instance
with both games' journals and a retired game credential; host recovery must retain
files until acknowledged and must never create a new purchase or gameplay reward.
Live T5/T6 gameplay and deployment validation require the actual game servers.
+101 -28
View File
@@ -31,16 +31,30 @@ guessable token. An unset setup token holds setup closed.
## Roles ## Roles
| Capability | Viewer | Operator | Administrator | The Moderator role has Viewer-level fleet access plus private player moderation.
| --------------------------------------------------------------------------------------------- | -------------- | ----------- | ------------- | It permits live player actions, case review and server/game restrictions lasting
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | up to seven days, without granting fleet operations or access to raw configs and
| Raw configs, file diffs, logs, RCON and telemetry diagnostics | No | Yes | Yes | credentials. Administrators also control permanent/fleet restrictions, integration
| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes | configuration and revoking another moderator's restriction. Super Viewers can
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage | read moderation; Operators and Viewers cannot. See [Player moderation](moderation.md).
| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage |
| Server keys and global settings/maintenance policies | No | No | Manage | | Capability | Viewer | Super Viewer | Operator | Administrator |
| Create users, change roles, disable accounts and reset passwords | No | No | Yes | | --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
| Own password and sign out | Yes | Yes | Yes | | Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
| Raw configs, file diffs, logs, saved RCON and telemetry diagnostics | No | Read | Read | Read |
| Deploy/start/stop/restart/rebuild/remove servers and run diagnostic probes | No | No | Yes | Yes |
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Read | Manage | Manage |
| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Read status | Manage |
| Server keys and global settings/maintenance policies | No | Read | No | Manage |
| Users and account audit activity | No | Read | No | Manage |
| Create users, change roles, disable accounts and reset passwords | No | No | No | Yes |
| Own password and sign out | Yes | Yes | Yes | Yes |
Super Viewer is a read-only administrator. It includes the Admin navigation and
access to sensitive configuration and diagnostic content, but cannot change fleet
state, settings, keys, or other accounts. Cleanup previews remain available because
they do not save settings or run cleanup. Assign it only to people trusted to read
administrative information. Existing accounts and sessions are preserved on upgrade.
Operators can launch using available game-pool keys already saved by an administrator. They Operators can launch using available game-pool keys already saved by an administrator. They
cannot save keys indirectly through a launch request. An operator's content cannot save keys indirectly through a launch request. An operator's content
@@ -50,9 +64,9 @@ people trusted to operate the game fleet.
Drift previews (`/api/drift`) and GitHub release lookups Drift previews (`/api/drift`) and GitHub release lookups
(`/api/plugins/github/release`) require operator or administrator access, including (`/api/plugins/github/release`) require operator or administrator access, including
`GET` and `HEAD` requests. These endpoints initiate fleet file scans or use GitHub `GET` and `HEAD` requests. These endpoints initiate fleet file scans or use GitHub
API quota; viewers cannot trigger them. API quota; Viewers and Super Viewers cannot trigger them.
RCON failure replies are available only to operators and administrators in the Saved RCON failure replies are available to Super Viewers, operators and administrators in the
server's Diagnostics tab. Player addresses are redacted before diagnostics are server's Diagnostics tab. Player addresses are redacted before diagnostics are
saved; successful checks clear the saved reply. Fleet snapshots and live status saved; successful checks clear the saved reply. Fleet snapshots and live status
streams contain no reply snippets. Upgrades discard older saved RCON snippets. streams contain no reply snippets. Upgrades discard older saved RCON snippets.
@@ -69,22 +83,61 @@ passwords created or reset by administrators must be changed before any fleet
access. Password changes require the current password. access. Password changes require the current password.
Sessions use random tokens in HTTP-only cookies, with only token hashes stored in Sessions use random tokens in HTTP-only cookies, with only token hashes stored in
SQLite. HTTPS uses a Secure `__Host-` cookie. Sessions expire after 12 hours, or SQLite. HTTPS uses a Secure `__Host-` cookie. By default, sessions expire after
30 minutes without authenticated requests; background dashboard polling counts 12 hours, or 30 minutes without authenticated requests; background dashboard
as activity. Login replaces the existing browser session. Logout revokes it. polling counts as activity.
Select **Keep me signed in on this device** at sign-in to stay signed in for
30 days, including between visits from a phone's home-screen app. This option is
unchecked by default and applies only to that browser or home-screen app. Remembered
sessions have no inactivity timeout, but still expire 30 days after sign-in;
activity does not extend that deadline. Use this option only on a device you trust.
Existing sessions keep their original limits after upgrading.
Password changes always require the current password. Remembered administrator
sessions also require the administrator's current password when creating accounts
or editing users, including access changes and password resets. A successful
password change replaces the current session, preserving its remember-device
choice with a fresh lifetime, and revokes all other sessions.
For API clients, `POST /api/auth/login` accepts an optional boolean `rememberMe`
(omitted or `false` keeps the default limits). When using a remembered session,
`POST /api/users` and `PATCH /api/users/:id` require the administrator's password
in `currentPassword`; the `password` field remains the target user's temporary
password. The password-change endpoint preserves the current session's choice.
Login replaces the existing browser session. Logout revokes it.
Changing a password, changing a role, disabling an account, or revoking that Changing a password, changing a role, disabling an account, or revoking that
user's sessions invalidates their existing sessions. Live event streams check user's sessions invalidates their existing sessions. Live event streams check
revocation every 15 seconds and then close. revocation every 15 seconds and then close.
Login, initial setup and password verification are rate-limited with persistent Login and password changes for enabled Administrators and Super Viewers are exempt
15-minute windows. The database stores audit entries for login results, account from per-account and per-address rate limits, including existing exhausted windows.
changes and successful management requests without recording passwords, tokens Their attempts do not consume either limit. Other accounts use persistent
or request bodies. Administrators can review recent entries in Users. 15-minute login windows of 10 attempts per username-and-address pair and 30 per
address across accounts. Attempts from one address do not exhaust another
address's allowance for the same username. Initial setup and password changes
retain their limits of 10 per account and 30 per address. Exemptions use the
account's current database role; password verification is still required.
The password service runs at most two scrypt operations at once and queues up to A successful password login also sets a separate, HTTP-only login-device cookie
16 more for up to two seconds. If that queue fills or the wait expires, the for 30 days. This cookie lets that browser skip its username-and-address limit,
request returns HTTP 429 with `Retry-After: 2`. The persistent rate limits still while the password and shared address limit still apply. It does not sign you in,
return `Retry-After: 900` when an account or address reaches its attempt limit. and is independent of **Keep me signed in**. It survives ordinary sign-out;
password changes/resets, role changes, disabling an account, and revoking all of
its sessions invalidate its login-device cookies. Only token hashes are stored in
SQLite. Reusing a valid cookie does not extend its original expiry.
The database stores audit entries for login
results, account changes and successful management requests without recording
passwords, tokens or request bodies. Administrators and Super Viewers can review
recent entries in Users.
For every role, the password service runs at most two scrypt operations at once
and queues up to 16 more for up to two seconds. If that queue fills or the wait
expires, the request returns HTTP 429 with `Retry-After: 2`. The persistent rate
limits return `Retry-After: 900` when a non-exempt account or address reaches its
attempt limit.
An administrator can reset another user's password from Users. If the only An administrator can reset another user's password from Users. If the only
administrator forgets their password, an operator with local controller/container administrator forgets their password, an operator with local controller/container
@@ -98,8 +151,10 @@ DB_PATH=./data/cherry.db node scripts/reset-admin.mjs admin
Replace `admin` with the existing administrator's username. The command prompts Replace `admin` with the existing administrator's username. The command prompts
without echoing the password, resets that enabled administrator only, revokes without echoing the password, resets that enabled administrator only, revokes
their sessions and records the recovery. It does not expose a remote recovery their sessions and trusted login devices, clears their account-specific login and
endpoint or accept passwords as command arguments. password-change rate limits, and records the recovery. Other users' limits and
shared address limits remain intact. It does not expose a remote recovery endpoint
or accept passwords as command arguments.
## Agent and telemetry connections ## Agent and telemetry connections
@@ -141,9 +196,27 @@ JSON requests and multipart uploads. Do not configure a proxy to replace arbitra
incoming origins with your trusted origin. Machine endpoints use their own token incoming origins with your trusted origin. Machine endpoints use their own token
checks and do not require a browser Origin. checks and do not require a browser Origin.
Use the adapter's real client-address configuration only with a trusted proxy Login throttling uses the address returned by SvelteKit's adapter. Without
that strips spoofed forwarding headers; login throttling otherwise uses the direct `ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy
peer address. Forwarded host or user headers do not authenticate users. share the proxy's address limit. Compose passes `ADDRESS_HEADER` and `XFF_DEPTH`
from `.env` to the controller; direct Node deployments must set them in the
server's environment. For one trusted proxy that sets `X-Forwarded-For`, use:
```dotenv
ADDRESS_HEADER=x-forwarded-for
XFF_DEPTH=1
```
Recreate the Compose controller after changing these values with
`docker compose up -d`. For a proxy chain, set `XFF_DEPTH` to the number of trusted
proxies: the adapter selects that many positions from the right of the header.
For example, `client, proxy1` with two trusted proxies requires `XFF_DEPTH=2`.
The public-facing proxy must replace untrusted forwarding headers, and every
trusted hop must supply the expected address chain. Restrict direct access to
port 3000 so requests cannot bypass those proxies. Do not enable these settings
for an untrusted proxy or copy a depth that does not match your deployment.
See the [adapter's client-address settings](https://svelte.dev/docs/kit/adapter-node#Environment-variables-ADDRESS_HEADER-and-XFF_DEPTH).
Forwarded host or user headers do not authenticate users.
Local HTTP browser development requires `EC_AUTH_ALLOW_HTTP=true` and a loopback Local HTTP browser development requires `EC_AUTH_ALLOW_HTTP=true` and a loopback
hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development
+6 -4
View File
@@ -10,7 +10,7 @@ T5 game-wide policies cover the ten standard Zombies maps; T6 policies cover nin
Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum. Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum.
Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show lobby counts, key availability, and the current status. An active job appears once with its latest stage and a **View job** link. Use **Lobbies** for per-map spare coverage and occupancy evidence, **Activity** for decision changes grouped by job, and **Metrics** for observed capacity and provisioning measurements. Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show lobby counts, key availability, and the current status. An active job appears once with its latest stage and a **View job** link. Use **Lobbies** for per-map spare coverage and occupancy evidence and **Activity** for decision changes grouped by job. [**Metrics**](metrics.md) shows observed lobby counts and empty spare hours, plus autoscaling additions, removals, failed or cancelled operations, and average/maximum request-to-ready time for the selected period, game, and map. The policy card retains its separate 31-day measurement totals, including spare deficits.
| Autoscaling | Behavior | | Autoscaling | Behavior |
| ----------- | ----------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------- |
@@ -19,7 +19,9 @@ Choose **Save policy** to apply changes directly. Saving checks the current flee
Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The policy shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host. Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The policy shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies allow one outstanding operation across their maps, prioritizing additions before removals. Once an operation settles, the next evaluation can act immediately on another eligible lobby. Surplus lobbies must still satisfy the configured continuous empty duration before removal. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins. The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. Each occupied lobby must reach **round 2 or greater**, confirmed by fresh telemetry for its current match, before it increases the creation target. Brief joins, round-one matches, and unavailable round evidence do not trigger an additional spare. The minimum and baseline spare target can still bootstrap a map without an active match. Existing spares remain protected by the ordinary desired count even when a match restarts or round evidence becomes unavailable.
The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies apply the round requirement independently to each map and allow one outstanding operation across their maps, prioritizing additions before removals. Once an operation settles, the next evaluation can act immediately on another eligible lobby. Unsubmitted creates are withdrawn if they no longer meet the round or capacity requirements. Surplus lobbies must still satisfy the configured continuous empty duration before removal. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins.
When trimming a map, autoscaling keeps occupied lobbies and the lowest-numbered When trimming a map, autoscaling keeps occupied lobbies and the lowest-numbered
empty lobbies needed for its minimum and spare targets. It selects the empty lobbies needed for its minimum and spare targets. It selects the
@@ -68,12 +70,12 @@ The production observation period and live pilot have not been run as part of im
1. Select one or two representative maps and record the baseline lineup, keys, versions, host CPU and memory, and player-hours. 1. Select one or two representative maps and record the baseline lineup, keys, versions, host CPU and memory, and player-hours.
2. Select On after resolving blockers and checking agent capability. Set the minimum, maximum, and spare target, then choose Save policy. 2. Select On after resolving blockers and checking agent capability. Set the minimum, maximum, and spare target, then choose Save policy.
3. Monitor request-to-ready time from Provisioning, browser visibility, successful external joins, empty lobby-hours, spare deficits, and removals. Increase the spare target if creation latency leaves new groups without a suitable lobby. 3. Use Metrics to monitor request-to-ready time, empty lobby-hours, and autoscaling removals for the selected period and scope. Check the policy card's 31-day spare deficits and individual Provisioning operations for delays; verify browser visibility and successful external joins separately. Increase the spare target if creation latency leaves new groups without a suitable lobby.
4. Expand one map at a time when spare availability and gameplay meet your requirements. Pause if they regress. 4. Expand one map at a time when spare availability and gameplay meet your requirements. Pause if they regress.
The page retains up to 31 days of decision evidence and observations, with the 30 most recent decision changes shown. Repeated unchanged evaluations update the decision's last-seen time instead of adding a row. Unresolved intent survives retention. Provisioning history retains the operation and policy backlink separately. The page retains up to 31 days of decision evidence and observations, with the 30 most recent decision changes shown. Repeated unchanged evaluations update the decision's last-seen time instead of adding a row. Unresolved intent survives retention. Provisioning history retains the operation and policy backlink separately.
Measurement totals exclude collection gaps. Request-to-verified-readiness summaries include queue time and only completed creates; they do not establish browser visibility or successful joins. “Time with players” in Metrics can rise simply because idle lobbies leave its denominator. Use host CPU and memory measurements alongside player-hours and spare availability when assessing savings. Measurement totals exclude collection gaps. Request-to-verified-readiness summaries include queue time and only completed creates; they do not establish browser visibility or successful joins. [Metrics](metrics.md) shows average and peak lobby counts, empty spare hours, and map time with no reported empty lobby. Its autoscaling signals compare historical counts with current policy targets. Use host CPU and memory measurements alongside player-hours and spare availability when assessing savings.
## API and implementation ## API and implementation
+236
View File
@@ -0,0 +1,236 @@
# Player bank
The bank stores one points balance per exact GUID across T5 and T6. Players use
`.b`, `.d <amount|all>` and `.w <amount|all>` with the TWS bank scripts. Deposits
move spendable match points into the bank; withdrawals return bank points to
the current match. Neither changes earned `score_total`. Bank-assisted games
still count toward the existing leaderboards; the ledger records their match
IDs. This release does not introduce separate bank-assisted rankings.
## Setup and rollout
1. Back up the controller database. Update every host agent to **1.9.1 or later**
before installing the public-endpoint bank scripts. This version settles both
public and legacy controller-derived journals before removing runtime storage.
Package and offline-sign the agent
release using the [host update workflow](native-hosts.md).
2. Deploy Electric Cherry and restart the controller. Migration 20 creates
accounts, requests and the ledger; migration 21 adds instance credentials and
binds existing requests to their currently active native instance, if any.
3. Set `EC_BANK_MAX_BALANCE` (default 250000, range 1–1000000). Invalid configuration
refuses new deposits. Lowering the limit preserves existing balances and
prepared deposits; withdrawals remain available.
4. Build/install TWS plugins `t5-plugin`/`t6-plugin` 1.1.0 or later and restart
the **whole game-server process**. Install `tws_t5_bank.gsc` or
`tws_t6_bank.gsc` 2.0.0, common 1.0.0+ and telemetry 1.0.0+.
For existing servers changing endpoints, use the rebuild procedure below.
5. Create or rebuild the servers through EC. Launch preparation generates a
cryptographically random `ec_bank_` credential for each immutable instance,
stores it in the private controller database, and injects it through
`configs/electric_cherry_bank.cfg`. The launch command executes this private
file and sets `tws_server_id`. The host agent writes only `tws_bank_token` in
that private file; it no longer generates `tws_bank_url`.
Both game scripts always send bank requests to
`https://ec.thewrightserver.net/api/bank`, even if an old `tws_bank_url` remains
in configuration. The host agent's enrolled HTTPS controller URL is separate
and continues to carry agent traffic, including authenticated bank settlement.
It may differ from the public endpoint reachable by game containers.
There is **no `EC_BANK_TOKENS` environment map** and no per-server secret setup.
The private configuration is sent only in that host's authenticated operation
payload. It is excluded from shared recipes, promoted files and blob downloads.
Do not upload `electric_cherry_bank.cfg`: its filename is reserved. Protect the
controller database, host operation journal and per-instance runtime directory,
which contain private credentials.
Drift scans exclude the exact generated configuration path for both games.
A maintenance rescan resolves old bank-only incidents and retains other file
changes in mixed incidents. Private-file campaign plans saved before this fix
are rejected at activation; cancel them and rescan rather than retrying the old
plan. Upgrade host agents to **1.9.3 or later** before resuming drift recovery
so stages already saved on a host also reject private-file reads and changes.
Legacy private bank-file campaign diffs and their blob downloads are blocked;
this does not erase previously captured bytes from databases or backups.
Retries, rebuilds and host moves of the same instance reuse its credential.
Retiring the instance immediately revokes authentication. Creating another
server with the same display name produces a new instance ID and secret; it
cannot finish the old instance's requests. Accounts remain attached to player
GUIDs, so balances survive the servers. Telemetry tokens and browser sessions
cannot authorize bank requests. Host credentials can only settle decisions
through the separate, operation-scoped teardown endpoint.
For an upgrade from the initial static-token implementation, drain outstanding
transfers while the old controller/configuration still runs, then deploy and
rebuild. Remove the obsolete environment map and any manually supplied token.
Do not replace identities or discard pending journals to force an upgrade.
### Upgrading from a controller-derived game endpoint
Update agents to `1.9.1+` first. If an existing server's `tws_bank_url` differs
from `https://ec.thewrightserver.net/api/bank`, rebuild that server through EC
with bank scripts `2.0.0`. The agent stops the old game, settles its pending
commit/cancel decisions with their original request IDs, then starts the
replacement and removes old storage. The server identity and instance credential
are preserved. Lost acknowledgments can replay the same decisions; EC's retained
request IDs prevent duplicate ledger changes. An outage or invalid journal
retains storage and blocks replacement until recovery succeeds.
Do not deploy the new scripts with only a map restart when the endpoint changes:
native journals are keyed by server and endpoint, and the new scripts cannot load
the old scope. Do not rename, rewrite or delete pending journals. Keep the
agent's enrolled controller URL unchanged until legacy journals drain; the
agent accepts only the fixed public endpoint and the legacy `/api/bank` URL
derived from that enrolled controller. Other legacy URLs require recovery with
their original configuration before switching. If the old URL already matches
the exact public endpoint, existing journals retain their identity and a map
restart is sufficient for the script upgrade. No new native plugin build is
required beyond `1.1.0`.
## Machine API
All routes below are `POST /api/bank/<action>`, with `Content-Type:
application/json` and `Authorization: Bearer <server-bank-token>`. Responses
are never cached. Bodies are capped at 4096 bytes. GUIDs and request IDs stay
strings; never convert them to numbers.
| Action | Body | Meaning |
| ---------- | ------------------------- | -------------------------------------------------------------------- |
| `balance` | `server`, `guid` | Read the shared balance and currently available points |
| `deposit` | Transfer fields below | Reserve deposit capacity without crediting the account yet |
| `withdraw` | Transfer fields below | Reserve bank funds without debiting the account yet |
| `commit` | Identical transfer fields | Apply a prepared transfer once and append its ledger entry |
| `cancel` | Identical transfer fields | Release a reservation; also prevents a delayed prepare from applying |
Transfer example:
```json
{
"server": "t5_zombie_theater_1",
"guid": "18446744073709551615",
"match": "reporter-match-key",
"request_id": "unique-process-token-17",
"operation": "deposit",
"amount": 2000,
"capacity": 995000
}
```
`match` is the reporter's telemetry match key, **not** Electric Cherry's numeric
match ID. `request_id` is unique for the server and at most 64 characters.
`amount` is a positive integer up to 1000000; withdrawal also accepts `"all"`.
The script resolves deposit `all` to its captured spendable points. `capacity`
is the current room below the game's bank withdrawal cap of 1000000 points.
An `all` withdrawal that exceeds this room is refused, rather than truncated.
Preparation requires the GUID to be present in the server's latest active match,
with the exact telemetry match key. Late joiners may need to wait for the next
30-second heartbeat. Unknown/destroyed servers, absent players and old matches
cannot prepare transfers. Settlement of an existing decision remains possible
after match completion or retention. Host teardown can finish saved decisions
after retirement; the retired game credential itself no longer authenticates.
Successful application responses include `ok`, `state`, `code`, `request_id`,
`amount` and `balance`. States are `balance`, `prepared`, `committed`, `cancelled`
or `rejected`. For balance reads, `amount` is the available balance. A business
rejection uses HTTP 200 with `ok: false`, `state: "rejected"` and a code such as
`insufficient_balance`, `empty`, `bank_limit`, `points_limit`, `not_in_match` or
`pending`. It never changes points. Authentication failures use 401/403,
malformed input 400/413/415, identity conflicts 409, and unavailable service 503.
Repeated requests return the saved result/state without applying a second
change. Settled IDs never reopen; a committed result retains its original
resulting balance even if later transfers change the account. Reusing an ID
with different transfer fields returns 409. A GUID has at most one prepared
transfer across all servers. SQLite transactions serialize checks, account
updates and ledger insertion, so concurrent requests cannot overspend funds or
deposit past the configured maximum.
## Lost responses and recovery
The game saves a cancellation decision **before** sending a prepare request.
Only a validated prepared reply lets it recheck the player's life, current
spendable points and withdrawal room. It then records a commit decision and
updates spendable points without yielding. The sender retries settlement with
the original identity, at delays of 1, 2, 4, 8, 16, then 30 seconds. Invalid,
truncated or uncorrelated replies never authorize points changes.
A failed/uncertain prepare leaves game points unchanged and queues cancellation.
Cancellation can arrive first: its tombstone blocks the delayed prepare. Death,
respawn, disconnect or match end before application also cancels. After a commit
decision, retries only finish the ledger; they never change game points again.
These points now belong to the current match, so an applied withdrawal is not
automatically refunded if that match ends or its server crashes.
Keep `tws-bank/t5/` or `tws-bank/t6/`, relative to the game process working
directory, on persistent writable storage with a
single process per server/endpoint. Journals contain transfer data, not secrets.
On a map/process restart, saved decisions settle before new bank commands.
Unreadable storage disables the bank; failed writes prevent points changes.
Pending decisions remain until acknowledged, even on credential errors. Bank
holds do not expire automatically, because expiring a saved commit can lose a
deposit or create a free withdrawal. Restore connectivity/configuration and the
original journal to resolve holds; never delete a pending journal to unblock
the bank. If storage is permanently lost, reconcile against the retained
request and ledger records before any manual repair.
During removal, rebuild, host migration or launch cancellation, agent 1.9.1+
stops the game and drains its journals **before** deleting runtime files or
starting a replacement. It uses its host credential at
`POST /api/agent/bank/settle`; EC checks the assigned host, immutable instance,
unfinished teardown operation, server and original request identity. This
endpoint permits only commit/cancel, never new deposits or withdrawals. Journal
URLs must match the public game-bank endpoint or that host's legacy
controller-derived bank endpoint; the agent never follows a journal URL.
Corruption, an unrecognized endpoint, failed acknowledgment or an
outage retains storage and leaves the operation unresolved for retry. An
interrupted rebuild uses the existing rebuild recovery workflow to retry with
a fresh operation after settlement. Do not manually erase its old directory.
The journal provides recovery from map/process restart. It cannot make live
game memory and SQLite one atomic transaction or preserve match points after a
machine/storage failure. Back up persistent storage and verify crash behavior
in the actual Windows/Plutonium deployment before rollout.
## History and validation
The **Players → Bank** page shows fleet-wide balances, funded accounts, completed
transfers in the last 24 hours and pending transfers. Accounts can be searched by
player name or GUID, sorted by balance or recent completed activity, and filtered
to funded accounts, accounts at or above the configured limit, or pending transfers.
Balances always remain shared across both games.
Transactions can be filtered by player, game, server, direction and inclusive UTC
dates. Pending transfers show oldest first, with an attention indicator after five
minutes and details explaining the reservation. This indicator does not expire or
cancel a transfer. Both views retain historical server identities and match IDs
after retention; unavailable matches have no live link.
Selecting a player opens a drawer with their balance, available points, pending
transfer and paginated history. Filters and pagination are retained in the URL,
including when following a match or player-profile link. The page refreshes every
30 seconds while visible. Summary counts always cover the whole fleet. Bank is
read-only for all browser roles; transfers continue to originate in game.
Player profiles show the shared balance, pending status and three newest ledger
entries, with a **View bank history** link to the drawer. Entries include signed
amounts, resulting balances, server, time and match.
Accounts, request IDs and ledger entries have no cascading match/server foreign
keys and are not deleted by match retention or server deletion. Retained IDs
also keep old retries idempotent. Expired match links become plain historical
IDs. Include these tables in normal database backups.
Run `npx vitest run tests/unit/bank.test.ts tests/unit/bank-overview.test.ts tests/client/bank-page.test.ts`, then the usual lint, type checks,
tests and build. TWS supplies `node tools/check_bank.mjs` and
`python3 tools/test_plugin_bank.py`. Live validation must cover both games:
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
telemetry, `.help`, score HUD updates, death/respawn/disconnect during requests,
lost prepare/commit replies, controller outage, disk failures and map/process
restart with pending work. Confirm earned score and leaderboard behavior, and
verify that another server's credential and the telemetry credential fail.
For the endpoint upgrade, also test a different enrolled controller address and
a stale `tws_bank_url`, `.b` with an empty balance, `.w all` with no funds, and
rebuild/removal with both legacy and public journals. Drop a settlement reply,
restart the agent and verify the original IDs settle without duplicate ledger
changes before the replacement starts or runtime storage is removed.
+73 -20
View File
@@ -12,6 +12,10 @@ file applies to all participants. Every file needs at least one matching test
participant. For example, a T5 script and a T6 script can share one beta without participant. For example, a T5 script and a T6 script can share one beta without
either script being installed on the other game's servers. either script being installed on the other game's servers.
Choose **Add all eligible files** to add the latest unpromoted version of every
remaining eligible file at once. Files and exact versions you already selected
stay unchanged.
Setup defaults to automatic selection. Enter one **Instances per game** number, Setup defaults to automatic selection. Enter one **Instances per game** number,
which defaults to 1 and applies to every game represented by the selected files which defaults to 1 and applies to every game represented by the selected files
and their matching servers. Every automatically selected instance must pass and their matching servers. Every automatically selected instance must pass
@@ -29,8 +33,8 @@ servers. Servers held by another active beta or with pending deployment work
are excluded, as are instances on unavailable, draining, or disabled hosts. are excluded, as are instances on unavailable, draining, or disabled hosts.
Unknown or stale player counts are not treated as empty. If the Unknown or stale player counts are not treated as empty. If the
requested counts cannot be met, including coverage of every selected map-scoped requested counts cannot be met, including coverage of every selected map-scoped
file, the beta does not start. Deployment and restarts still wait for confirmed file, the beta does not start. File deployment rebuilds servers after waiting
empty lobbies and restart capacity. for confirmed empty lobbies and rebuild capacity.
Open **Advanced options → Choose specific servers** for manual selection. The Open **Advanced options → Choose specific servers** for manual selection. The
picker includes servers matching any file in the bundle; each file must have a picker includes servers matching any file in the bundle; each file must have a
@@ -73,6 +77,22 @@ without trimming or truncation. Keep GUIDs as strings to preserve their precisio
and leading zeros. Round and stat values are nonnegative JSON integers. and leading zeros. Round and stat values are nonnegative JSON integers.
Each GUID must appear only once within a report's player array. Each GUID must appear only once within a report's player array.
Every event also accepts these optional top-level fields:
| Field | Meaning |
| ------- | --------------------------------------------------------------------------------------------------- |
| `match` | Reporter-generated game identity: a nonblank string of at most 64 UTF-16 code units. |
| `seq` | Positive JSON safe integer, at most 9,007,199,254,740,991, identifying this report within the game. |
Send both fields for reliable match boundaries, ordering, and retry handling.
Choose a fresh `match` value for every game, including restarts on the same map,
and keep it unchanged throughout that game. Match keys are scoped to the exact
`server` name. Start `seq` at 1 and increment it once per event, including
heartbeats. Retry a report with its original `match`, `seq`, and payload; do not
allocate a new sequence number for a retry. Either field may be omitted for
compatibility, but sequence-based deduplication requires both fields. Existing
reporters without these fields remain valid.
Every event also accepts an optional per-player `ping`: the game server's current Every event also accepts an optional per-player `ping`: the game server's current
latency measurement in milliseconds, as a nonnegative JSON integer (for example, latency measurement in milliseconds, as a nonnegative JSON integer (for example,
`"ping": 42`). Send `null` or omit the field when unavailable; do not substitute `"ping": 42`). Send `null` or omit the field when unavailable; do not substitute
@@ -95,6 +115,8 @@ Send when the game starts. Round and stats are not required.
{ {
"event": "game_start", "event": "game_start",
"server": "t6_zombie_town_1", "server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 1,
"map": "zm_transit", "map": "zm_transit",
"players": [ "players": [
{ "name": "Josh", "guid": "12345678901234567" }, { "name": "Josh", "guid": "12345678901234567" },
@@ -111,6 +133,8 @@ Send the new round and each player's current stats.
{ {
"event": "round_change", "event": "round_change",
"server": "t6_zombie_town_1", "server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 2,
"map": "zm_transit", "map": "zm_transit",
"round": 25, "round": 25,
"players": [ "players": [
@@ -144,6 +168,8 @@ Send the final round and each player's final stats when the game ends.
{ {
"event": "game_end", "event": "game_end",
"server": "t6_zombie_town_1", "server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 3,
"map": "zm_transit", "map": "zm_transit",
"round": 26, "round": 26,
"players": [ "players": [
@@ -179,16 +205,37 @@ validation. Authenticated bodies over 1 MiB return 413 before JSON parsing.
Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON
content type 415. content type 415.
The script sends each event once, in gameplay order, directly to Electric Cherry. Send events in gameplay order directly to Electric Cherry. There is no discovery
There is no discovery request, beta token, boot/session ID, sequence number, request, beta token, custom feature event, external helper, or separate beta
custom feature event, external helper, or separate beta acknowledgement. acknowledgement. Basic payload checks happen in the receiver.
Basic payload checks happen in the receiver.
Electric Cherry saves the latest complete report, including player stats, and With `match` and `seq`, repeated sequences are ignored even after an application
restart. A lower sequence that arrives late can fill a missing-report gap without
rolling back the current round, roster, or counters. A delayed `game_start` can
record the starting roster without resetting the game's observed progression.
A known older match key cannot replace a newer game's live state. When an unseen
key starts a new game before the previous keyed game's `game_end` arrives,
Matches labels the previous game **End report missing**. A later `game_end`
completes its own match without changing the newer game. Missing sequence counts
are diagnostic: they do not invent absent rounds, rosters, or an ending.
Duplicate sequences, lower-sequence reports, and reports for known older keys do
not advance beta requirements or replace the current scoreboard. A successful
response acknowledges the submitted report; it does not mean that report became
the current game state.
Preserve delivery order across games as well: keep queued retries ahead of
newer games' reports. An unseen key is always treated as the next game, even if
its first report was delayed from an older game. `seq` cannot resolve this because
it orders reports only within a game. A persistent per-server game counter or
equivalent ordering metadata would require a protocol extension. See the
[first-report ordering limitation](leaderboard.md#persistence-and-match-boundaries)
for its effect on match history and the live scoreboard.
Electric Cherry saves the latest accepted report, including player stats, and
streams it to connected clients. A new start clears the previous round display; streams it to connected clients. A new start clears the previous round display;
an end closes the match. Receipt order determines the latest report. This small an end closes the match. Without a match key, legacy receipt-order and
protocol does not attempt to identify delayed events from earlier games or map/round heuristics remain in effect. Those reports cannot reliably distinguish
provide exactly-once delivery across games. delayed events from earlier games; send them once in order.
Match timing is independent of player count: an empty `game_start` still records Match timing is independent of player count: an empty `game_start` still records
the start, and empty rosters or RCON polls do not erase it. Fleet can display a the start, and empty rosters or RCON polls do not erase it. Fleet can display a
@@ -198,7 +245,7 @@ Fleet clears the previous match's timing rather than carrying it into the new on
Reports also feed the persistent [Leaderboard](leaderboard.md), independently Reports also feed the persistent [Leaderboard](leaderboard.md), independently
of beta activity. That page documents cumulative stat accounting, round credit, of beta activity. That page documents cumulative stat accounting, round credit,
and the limits of identifying matches from this protocol. and match identity and ordering behavior.
### Reporter deployment ### Reporter deployment
@@ -306,7 +353,7 @@ Health monitoring continues after a pass until promotion.
For failures, select hold as failed, pause for investigation, or automatic For failures, select hold as failed, pause for investigation, or automatic
rollback. Rollback queues restoration only for files touched by the beta. rollback. Rollback queues restoration only for files touched by the beta.
Restoration and restart both wait for an empty server and restart capacity. Restoration rebuilds the server after waiting for an empty lobby and rebuild capacity.
The manual option holds those changes in a rollback campaign until you select The manual option holds those changes in a rollback campaign until you select
**Resume when empty**. Promotion is always manual. **Resume when empty**. Promotion is always manual.
@@ -318,9 +365,9 @@ Review the promotion preview before confirming. API clients fetch it with
409 and requires a fresh preview. 409 and requires a fresh preview.
Confirming promotion creates a campaign without reading the existing files on Confirming promotion creates a campaign without reading the existing files on
additional servers. Each server waits for an empty lobby and restart capacity, additional servers. Each server waits for an empty lobby and rebuild capacity,
then captures rollback snapshots immediately before applying its queued files then captures rollback snapshots before rebuilding with its queued files.
and restarting. Campaign history records snapshot capture, and file comparisons Campaign history records snapshot capture, and file comparisons
become available once those snapshots are confirmed. Tested servers retain their become available once those snapshots are confirmed. Tested servers retain their
original pre-beta snapshots; their composed configuration is still checked at original pre-beta snapshots; their composed configuration is still checked at
promotion to determine whether another activation is needed. promotion to determine whether another activation is needed.
@@ -350,9 +397,9 @@ binary and large files show their checksums. Files must actually change.
The beta keeps its ID, servers, scope and test policy. Each revision creates new The beta keeps its ID, servers, scope and test policy. Each revision creates new
file versions, clears current test progress and checklist sign-offs, repeats the file versions, clears current test progress and checklist sign-offs, repeats the
configured waves, and starts a fresh testing window at submission. Files install configured waves, and starts a fresh testing window at submission. Servers rebuild
and servers restart through the normal empty-server and capacity gates. Promotion with the requested files through the normal empty-server and capacity gates.
requires the new revision to pass. Pause and retry retain the current revision's Promotion requires the new revision to pass. Pause and retry retain the current revision's
deadline; they do not create a revision. deadline; they do not create a revision.
Revision history retains downloadable bundle files and previous test results, Revision history retains downloadable bundle files and previous test results,
@@ -364,7 +411,7 @@ new revision is waiting to deploy. Files originally absent are removed.
Updates are available until promotion or rollback, including paused, failed and Updates are available until promotion or rollback, including paused, failed and
expired tests. An activation already in progress must settle first; interrupted expired tests. An activation already in progress must settle first; interrupted
file writes and deleted participants must be resolved before an update. Pending, file writes and deleted participants must be resolved before an update. Pending,
unsubmitted restarts are superseded. Adding/removing files, changing targets or unsubmitted activations are superseded. Adding/removing files, changing targets or
changing the policy still requires a new beta. changing the policy still requires a new beta.
## API and storage ## API and storage
@@ -386,7 +433,13 @@ recent evidence under `test`.
copied into a beta; later edits do not change an active plan. copied into a beta; later edits do not change an active plan.
`POST /api/betas/:id/revisions` accepts multipart `expectedRevision`, optional `POST /api/betas/:id/revisions` accepts multipart `expectedRevision`, optional
`notes`, and replacement uploads named `file:<scriptId>`. A stale revision is `notes`, and replacement uploads named `file:<scriptId>`. Each script replacement
also requires `release:<scriptId>` in `MAJOR.MINOR.PATCH` form. The update dialog
accepts manual versions or `versions.json` to fill empty fields by exact filename.
A changed script cannot reuse a previous release version. Missing or invalid
versions reject the entire update without modifying beta history or deployments.
Plugin release tags remain optional. See [script release versions](getting-started.md#script-release-versions).
A stale revision is
rejected. `GET /api/betas/:id` includes the current `revision` and `revisions` rejected. `GET /api/betas/:id` includes the current `revision` and `revisions`
history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an
archived file (or the original pinned file before its first update). archived file (or the original pinned file before its first update).
+24
View File
@@ -304,6 +304,27 @@ before continuing eligible interrupted startups. Unknown command results retain
their original command ID and occupied slots until the host confirms the outcome. their original command ID and occupied slots until the host confirms the outcome.
Missing server registrations and later destruction attempts require inspection. Missing server registrations and later destruction attempts require inspection.
### Recovering a completed rebuild generation
A completed rebuild records the replacement generation before EC verifies health.
If fresh host inventory disagrees, verification pauses with the controller and
host generations in the operation's recovery message.
For an existing mismatch, open the **completed rebuild's Provisioning detail**
(even if that operation was later cancelled), then choose **Reconcile replacement
generation**. EC requires a completed command for the original host and immutable
instance ID, the command's requested replacement generation, and matching host
inventory observed within the last 30 seconds. Wait for that host to reconnect
and report fresh inventory if the action is unavailable. A server name alone is
not sufficient evidence.
Reconciliation preserves cancellation and submitted commands. Use **Resume health
verification** when offered, or request a new lifecycle operation after resolving
any remaining active operation. Fresh health checks, log reads, and new lifecycle
commands use the replacement generation. EC requires fresh container and game
evidence before reporting the server online. The agent continues to reject stale
commands; an admin player-safety override does not bypass generation checks.
## API ## API
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` / Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
@@ -351,6 +372,9 @@ there while preserving query parameters.
- `GET /api/deployments/:id`: includes `recipe`, `blueprintName`, and - `GET /api/deployments/:id`: includes `recipe`, `blueprintName`, and
`canResumeVerification` and `canRetryWithRebuild` for lifecycle operations. `canResumeVerification` and `canRetryWithRebuild` for lifecycle operations.
- `POST /api/deployments/:id/resume`: resume eligible creation health checks. - `POST /api/deployments/:id/resume`: resume eligible creation health checks.
- `POST /api/provisioning/:id/reconcile-generation`: reconcile a completed rebuild
using fresh inventory from its original host. Returns 409 when identity or
completion evidence cannot be validated; repeated application is safe.
- `POST /api/provisioning/:id/retry-rebuild`: request a new rebuild for an eligible - `POST /api/provisioning/:id/retry-rebuild`: request a new rebuild for an eligible
failed creation and link it from the original operation. failed creation and link it from the original operation.
+173
View File
@@ -0,0 +1,173 @@
# Discord companion
Electric Cherry can publish live Black Ops I and Black Ops II server boards in
Discord, answer commands privately, and send requested map availability alerts by
DM. Configure it under **Admin → Discord**. Administrators can make changes;
Super Viewers can inspect configuration, the saved preview, and delivery health.
The integration is disabled by default. Once enabled, it publishes all current
and newly created game servers unless an administrator excludes them.
## Install the application
1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
Copy its Application ID and Public Key, and create a bot token.
2. Set the controller environment variables below. Keep the token private; the
settings page reports whether it is configured without displaying its value.
With Docker Compose, put the values in the deployment's `.env` file and recreate
the controller to apply them. Do not commit that file.
```dotenv
EC_DISCORD_APPLICATION_ID=
EC_DISCORD_PUBLIC_KEY=
EC_DISCORD_BOT_TOKEN=
```
3. Install the application into your Discord server using the `bot` and
`applications.commands` scopes. Grant **View Channel**, **Send Messages**, and
**Read Message History** in each board channel. No Administrator permission or
privileged Gateway intents are needed. Channel overrides must also permit the
bot to read and write those channels.
4. In the Developer Portal, set **Interactions Endpoint URL** to
`https://your-controller.example/api/discord/interactions`. Use your actual
public controller origin with a trusted HTTPS certificate. Discord verifies
the endpoint before accepting it. The controller checks Discord's request
signatures; this endpoint does not use the Electric Cherry login session.
5. In Discord, enable Developer Mode, then copy the Discord server ID and the
board channel IDs. Open **Admin → Discord**, enter those IDs, and uncheck any
game servers you want to exclude. Either game may omit its board channel.
6. Review the information-sharing switches, enable the companion, and save.
Select **Register commands** to install the commands in the saved Discord
server. Saving configuration and registering commands are separate actions.
The deployment's reverse proxy must route the interaction endpoint to the
controller and preserve Discord's signature headers and request body. No Gateway
connection is required. Installation scopes and channel permissions follow
Discord's [OAuth2 documentation](https://docs.discord.com/developers/topics/oauth2)
and [permissions reference](https://docs.discord.com/developers/topics/permissions).
See [HTTP interactions](https://docs.discord.com/developers/interactions/receiving-and-responding)
for Discord's endpoint verification and response requirements.
Use a dedicated Discord application for this controller. The command registration
action updates that application's command set in the configured Discord server.
## Choose what is published
All current and newly created game servers are included by default. Uncheck a
server to exclude it from boards, private server browsing, server details, and
availability watches. Exclusions are retained if a game server is removed, so a
server with the same name remains excluded if it returns. Check an excluded
server again to remove that exclusion.
Player names in server details and public connection addresses are separate,
disabled-by-default options. Connection instructions use an explicitly configured
native host public endpoint; internal host addresses and RCON credentials are not
published. Capacity, player counts, map, round, and observation state can appear
without either switch enabled.
**Publish fleet leaderboards** is a separate decision. It exposes recorded player
names and statistics from the whole fleet, including historical matches on
excluded servers. Leave it disabled if those
statistics should remain available only to Electric Cherry users.
The settings page's preview uses the **saved settings**. Pending form edits do
not change the preview or Discord. The preview remains available while the
integration is disabled.
## Boards and browsing
Each configured game channel receives a persistent server board, split into
additional messages when needed. The controller revisits delivery about every
30 seconds and edits changed content. Stored message IDs survive controller
restarts. Periodic existence checks recreate messages that were removed.
Boards show observed occupancy, capacity and current round where available,
with separate empty, in-progress, full, starting, offline, not-responding, and
unknown states. Freshness is part of the observation. **Empty slots do not prove
that a match accepts new players**; this release does not label games joinable.
Capacity defaults to four players, matching the fleet UI, until a fresh reported
capacity overrides it. A default capacity does not make stale player counts
current or allow a stale observation to trigger an availability alert.
Members can use the board controls or slash commands. Browsing, filters, details,
leaderboards, and command confirmations are visible only to the member who
requested them. They do not rearrange the shared board.
| Command | Purpose |
| ------------------------------- | ------------------------------------------------------------------------------------------ |
| `/servers` | Browse published servers; filter by game, map and state, and page through results. |
| `/server name` | Inspect one published server, including optional roster names and connection instructions. |
| `/leaderboard` | Browse recorded fleet rankings by game, map, metric, squad size and page, when enabled. |
| `/watch add game map condition` | Request a one-shot DM when a map has an empty lobby or observed space. |
| `/watch list` | See your active watches and their IDs. |
| `/watch cancel id` | Cancel one of your own watches. |
| `/help` | Explain the commands and their limits. |
Commands are limited to the configured Discord server. Map options preserve the
game's map variants. Leaderboard results reflect recorded gameplay, not a new
claim that a match was won or that a player survived continuously.
## Availability alerts
A watch lasts up to 24 hours and sends one DM when its condition is satisfied.
Each member may have up to ten active watches. Requesting the same map and
condition again reuses the existing watch. Members can cancel only their own
watches.
An **empty** watch needs an observed empty lobby. A **space** watch needs fewer
players than the current capacity, using the four-player default when no fresh
capacity override is available. Neither condition promises that
joining is allowed. Unknown, stale, and offline observations never satisfy a
watch. The worker requires two qualifying observations at least 15 seconds apart
before queueing a notification.
Watches and pending notifications persist across controller restarts. Delivery
handles transient failures and Discord rate limits. A permanent DM failure, such
as a member blocking messages from the bot, ends that watch. Members should allow
DMs from the bot before creating a watch and can inspect active watches with
`/watch list`.
## Delivery and troubleshooting
The settings page shows the last attempt, last success, current error, active
watches, pending notifications, and links to recorded board channels. Refreshing
that page preserves pending edits. A failure keeps the last loaded information
visible.
- **Credentials need attention:** check all three environment variables and
restart the controller after changing them. A readiness indicator checks local
configuration; it does not establish that Discord has accepted the token.
- **Discord rejects the interaction endpoint:** verify HTTPS routing, the
application's Public Key, and that the reverse proxy preserves signed
requests. Discord must reach the endpoint without an interactive login.
- **Buttons time out without a private loading message:** inspect the controller
logs while clicking once (`docker compose logs --since=5m electric-cherry`).
A `[discord] Deferring interaction` entry means the signed request reached the
handler and it prepared a private acknowledgement before database work. A
rejection entry identifies signature, body, or application-ID validation
failures. An application-ID mismatch also returns a private explanation;
`EC_DISCORD_APPLICATION_ID` must identify the same application as the Public
Key and bot token. If no entry appears, check the proxy and saved Interactions
Endpoint URL. A verified endpoint and working boards alone do not confirm
that button clicks are reaching the running controller.
- **Commands are missing:** save the correct Discord server ID, install the bot
with `applications.commands`, then select **Register commands**. Register again
after upgrading to a release that changes the command schema.
- **Board delivery is denied:** check the bot's permissions and channel overrides.
Authorization failures pause retries. After correcting access, select **Save
and retry** in Electric Cherry.
- **A board was deleted:** the controller's periodic check recreates it. Keep
Read Message History enabled so those checks can verify existing messages.
- **Discord rate limits or temporary outages:** delivery honors retry delays and
retries transient failures with increasing delays. Fleet operations continue
independently.
- **Old board messages remain after a channel change or disable:** the controller
attempts to remove old messages. If permissions were removed first, an
administrator may need to delete those messages in Discord.
Disabling the companion stops new publication and notifications. Cleanup may
still remove previously recorded board messages. Data already seen or copied by
members cannot be recalled by changing publication settings.
This release does not link Discord accounts to game identities, expose personal
bank or AFK accounts, mutate servers or moderation state, or announce
achievements. Those workflows require separate authorization and implementation.
+54 -3
View File
@@ -9,6 +9,8 @@ HTTPS. Running game containers continue through controller or agent outages.
- A controller machine with Docker Compose and an HTTPS reverse proxy. - A controller machine with Docker Compose and an HTTPS reverse proxy.
- Debian 12 or 13 amd64 game hosts with storage for game files and instances. - Debian 12 or 13 amd64 game hosts with storage for game files and instances.
- A signed agent release and its public key published in the trusted repository.
- Outbound HTTPS access from game hosts to the controller and public repository.
- Your complete T5 or T6 game folders, including the DLC you intend to host. - Your complete T5 or T6 game folders, including the DLC you intend to host.
- Plutonium server keys for each game, with one unused key per new concurrent lobby. - Plutonium server keys for each game, with one unused key per new concurrent lobby.
@@ -37,6 +39,13 @@ For an existing installation, retain its original data directory. Compose
defaults to `/root/zombies/.electric-cherry`; choose a dedicated directory for a defaults to `/root/zombies/.electric-cherry`; choose a dedicated directory for a
new controller. See [.env.example](../../.env.example) for the other settings. new controller. See [.env.example](../../.env.example) for the other settings.
Before building for game hosts, obtain the publisher's signed `static/agent`
artifacts and matching source revision, or follow
[Publishing signed releases](native-hosts.md#publishing-signed-releases)
for your own build. Normal builds preserve signatures for identical artifacts;
source changes require a new offline signature. An unsigned controller can run,
but hosts reject its agent installation and updates.
```sh ```sh
docker compose up -d --build docker compose up -d --build
``` ```
@@ -47,6 +56,11 @@ including server-rendered pages, page data, and API responses. The build already
precompresses static JavaScript and CSS assets; dynamically generated responses precompresses static JavaScript and CSS assets; dynamically generated responses
need compression at the proxy. need compression at the proxy.
Configure `ADDRESS_HEADER` and `XFF_DEPTH` for your trusted proxy so login limits
use each client's address. Leave `ADDRESS_HEADER` empty for direct connections;
see the [proxy configuration guide](authentication.md#proxy-and-local-development)
before enabling forwarded addresses.
Open the dashboard and follow `/setup` to create the first administrator. Then Open the dashboard and follow `/setup` to create the first administrator. Then
remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the
controller. Setup stays closed once an account exists. controller. Setup stays closed once an account exists.
@@ -60,15 +74,23 @@ password recovery.
1. Open **Releases → Game files** and upload the complete local folder for T5 or 1. Open **Releases → Game files** and upload the complete local folder for T5 or
T6, including DLC. Uploads can run before any host is enrolled. The latest T6, including DLC. Uploads can run before any host is enrolled. The latest
verified upload becomes that game's default files. verified upload becomes that game's default files.
2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and 2. Ensure the release publisher has [published its public key and signed release](native-hosts.md#publishing-signed-releases).
bind address, and run the generated enrollment command on the host. Hosts fetch the key automatically during installation and before every update
3. Wait for automatic setup to prepare the Plutainer runtime and available game check; there is no manual key-copy step.
3. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
bind address, and run the generated enrollment command. It downloads the
installer from the fixed public repository URL before running it. Check that
the installer source is the known repository, independent of the controller.
4. Wait for automatic setup to prepare the Plutainer runtime and available game
files. The host page reports download, verification, and readiness progress. files. The host page reports download, verification, and readiness progress.
The installer sets up the Python agent as a systemd service and enables automatic The installer sets up the Python agent as a systemd service and enables automatic
agent updates. It installs Docker if needed. Multiple instances share read-only agent updates. It installs Docker if needed. Multiple instances share read-only
base game files; each instance has its own writable data. See base game files; each instance has its own writable data. See
[Native hosts](native-hosts.md) for storage layout, network access, and recovery. [Native hosts](native-hosts.md) for storage layout, network access, and recovery.
Existing hosts using updater protocol 1 need a
[trusted installer migration](native-hosts.md#automatic-agent-updates) before
controller-independent release verification takes effect.
## Launch your first server ## Launch your first server
@@ -91,6 +113,35 @@ servers. Disruptive operations normally wait for a fresh empty lobby or a
verified stopped container. A rebuild removes the instance's writable files. verified stopped container. A rebuild removes the instance's writable files.
See [Provisioning](provisioning.md) and [Collections](collections.md) for details. See [Provisioning](provisioning.md) and [Collections](collections.md) for details.
## Script release versions
Every new script upload needs a release version in `MAJOR.MINOR.PATCH` form,
for example `1.2.0`. Enter a version for each file in the upload drawer, script
page, or beta replacement dialog. Versions belong to individual scripts; a batch
can contain different versions.
To fill empty fields automatically, choose `versions.json` from the same source
checkout as your scripts. Cherry matches exact, case-sensitive filenames (such as
`tws_t5_hud.gsc`) and leaves entered values intact. Review the values before
uploading. A manifest identifies source releases; it does not verify file contents.
Changing a script requires a new release version. Reuploading identical bytes with
the same version reuses the saved release; different bytes with that version are
rejected. Older release versions can be imported. Cherry keeps its upload number
separately, displaying labels such as `1.2.0 (upload 7)`.
Historical scripts without a release label display `Unversioned (upload N)` and
remain available for testing, promotion, and rollback. Cherry does not infer old
versions from today's manifest or rewrite historical records. Uploading the same
bytes with an explicit version creates a separate labeled record when needed.
Plugins retain optional publisher tags; configurations retain automatic upload
numbers.
API clients must include multipart `releaseVersion` when creating a script with
`POST /api/scripts` or uploading with `POST /api/scripts/:id/versions`. Missing or
invalid versions return HTTP 400; conflicting contents return HTTP 409. The
version upload response reports `reused: true` for an existing identical release.
## Connect game telemetry ## Connect game telemetry
Configure your game reporter or plugin to send `EC_TELEMETRY_TOKEN` in an Configure your game reporter or plugin to send `EC_TELEMETRY_TOKEN` in an
+96 -27
View File
@@ -13,10 +13,11 @@ only under All squad sizes. The JSON API also defaults to all maps.
## Qualifying matches ## Qualifying matches
Matches only appear in server/global history once they reach round 2. Matches only appear in server/global history once they reach round 2.
Leaderboards, summaries, map options, profiles and player history include only Leaderboards, summaries, map options, profile statistics and player history include only
completed matches with a received `game_end`. Active and interrupted matches do completed matches with a received `game_end`. Active, interrupted, and
not contribute. A player must also be observed at round 2 or later; players who **End report missing** matches do not contribute. A player must also be observed
left before a completed game's end retain their recorded stats. at round 2 or later; players who left before a completed game's end retain their
recorded stats.
This also excludes existing round 1 records. Early telemetry is retained so This also excludes existing round 1 records. Early telemetry is retained so
qualifying games keep their start time, roster and cumulative counters. qualifying games keep their start time, roster and cumulative counters.
@@ -24,6 +25,12 @@ qualifying games keep their start time, roster and cumulative counters.
### Profiles and filters ### Profiles and filters
Players have a profile as soon as they are first reported, even before they have
qualifying match history. These profiles show their identity, an explanation of
when statistics become available, and dashes for personal bests with no recorded
match. The page fills in on refresh after a qualifying game ends. Unknown player
GUIDs still return 404.
Click a player's name to open their shareable profile at Click a player's name to open their shareable profile at
`/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II `/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II
(T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]` (T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]`
@@ -68,9 +75,22 @@ Completed matches link to their own results page at `/matches/[id]` from global
Match History, server history, and player profiles. The page shows the recorded Match History, server history, and player profiles. The page shows the recorded
map and server, final round, start/end times, duration when the start was observed, map and server, final round, start/end times, duration when the start was observed,
player counts, and the full saved roster. Players missing from the final report player counts, and the full saved roster. Players missing from the final report
retain their earlier counters; players with no stats display dashes. Counters and retain their earlier counters; players with no stats display dashes. Counters are
score are the highest reported values within the match, not necessarily its final the highest reported values within the match, not necessarily its final scoreboard.
scoreboard. Active and interrupted matches remain listed without results links.
Each match shows one score column. If any player has a recorded total (including
zero), **Total score** shows each player's highest recorded cumulative points
earned in that match. Players absent from the final report retain their latest
recorded total. A dash in this column means no total was recorded for that player;
their points balance is never substituted. Historical matches where all totals
are missing retain **Highest reported score**, the highest recorded points
balance, not total points earned or necessarily the final score. This choice is
based on recorded data, not the match date. It does not change leaderboard or
profile score rankings, which still use balances.
Active, interrupted, and **End report missing** matches remain listed
without results links. Matches can filter each status and show known gaps in the
reporter's sequence; a delayed report can reduce that missing-report count.
Match pages remain available after the original server is removed. Match pages remain available after the original server is removed.
### Player identity ### Player identity
@@ -87,6 +107,21 @@ of inferring one. GUIDs stay strings.
## Recorded statistics ## Recorded statistics
Telemetry stat reports (`round_change`, `heartbeat`, and `game_end`) may include
`score_total`, a nonnegative integer read from the game's cumulative match score.
`score` remains the spendable balance. Updated T5/T6 reporters capture game end
at the first end signal, before stock intermission replaces the balance with the
total. `game_start` remains identity-only. Deploy the receiver before the updated
reporters so it retains the new field.
The receiver accepts older reports without `score_total`. Latest telemetry
preserves the field when supplied; match history retains the highest observed
total independently of the balance and exposes it as `scoreTotal` on each player
in the match-detail API. Missing totals and pre-upgrade history are unknown
(`null`), not zero, and are never inferred from balances. Totals are per match;
repeated snapshots are not added together. Existing score rankings continue to
use the highest reported balance.
| Statistic | Meaning | | Statistic | Meaning |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Highest round | Highest round reported with that player present, including players who joined mid-game. | | Highest round | Highest round reported with that player present, including players who joined mid-game. |
@@ -120,8 +155,9 @@ rate also requires 100 kills; kills per down requires five downs. The board show
the numerator, denominator and game count beside each ratio. Ratios are computed the numerator, denominator and game count beside each ratio. Ratios are computed
from aggregate counters, not an average of individual match percentages. from aggregate counters, not an average of individual match percentages.
Completion rate is the share of those ended games whose final report contained Completion rate is the share of those ended games whose final report contained
the player; it is not a win rate. Active and interrupted games do not enter any the player; it is not a win rate. Games without a received `game_end`, including
denominator. Zero-denominator ratios remain unknown and are not ranked. **End report missing** games, do not enter any denominator. Zero-denominator ratios
remain unknown and are not ranked.
Qualification is applied before ranking, search and pagination. Summary cards Qualification is applied before ranking, search and pagination. Summary cards
still describe all players in the game/map selection, including those below the still describe all players in the game/map selection, including those below the
@@ -143,12 +179,41 @@ They persist across app restarts and server deletion, and are not subject to
campaign history retention. The latest scoreboard and leaderboard update in the campaign history retention. The latest scoreboard and leaderboard update in the
same transaction. Each match keeps player counters even after someone leaves. same transaction. Each match keeps player counters even after someone leaves.
A `game_start` opens a match; `game_end` completes it. A changed map, a lower round, A `game_start` opens a match; `game_end` completes it. Reporters can send an optional
or a round report after a completed game can also establish a new match when the `match` key and per-game `seq` on every event; see the
start was missed. Superseded active matches remain in history as interrupted. [reporter protocol](beta-test-plans.md#event-fields) for field limits and retry rules.
Repeated starts before any stats belong to the same match. Identical consecutive A key identifies one game on one server, including when the start report was
payloads (ignoring receipt time and roster order) do not add records. Repeated missed. An unseen key opens a new game and labels the previous active keyed game
snapshots in a match cannot increase counters or rounds twice. **End report missing**. This records missing evidence; it does not establish
whether the game finished or was interrupted. A late `game_end` for a previously
seen key can still complete its own match; reports for known older keys do not
replace the newer game's live state.
Keys are opaque, and `seq` orders reports only within one game. Electric Cherry
therefore orders games by their first processed report. If game `new` is already
reporting when the first report for game `old` arrives, the unseen `old` key
supersedes `new` and marks it **End report missing**. Later `new` heartbeats update
its history but cannot restore it as the live game. If `old` arrived as a
heartbeat, it can remain active even after `new` ends. Reporters should preserve
delivery order across games, keeping queued retries ahead of newer games' reports.
Reliably ordering unseen keys would require additional reporter metadata, such
as a persistent, increasing per-server game counter; the current protocol has no
such field.
With both fields present, each sequence is counted once, including across app
restarts. Lower sequences fill missing-report gaps without rolling back newer
rounds, counters, or rosters. A delayed `game_start` may fill the recorded starting
roster without resetting progression. The history API exposes the highest
sequence minus the count of unique received sequences as `missingReports`, or
`null` when sequencing is unavailable. A missing report count does not grant
credit for unobserved rounds or complete a game without `game_end`.
Without a match key, the legacy heuristics remain: a changed map, a lower round,
or a round report after a completed game can establish a new match when the
start was missed. Superseded active matches remain interrupted. Repeated starts
before any stats belong to the same match. Identical consecutive payloads
(ignoring receipt time and roster order) do not add records. Repeated snapshots
in a match cannot increase counters or rounds twice.
The first upgrade imports the latest stored full scoreboard from each server The first upgrade imports the latest stored full scoreboard from each server
once. That provides the available counters and highest round, but not discarded once. That provides the available counters and highest round, but not discarded
@@ -161,14 +226,13 @@ explicitly identified instead of being labeled as a known selection. A match
retains the game/map identity recorded at ingestion even if the server is later retains the game/map identity recorded at ingestion even if the server is later
reconfigured or removed. reconfigured or removed.
The current protocol sends events in gameplay order and has no match ID, event Legacy reports without match identity cannot reliably distinguish a late report
ID, or source sequence. A late report from an older game cannot be reliably from an older game from a new game. Likewise, identical starts before any stats
distinguished from a new game. Likewise, identical starts before any stats are are indistinguishable from retries. Send those reports once in order. Within a
indistinguishable from retries. Send reports once in order. Within a match, match, counter decreases use the previous high-water mark; if a reconnect resets
counter decreases use the previous high-water mark; if a reconnect resets a a player's counters, subsequent activity may be undercounted until those counters
player's counters, subsequent activity may be undercounted until those counters pass their earlier maxima. Match keys and sequence numbers do not identify
pass their earlier maxima. Exact replay handling and counter-reset accounting player counter resets.
would require additional reporter identity/ordering information.
## Read API ## Read API
@@ -343,10 +407,15 @@ an identity rename can change results without changing those timestamps.
### Related routes and current limits ### Related routes and current limits
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match `GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
summaries as `{ "matches": [...], "total": number }`. These routes still require summaries as `{ "matches": [...], "total": number, "page": number, "limit": number, "maps": [...] }`.
session authentication. Both accept only `limit` (default 25, range 1–10,000); there is These routes still require session authentication. Both accept `limit` (default
no offset pagination. Results are newest first by first observation, then match 25, range 1–10,000), `page`, `game`, `map`, `q` (server name), `days` (0, 7, 30, or
ID, and include active/interrupted matches that reached round 2. Their 90), `status`, `sort` (`started`, `round`, `duration`, or `players`), and `direction`
(`asc` or `desc`). Results default to newest first by first observation, then
match ID, and include unfinished matches that reached round 2. Status values are
`active`, `completed`, `interrupted`, and `end-report-missing`; omit the filter for
all statuses. `reporterMatchKey` is the supplied match key or `null`;
`missingReports` is the known sequence-gap count or `null`. Their
[ServerMatch fields](../../src/lib/match-history.ts) differ from leaderboard entries [ServerMatch fields](../../src/lib/match-history.ts) differ from leaderboard entries
and do not include player counters or a roster. Unknown server names return an and do not include player counters or a roster. Unknown server names return an
empty list. URL-encode server names in paths. empty list. URL-encode server names in paths.
+1 -1
View File
@@ -17,7 +17,7 @@ Campaigns retain managed-file deployment evidence; Collections define lineups.
when the running revision is unverified. when the running revision is unverified.
- **Policies & schedules** controls health recovery, configuration drift, automatic - **Policies & schedules** controls health recovery, configuration drift, automatic
Plutonium updates, and the optional daily restart schedule independently. Plutonium updates, and the optional daily restart schedule independently.
- **Activity** uses the shared Activity log, scoped to maintenance and recovery. - **Activity** shows retained history scoped to maintenance and recovery.
Filter retained findings, update results, and scheduled restarts in one timeline. Filter retained findings, update results, and scheduled restarts in one timeline.
Follow a finding for evidence or an operation for execution progress. Use Follow a finding for evidence or an operation for execution progress. Use
Overview’s **Active work & attention** to cancel waiting work or retry failed Overview’s **Active work & attention** to cancel waiting work or retry failed
+241 -68
View File
@@ -5,98 +5,271 @@ Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute.
## Overview and filters ## Overview and filters
The overview shows **Time with players** (occupied server-hours divided by known **Lobbies running** shows the average and peak number of recorded lobbies in the
server-hours) and **Player-hours**. Known and unknown status time remain visible selected scope. The average divides captured lobby-hours, including unknown
alongside the totals. **Last collected** is the newest recorded batch, including status, by captured wall time. Empty spares are deliberate supply for new groups:
unknown checks; **Report refreshed** is when this report was calculated. Collection the card shows their hours and share of all captured lobby-hours, rather than
stops when no servers are registered. Earliest retained observations do not prove treating them as failed utilization. Unknown status can include lobbies starting
continuous history or the original installation date. or unavailable, so this count does not guarantee every lobby was ready to join.
**Player-hours** integrates reported player counts over time. **No empty lobby**
shows the share of known map time when no tracked lobby reported zero players.
Each game/map is measured separately, then its durations are pooled. This is not
elapsed fleet time or a count of rejected joins. A partly occupied Zombies lobby
does not count as an empty spare, even if player slots remain.
**Last collected** is the newest recorded batch, including unknown checks;
**Report refreshed** is when this report was calculated. Collection stops when no
servers are registered. Earliest retained observations do not prove continuous
history or the original installation date.
Background refresh keeps the last successful report visible on failure and offers Background refresh keeps the last successful report visible on failure and offers
Retry. Returning to a visible tab refreshes immediately. The selected range is Retry. Returning to a visible tab refreshes immediately. Game and map filters apply
stored in the URL, and refreshes preserve table controls and chart selection. to the whole report, including comparisons, signals, and the heatmap. Map choices
preserve the exact recorded game/map pair and include retained occupancy, match,
and autoscaling Provisioning history. Range and scope are shareable through the URL; refreshes preserve table
controls and chart selection.
Game and map filters apply to the whole report, including its comparisons, ## Autoscaling operations
recommendations, and heatmap. Map choices preserve the exact recorded game/map
pair. Range and scope are shareable through the URL. Table searches remain local. The **Autoscaling** section follows the selected period, game, and map and links
to the policies. **Lobbies added** and **Lobbies removed** count successful create
and remove operations, including completions with warnings. Failed and cancelled
operations appear separately. Counts come from retained Provisioning records
linked to autoscaling, independently of occupancy samples; manual operations and
changes in sampled lobby counts do not contribute.
An operation belongs to the period containing its finish time, including both
endpoints. A request made before the period still counts if it finishes within it;
pending operations and finishes outside the period do not count. **Request to
ready** shows the average and maximum request-to-finish duration of successful
creates with valid timestamps, including queue time. Each create has equal weight.
Without valid completed creates, these timings are unavailable, not zero. Verified
readiness does not establish browser visibility or successful external joins.
Scope uses the operation's recorded server name, which encodes its game and map
variant. Game-wide policies do not combine different variants when filtering by
map. Friendly map names, slugs, and unambiguous canonical map names are supported;
an ambiguous engine label such as `zm_transit` is not assigned to a variant.
History survives server removal and policy deletion, subject to Provisioning
retention. Missing or purged operations are not estimated. The policy card's
31-day spare-deficit measurements remain separate from this section.
## Matches and groups
**Matches started** counts retained matches whose recorded start falls within the
selected period, including both endpoints. A missing start event uses the first
recorded match observation, which may be later than the actual start. Active,
completed, and interrupted matches all count, including matches that never reach
round 2 and matches on removed servers. These counts do not use the leaderboard's
completed-match or round qualification rules.
**Average group size** is the average number of distinct recorded player GUIDs per
match. A player counts once across all reports for that match, including late
joins and players who left before its end. Each match has equal weight in the
fleet average; the average is not an average of map averages. This measures
recorded participation over the match's lifetime, not its starting squad or peak
simultaneous occupancy. Active matches can change as more players are recorded.
**Solo games** counts matches with exactly one distinct recorded player. Its share
uses only matches with at least one recorded player. Matches with no recorded
players still count as started, but their group size is unknown and they are
excluded from both the average and solo-share denominator. If no matches have
recorded players, those two values are unavailable rather than zero.
The map table includes these same match statistics. A map with match history in
the selected period remains visible even without occupancy samples; its match
statistics do not establish lobby-hours, availability, or an autoscaling shortage.
Match statistics cover the selected period only and have no previous-period
comparison.
## Players over time
The player chart uses the lobby chart's periods: hourly for 24 hours, and UTC-aligned
24-hour periods for 7 or 30 days. Labels use the report timezone. **Unique players**
counts distinct player GUIDs in every period overlapped by their recorded match
participation, from first to last sighting. Multiple matches in one period count
once, and the same GUID across Black Ops I and II is one player. Matches that began
before the range still contribute participation inside it. These are recorded
participants, not concurrent player counts or the lobby chart's player-hours.
**New players** counts a GUID at its earliest recorded sighting across both games.
Game and Map filters limit unique players to matching retained matches; new players
count only when their original sighting was in a matching match. A regular's first
visit to another map or game does not make them new again. Period boundaries belong
to the following period; a sighting exactly at the range's end counts in the final
period. Range totals count distinct GUIDs across the entire window independently:
daily unique counts cannot be added to get the range total.
Periods before the earliest retained match are gaps, not zero players. This history
boundary is fleet-wide, so filtering to a quiet map does not hide recorded zeroes.
The chart notes limited history and marks periods clipped by the selected range or
the retained-history boundary with hollow points. Totals cover available history
only. Select a point, use Left/Right or Home/End, or open **Inspect a period** for
the exact interval and counts. **View exact time data** provides the same values
in a keyboard-accessible table, with missing history shown as a dash.
History begins when leaderboard recording started; earlier play cannot be recovered.
Players already playing then count as new when first recorded. Participation spans
do not prove continuous presence between reports, and unrecorded matches are absent.
Match retention removes participation, which can shorten or leave holes in this
history. The durable first-seen player identities survive retention, so removing an
old match cannot make a returning player new again. The original map cannot be
recovered from an expired match. The chart remains available without occupancy samples.
## Timeline and tables ## Timeline and tables
The timeline defaults to **Share of captured time**: each stack includes occupied, The timeline defaults to **Average lobbies running**, split into **With players**,
idle, and unknown server time, normalized to 100%. This denominator differs from the **Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its
headline utilization percentage, which excludes unknown time. Switch to **Hours** captured wall time, so changing lobby counts remain visible. Collection gaps are
to compare absolute server time. Periods without captured time remain gaps. excluded from that denominator. The average-lobby axis uses whole-number ticks;
The timeline has labelled axes and marks partial edge periods. Select a period, period details preserve fractional averages. Switch to the share view to compare proportions,
use Previous/Next, or select a bar to inspect its exact interval and values. The or hours to compare absolute lobby time. Periods without captured time remain gaps.
time table supplies the same figures for comparison and assistive technology.
Zero captured time is distinct from an observed empty server. Short durations use
seconds or minutes so a real observation is not rounded to zero.
Map and server tables support numeric sorting and local search. Their searches The timeline has labelled axes and marks partial edge periods. Select a bar or use
apply only to the named table. Known-capacity duration stays next to all-full time. Previous/Next to inspect its exact interval and values. The time table supplies
Server identities retain the technical name; rows describe history grouped by the same figures for comparison and assistive technology. Zero captured time is
name, which may include different instances if a name was reused. **Open current distinct from an observed empty lobby. Short durations use seconds or minutes so
server** appears only when that name is currently registered; its destination a real observation is not rounded to zero.
represents the current registration rather than every instance in the history.
## Comparisons and hosting experiments The map table supports numeric sorting and local search. **Lobbies (avg · peak)**
shows the time-weighted average and peak concurrent lobby count for each recorded
game/map pair, with empty lobby-hours beneath. Sort this column by average count.
The average divides all captured lobby-hours, including unknown occupancy, by
that map's captured wall time; collection gaps and periods without recorded lobbies
for that map are excluded. Peak is the largest captured concurrent count, not the
number of distinct lobby names. Empty lobby-hours sum time with zero reported
players across the map's lobbies. Unknown occupancy is not counted as empty;
empty time is unavailable when no occupancy is known. Lobby counts are unavailable
without captured time. Per-lobby match and activity history is available on server pages.
**Explore this period** adds experimental decision aids below the main timeline: The map table's
**No empty lobby** column uses occupancy alone and retains the known map-time
denominator in its details. Unknown map availability stays distinct from zero
time without a spare. Evidence can be limited by short observations or unknown
occupancy, never by missing player limits.
- **Map capacity pressure** pools all-full map-hours divided by known-capacity ## Comparisons and activity
map-hours across the selected map groups. Overlapping map intervals are counted
separately. The score is not elapsed fleet time or a fleet health score; its
denominator and contributing map count remain visible.
- **Previous-period comparisons** use the preceding equally long window with
identical filters. Utilization changes are percentage points; player-hours
changes are relative percentages. A previous observed zero can show **New**.
Missing evidence remains unavailable. Partial retained history is labelled;
a 30-day comparison usually has little previous history because default retention is
31 days. Both periods' known time is shown. Bounds cannot establish that
collection was uninterrupted, and changes are not adjusted for fleet size.
- **Activity heatmap** groups captured time by weekday and hour in its displayed
timezone. Color represents occupied / known server time. Exact cell values
include unknown time and contributing dates. No-data cells differ from observed
zero utilization; repeated DST hours contribute their actual elapsed time.
- **Hosting experiments** are generated automatically from the selected map
observations. They are advisory and never create, restart, or destroy a server.
Hosting Rules v1 deliberately offer experiments even with small samples: Previous-period comparisons use the preceding equally long window with identical
filters. Average lobby counts compare recorded supply, while player-hours changes
are relative percentages. A previous observed zero can show **New**. Missing
evidence remains unavailable. Partial retained history is labelled; a 30-day
comparison usually has little previous history because default retention is
31 days. Collection gaps are excluded, and comparisons do not prove uninterrupted
collection or adjust player-hours for fleet size.
| Recorded condition | Suggested experiment | The activity heatmap groups captured time by weekday and hour in its displayed
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | timezone. Color represents occupied / known lobby time. Exact cell values include
| At least 20% of known capacity time had every lobby full | Trial one additional lobby during busy hours. | unknown time and contributing dates. No-data cells differ from observed zero;
| Capacity unavailable and players present for at least 80% of known server time | Tentatively trial an extra lobby using occupancy as a proxy. | repeated DST hours contribute their actual elapsed time. Occupancy share describes
| Players present for at most 10% of known time, with under 1% measured pressure or unavailable capacity | Review one idle lobby or a shorter hosting window while preserving access to the map. | how the fleet was used, but can change simply because a policy adds or removes
| Occupancy unavailable | Check telemetry/RCON reporting and watch for known player counts. | spares. Read it alongside player-hours, lobby counts, and empty-lobby availability.
| Other observations | Check missing capacity or keep the lineup and watch peaks. |
Each suggestion includes evidence and an outcome to watch. Unknown capacity, ## Autoscaling signals
less than one hour of relevant known evidence, or substantial unknown captured
status makes the suggestion tentative. These thresholds are product hypotheses; Signals compare each map's observations with its **current** applicable policy.
occupancy does not establish failed joins or a need for additional capacity. Game-wide policies apply independently to each supported map variant. Each signal
Suggestions use historical observations. Check the current lineup before trying links to the policy on **Fleet → Autoscaling**; a map without a policy gets a link
a change; a retained map may no longer have a hosted lobby. to add one. Signals are advisory and never change a policy or a lobby.
A global pause appears once above the signals. Historical shortage signals remain
visible while the fleet is paused, including suggestions to review the maximum
or spare target. Review these findings and current settings before resuming.
An individual policy's own pause or disabled state still prompts a policy review.
| Recorded condition | Suggested action |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| No empty lobby for at least 2% of known map time, mostly at or above the current maximum | Review raising the policy maximum. |
| No empty lobby for at least 2% of known map time, mostly below the current maximum | Review the spare target and check creation time or blockers. |
| An applicable policy is off or paused | Review the policy and its pause or activation conditions. |
| No applicable policy and known occupancy is available | Consider adding an autoscaling policy to keep spares available. |
| Occupancy unavailable | Check telemetry/RCON reporting and wait for known player counts. |
| No shortage signal | Keep watching empty-lobby availability. |
Evidence includes no-empty map time and how it splits above/below the current
maximum. Less than one hour of known map availability, less than 80% known map
availability, or less than 80% known lobby time makes advice tentative. Player limits are not needed. These
thresholds are advisory rules, not demand forecasts.
Policy settings may have changed during the selected period. Comparing historical
counts against today's maximum does not establish which settings applied then or
whether autoscaling was enabled. No reported empty lobby also does not prove
failed joins or unmet demand. Review provisioning delays, blockers, host resources,
and game-key availability before changing targets.
## Definitions and collection ## Definitions and collection
- Utilized means a successful status response reported at least one player. - **With players:** a successful observation reported at least one player.
- Idle means a successful response reported zero players. - **Empty spare:** an observation reported zero players.
- Unknown covers failed checks, provisioning, and unavailable containers; it is excluded from utilization percentages. - **Unknown:** failed checks, provisioning, or otherwise unavailable occupancy.
- Player-hours integrate the reported player count over observed time. Bots are included if the server counts them as players. - **Player-hours:** reported players × observed time. Bots count if the server includes them.
- All lobbies full measures map wall time when every tracked lobby in that game/map group has a known capacity and is full. Missing capacity or status makes that interval unknown. This is a capacity-pressure indicator, not a count of rejected joins or unmet demand. - **Empty lobby available:** at least one lobby on the game/map reported zero players.
- **No empty lobby:** at least one lobby reported occupancy, and none reported zero. Mixed positive and unknown counts mean no empty lobby was reported; an unknown lobby might actually have been empty.
- **Unknown map availability:** every lobby on the game/map had unknown occupancy. This interval is excluded from the known-map denominator and is never counted as a spare shortage.
Observations are recorded by the existing status worker. Each remains valid until the next collection, up to the 90-second freshness limit. Longer manager outages are excluded. Charts split time at UTC hour/day boundaries; partial periods remain partial. Map variants use the configured variant when it matches the reported map. Observations come from the existing status worker. Each complete collection stays
valid until the next collection, up to the 90-second freshness limit. Longer gaps
are excluded, not counted as unknown or zero lobbies. Captured wall time includes
intervals with at least one recorded lobby in the selected scope. Unknown lobby
time and unknown map time use different units: several unknown lobbies on one map
contribute several lobby-hours but only one interval of unknown map availability.
Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity. Charts split time at UTC hour/day boundaries; partial periods remain partial.
Map variants use the configured variant when it matches the reported map. Player
capacity may be retained in raw samples, but no metric or signal requires
`sv_maxclients` or an assumed four-player limit.
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Admin → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup. Occupancy collection begins after this feature is installed; earlier occupancy cannot be
reconstructed. Samples default to 31 days of retention; **Admin → Settings** can
change this period. Cleanup runs during collection and in the background. Samples
survive removal of a server and contain no player identities.
Match statistics use existing saved match history, independently of occupancy
samples and raw telemetry diagnostics. **Match history retention** under
**Admin → Settings → Matches & players** defaults to indefinite. Expiring a
completed or interrupted match also removes its player participation and changes
these statistics; active matches are protected. Increasing retention cannot
restore expired matches. Saved match history survives removal of a server.
## API ## API
`GET /api/metrics` supplies the same report as the page and disables response `GET /api/metrics` supplies the same report as the page and disables HTTP response
caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game), caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game),
and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults; and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults;
invalid scope syntax is normalized. Fleet collection timestamps remain separate invalid scope syntax is normalized. Fleet collection timestamps remain separate
from selected-scope evidence timestamps. Collection boundaries are determined from selected-scope evidence timestamps. Collection boundaries are determined
before scope filtering, so samples from other groups still end a batch interval. before scope filtering, so samples from other groups still end a batch interval.
`running` contains `average`, `peak`, and `capturedHours`; timeline points include
their own `capturedHours`. Report-level `matches` and each map row's `matches`
contain `started`, `withRecordedPlayers`, `averageGroupSize`, `solo`, and
`soloShare`. `withRecordedPlayers` is the denominator for the group average and
solo share; `soloShare` is a percentage from 0 to 100. When that denominator is
zero, `averageGroupSize` and `soloShare` are `null`, `solo` is zero, and `started`
retains the recorded number of starts. Match scope uses the exact saved `game` and `map_name`
labels, and its time window uses the saved `started_at` timestamp. Occupancy
collection and evidence timestamps remain independent of match history.
`players` contains the separately deduplicated range totals `uniquePlayers` and
`newPlayers`, plus `historyStartAt` (the earliest retained match, in epoch
milliseconds). Every `points` entry also has `uniquePlayers`, `newPlayers`, and
`playerHistoryPartial`. Counts before retained history are `null`; known periods
with no scoped participation are zero. Player charts treat either `partial` or
`playerHistoryPartial` as partial. Participation timestamp changes and durable
first-seen identity changes invalidate the report cache.
Map rows expose `noEmpty`, `availabilityObserved`, and
`availabilityUnknown` durations, plus current `policy` context and
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
map durations and coverage. `autoscalingPaused` reports the current global pause
separately from each policy's own `pausedReason`. Policy edits and global pause changes invalidate
cached metrics reports, as do changes to the match and player participation data
used by the report.
`autoscaling` contains `added`, `removed`, `failed`, `cancelled`, `readinessSamples`,
`readinessMeanSeconds`, and `readinessMaxSeconds`. Counts use `finished_at` in the
selected window; readiness uses `finished_at - started_at` for successful creates
with nonnegative, valid durations. Both timing values are `null` when there are
no readiness samples. Changes to Provisioning outcomes or autoscaling source
links invalidate cached reports, including when no new occupancy is collected.
+255
View File
@@ -0,0 +1,255 @@
# 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. Each case has a dedicated,
shareable page with the player identity, evidence, staff notes, linked restrictions,
and an activity timeline. These pages require moderation access. Give a case a
priority and assign it to an eligible administrator or moderator. Filter the case
queue by status, priority, or assignment; active cases appear first, ordered by
priority and most recent update.
Use **Change status** to move a case between **Open**, **In progress**, and
**Closed**, recording a reason for each transition. Assignment is separate:
a case can have an owner at any stage. To resume a closed case, change its status
to Open or In progress. Evidence, assessments, actions, and earlier decisions
remain in its history. Editing case details does not change its status.
Existing closed outcomes are migrated to Closed. Previously escalated cases
become In progress. Their previous statuses are recorded in history; existing
decision reasons, action references, assignments, and follow-ups are preserved.
Use **Edit assessment** to keep a concise current summary, list the applicable
rules, and record a finding for each concern: hate speech, harassment, threats,
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
the evidence; case status tracks whether work remains. Record the decision and
its rationale in the assessment summary.
Every saved assessment retains its author, timestamp, and complete previous
revision. Assessments can be revised after closure without reopening the case.
Before opening a case, review existing cases for the same game and GUID. A case
also shows same-player candidates; a shared archived match is a useful potential
duplicate signal, not a finding that two reports are identical. Link cases as
**Same incident**, **Appeal of**, or **Duplicate of**, recording why they belong
together. Same-incident links may include different players; appeals and duplicates
must concern the same game and player. Links appear from both cases with their
direction made explicit. Self-links and circular appeal/duplicate chains are
rejected. Removing a mistaken relationship requires a reason and preserves its
history. Relationship changes do not close or reopen either case.
Schedule a follow-up using a date and time, or **Review when a linked mute expires**.
Custom date entry uses your browser's local time; displayed dates follow your time
zone preference. Reviews remain scheduled after a case closes or reopens. Use
**Overdue reviews** to find due work across open and closed cases, and clear the
follow-up with a reason when the review is complete.
In-app case updates notify the assignee and case creator when another staff member
adds evidence or a staff note. Assignment changes notify the previous and new assignees
and the creator, excluding the person making the change. **Your unread updates**
is personal to your account. Open a case to review and mark existing updates
read; notifications arriving afterward stay unread. Queue and case pages refresh
every 15 seconds while visible. These are in-app notifications, with no email or
external messaging delivery.
Apply a ban or mute directly from an active case. The case fixes the target game
and GUID and records the resulting restriction alongside the evidence. The same
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
restriction leaves the case active so staff can finish reviewing it. Changing case status
records workflow progress; it does not send a warning, create a restriction, or
revoke one. Linked restrictions can be revoked separately with
the required permission and reason, including after a case closes. Reopening a
case also leaves its restrictions in place. Check enforcement coverage after
applying or revoking a restriction.
Use **Link existing action** to associate a warning, kick, private message, ban, or
mute already recorded for this player with the case. This preserves the original
action, author, time, delivery state, and expiry; it does not issue another action
or extend a restriction. The same action can support more than one related case.
Linking requires the current case revision and a reason, and appears in its timeline.
Linked records show what actually happened, including failed or uncertain delivery.
They remain visible when a case closes; status changes do not require choosing
or re-entering an action.
Staff notes are explicitly labeled as notes. In match chat, select up to 100
messages and choose **Attach to case** to open a case or add them to an existing
case for the selected subject. Select at least one message from that subject;
messages from other participants can be selected alongside it. Each attachment
preserves selected messages and, by default, three surrounding messages on either side, with
names, GUIDs, original timing, and highlighted subject messages. The selection
preview shows the excerpt before saving. Server verification uses stable archived
message identifiers; repeated text remains separate occurrences, and attaching
the same selected messages again does not duplicate evidence. Exact-text
attachments remain available and include every matching occurrence, without the
former ten-occurrence limit.
Snapshots remain readable after source-match cleanup. 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.
Open **Chat archive** from Moderation to include all recorded matches, including
round-one games, interrupted games, and games missing an end report. Archive
access requires moderation read permission. Filter by server, game, map, status,
or date and open a match to select its preserved messages. Incomplete matches
show their observed state rather than final results. Ordinary match history and
leaderboard qualification are unchanged.
Both public (`say`) and team (`sayteam`) log messages are captured. Team messages
carry a **Team chat** label in chat and saved excerpts. Collection runs without a live
viewer and checks surviving logs once after startup, including idle servers.
Archive coverage still depends on the source log and accepted match telemetry:
logs that were deleted or rotated before collection, or old clock segments that
cannot safely be associated with a match, cannot be reconstructed. The collector
retains a bounded log tail (up to 2,000 lines / 256 KiB); it is not a full historical
log import. Already saved case excerpts survive later source cleanup.
Case decisions, edits, and restrictions check the current revision; refresh before
retrying a decision if another staff member has changed the case. 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 reopening, 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.
+283 -46
View File
@@ -12,9 +12,11 @@ the agent owns local execution and its durable operation journal.
and roles; agent endpoints authenticate their own host credentials. See and roles; agent endpoints authenticate their own host credentials. See
[Users and access](authentication.md) for setup and password recovery. [Users and access](authentication.md) for setup and password recovery.
2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address, 2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address,
and generate an enrollment. Run the displayed command on Debian 12/13 amd64. and generate an enrollment. Run the displayed command on the Debian 12/13
3. The installer validates the platform, installs Python 3 and an absent Docker amd64 host. It downloads the installer directly from the public repository;
installation, verifies the downloaded agent and updater SHA-256 over HTTPS, [release trust](#provision-release-trust) is fetched automatically.
3. The installer validates the platform, installs Python 3, `gpgv` and an absent Docker
installation, verifies the release signature and agent/updater SHA-256 checksums,
enrolls it and enables `electric-cherry-agent.service` and its automatic update enrolls it and enables `electric-cherry-agent.service` and its automatic update
timer. It needs no Git, Node.js or local build. timer. It needs no Git, Node.js or local build.
4. In **Game Files**, upload your complete local folder under T5 or T6. Include 4. In **Game Files**, upload your complete local folder under T5 or T6. Include
@@ -41,6 +43,47 @@ the agent owns local execution and its durable operation journal.
A fresh destination host downloads centrally stored game files without manual A fresh destination host downloads centrally stored game files without manual
copying or per-container setup. EC does not supply the game content itself. copying or per-container setup. EC does not supply the game content itself.
### Provision release trust
The public Electric Cherry repository is the authority for both the installer
and release-signing public keys. The enrollment command downloads `agent/install.sh`
from that repository over HTTPS, saves it locally, and runs it only after the
download succeeds. No manual key transfer is required.
During installation and **before every update check**, the host downloads the
binary OpenPGP public keyring directly from this fixed URL:
```text
https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg
```
The URL is built into the trusted installer and updater. Controller configuration,
release metadata and enrollment credentials cannot change it. The host verifies
HTTPS certificates, rejects redirects and incomplete, empty or oversized key
responses, and requests revalidation with `Cache-Control: no-cache`. It uses the
fresh keyring only for that check and deletes its temporary copy afterward.
There is no fallback to a previous host keyring, cached download or controller key.
If the repository is unavailable, new installations stop and automatic updates
wait for the next check; the installed agent and game containers keep running.
Local recovery of an interrupted update does not require the repository.
This deliberately trusts the repository account, its `main` branch and HTTPS
hosting as authorities for root code on game hosts. They must be outside the
controller's write access: a compromised controller must not also have a token,
mount or server access that can change these repository files. A repository
compromise can replace the key or installer. Before initial enrollment, check that
the command downloads from
`https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/install.sh`,
since a compromised controller UI could display a different command. Host provisioning systems can use that known URL directly.
Before deploying the first signed release, the publisher must export and commit
its **public** key as `agent/release-signing.gpg` on `main`, following the
[signing workflow below](#publishing-signed-releases). No production key is
created by packaging. Until the public key and signed release are published,
installation and updates fail closed. Forks must change the fixed repository URLs
in the installer, updater and enrollment command to their own trusted repository;
there is intentionally no controller setting to override them.
### Docker installation ### Docker installation
The installer uses Debian's Docker packages: `docker.io` on Debian 12, plus The installer uses Debian's Docker packages: `docker.io` on Debian 12, plus
@@ -87,8 +130,11 @@ Agent 1.8.5 adds incremental game-log reads for chat collection. After the initi
tail, the controller requests only appended bytes, handles rotation and truncation, tail, the controller requests only appended bytes, handles rotation and truncation,
and buffers incomplete lines. Unchanged logs need no content transfer or chat and buffers incomplete lines. Unchanged logs need no content transfer or chat
parsing. Background collection skips offline hosts, stopped servers and known parsing. Background collection skips offline hosts, stopped servers and known
empty servers, with one final read after players leave. Opening chat still reads empty servers, with one final read after players leave. Opening chat reuses the
on demand. Older agents use tail reads until they update. worker's saved reader state and only starts an on-demand read when the previous
read completed at least 15 seconds ago. This limit is shared across viewers and also
applies to missing logs and failed reads. Older agents use tail reads until they
update.
### New game files and cached versions ### New game files and cached versions
@@ -167,45 +213,135 @@ directory, while file writes reject symlinks and paths outside that directory.
## Automatic agent updates ## Automatic agent updates
New installations enable automatic agent updates by default. Each host checks New installations enable automatic agent updates by default. Each host checks
its controller's release over HTTPS about every five minutes, beginning about the repository public key and then its controller's release over HTTPS about
two minutes after boot, with up to 30 seconds of jitter. Updating the controller every five minutes, beginning about two minutes after boot, with up to 30 seconds
therefore makes its compatible, newer agent release available to enrolled hosts. of jitter. Updating the controller
therefore makes its compatible, newer signed agent release available to enrolled hosts.
There is no controller command to schedule and no update button to press. There is no controller command to schedule and no update button to press.
**Existing hosts need the current installer once.** From the host's **Agent **Existing hosts must migrate to updater protocol 2 with the trusted installer.**
access**, generate an installer command and rerun it on the same machine, with Protocol 1 bootstraps trust controller-provided checksums and remain exposed to
the same controller and data directory. Do not use `--reenroll` for this upgrade. controller-forged updates until this migration is complete. Publishing a signed
The installer preserves host identity, credentials, game files and running release does not upgrade those bootstraps: they request an installer rerun when
containers. Agent version 1.2.0 includes automatic update support; the host page they see protocol 2, but a compromised controller could still serve protocol 1.
shows **Setup required** until a fresh heartbeat confirms the updater is installed.
From the host's **Agent access**, generate and run the command, which fetches
the repository installer and public key automatically. Use the same controller
and data directory. Preserve a custom root with `--data-dir`;
do not use `--reenroll` for this upgrade. To pause old automatic checks during
migration, run `sudo systemctl stop electric-cherry-update.timer`. This does not
interrupt a running update worker; let any active update or journal recovery
finish before installing. The installer preserves host identity, credentials,
game files and running containers, replaces the bootstrap, and enables its timer
on success. Hosts without an updater show **Setup required** until a heartbeat
confirms installation. Hosts using an earlier protocol 2 bootstrap with a
manually provisioned keyring also need this installer rerun to adopt repository
refresh: the updater does not replace itself automatically. An old local
`/etc/electric-cherry/release-signing.gpg` file is ignored by the new bootstrap.
The **Automatic agent updates** panel reports the running version, target The **Automatic agent updates** panel reports the running version, target
version, last check, progress and any error. Offline hosts show their last version, last check, progress and any error. Offline hosts show their last
reported status. Deferred updates report why they are waiting: for example an reported status. Deferred updates report why they are waiting: for example an
active agent operation, an unavailable controller or a newer updater bootstrap. active agent operation, an unavailable repository/controller or a newer updater bootstrap.
Checks also defer while the agent service is stopped; an update does not start a Checks also defer while the agent service is stopped; an update does not start a
service that was stopped outside the update workflow. service that was stopped outside the update workflow.
The independent updater holds the same execution lock, verifies the release The independent updater holds the same execution lock, authenticates the exact
checksum, version and supported protocol, replaces agent code, and restarts the manifest bytes with the freshly fetched repository keyring before parsing them,
agent service. It never restarts Docker or game containers. It confirms an verifies the release checksum, version and supported protocol, replaces agent
code, and restarts the agent service. It never restarts Docker or game containers. It confirms an
authenticated heartbeat from the new agent process before accepting the update; authenticated heartbeat from the new agent process before accepting the update;
failure restores the previous agent code. Its durable journal permits recovery failure restores the previous agent code. Its durable journal permits recovery
after a host reboot or interrupted update. after a host reboot or interrupted update.
The updater only installs newer compatible versions; rolling the controller back The updater only installs newer compatible versions; replaying an older signed
does not downgrade agents. A rejected version/checksum pair stays blocked until release or rolling the controller back does not downgrade agents. Signing cannot
force a controller to publish newer releases or prevent it withholding updates.
A rejected version/checksum pair stays blocked until
the controller publishes a different pair that is still newer than the installed the controller publishes a different pair that is still newer than the installed
agent. Inspect the reported error when a host agent. Inspect the reported error when a host
shows **Needs attention**. The updater itself is a stable bootstrap component: shows **Needs attention**. The updater itself is a stable bootstrap component:
it changes only when the installer is rerun. If a future release requires an it changes only when the installer is rerun. If a future release requires an
unsupported updater protocol, rerun that release's installer on the host. unsupported updater protocol, rerun that release's installer on the host.
### Publishing releases and inspecting updates ### Publishing signed releases
When publishing changed agent code, bump its numeric `VERSION` in When publishing changed agent code, bump its numeric `VERSION` in
`agent/ec_agent.py` and rebuild the controller. Packaging derives the manifest `agent/ec_agent.py`. Finalize and review all source changes before packaging and
version and checksums from the sources; an already installed version is never signing. An already installed version is never replaced just because its bytes
replaced just because its bytes changed. changed.
Use a separate offline publisher machine with GnuPG. Keep the private key and its
GnuPG home outside the checkout, Docker build context, controller, and CI. For
example, create a dedicated signing key and inspect its full fingerprint:
```sh
ec_signing_home=/secure/electric-cherry-signing
install -d -m 0700 "$ec_signing_home"
gpg --homedir "$ec_signing_home" --quick-generate-key 'Electric Cherry Release Signing' ed25519 sign 2y
gpg --homedir "$ec_signing_home" --list-keys --with-fingerprint
```
Protect the key with a passphrase and keep its backup and revocation certificate
outside release artifacts. Set the full fingerprint of the signing key, then
export only its public key into the reviewed checkout:
```sh
ec_signing_fingerprint=REPLACE_WITH_FULL_SIGNING_KEY_FINGERPRINT
gpg --homedir "$ec_signing_home" --export "$ec_signing_fingerprint" > agent/release-signing.gpg
```
Inspect the export with `gpg --show-keys --with-fingerprint agent/release-signing.gpg`
and confirm the expected publisher fingerprint. Commit and publish that public
file to the repository's `main` branch before enrolling hosts or releasing code
signed by this key. Never export secret keys into the repository. A YubiKey-held
signing key works with the same workflow: export its public certificate from your
GnuPG home and use the full signing key/subkey fingerprint with the signer. Keep
the YubiKey on the publisher workstation, outside controller and CI access.
From the reviewed checkout on the publisher machine, package and explicitly sign:
```sh
npm run agent:package
python3 agent/sign_release.py --gnupghome "$ec_signing_home" --fingerprint "$ec_signing_fingerprint"
```
The signer defaults to `static/agent`; `--release-dir` selects another packaged
release directory. It checks that the manifest matches the agent, updater and
installer bytes, then produces the ASCII-armored detached `manifest.json.sig`.
The manifest records their SHA-256 hashes as `sha256`, `updaterSha256`, and
`installerSha256`, along with version, platform and protocol metadata. The
installer hash lets recipients check a publisher-distributed installer against
the authenticated manifest before running it.
Commit or distribute the finalized `static/agent` public artifacts together,
including the signature, before building the controller image from the same
reviewed source revision. Never include private keys or the signing home.
Normal `npm run build`, development startup,
and Docker builds never sign: packaging preserves a signature only when the
manifest remains byte-for-byte identical and removes it after any covered source
change. Repackage, review and sign again after such a change. Unsigned builds can
run the controller, but hosts fail closed and leave their installed code intact.
### Signing-key rotation and removal
Review new fingerprints on the publisher workstation, then replace
`agent/release-signing.gpg` on the repository's `main` branch. Every host uses the
new keyring on its next update check; no host visits or controller changes are
needed. For a planned transition, export both approved public keys into this file,
publish the combined keyring, then publish releases signed by the new key. Remove
the retired key once the controller serves the new signatures. Checks already in
progress use the keyring they fetched at the start of that check.
For a compromised key, remove it from the repository keyring immediately and
publish releases signed by a replacement key. Hosts with the refreshed keyring
reject the old signer even if the controller still serves its release. Publishing
a revocation certificate or allowing a key to expire is insufficient: [GnuPG's
`gpgv` documentation](https://www.gnupg.org/documentation/manuals/gnupg/gpgv.html)
states that it does not check key expiration or revocation. Keep the keyring's
contents restricted to currently trusted keys. Removing all keys blocks new
installations and updates; it does not stop or remove already installed agents.
### Inspecting updates
The installer places the updater at The installer places the updater at
`/usr/local/libexec/electric-cherry-update.py` and enables `/usr/local/libexec/electric-cherry-update.py` and enables
@@ -221,6 +357,40 @@ journalctl -u electric-cherry-update.service -n 100
## Placement and resource capacity ## Placement and resource capacity
### Host temperature
Agent **1.10.0** reports temperature sensors exposed by supported Linux hwmon
drivers without installing additional packages. **Hosts** shows CPU temperature
beneath CPU usage. A host's **Overview → Inventory** shows the selected reading
and expandable **Temperature sensors**, including per-core and storage details.
Stale readings are marked as last reported; absent or failed readings are
**Unavailable**, never zero or a readiness failure.
**Fleet → Capacity → Temperature history** uses the existing host selector and
Last hour / 24 hours / 7 days controls. **All hosts** displays a separate CPU line
for each host. Select one host to add its NVMe composite and chipset lines using
the legend. Hover, tap, or use the arrow keys on the chart to inspect a period.
Each host page's **Capacity** button opens the view filtered to that host.
The game filter affects launch estimates, not temperatures.
The CPU reading is the hottest reported package, falling back to a physical
AMD die/CCD and then a core reading. AMD Tctl cooling-control values are excluded
because they may include an offset. Supported device drivers are `coretemp`,
`k10temp`, `nvme` and `pch_*`; sensor availability depends on hardware and drivers.
Virtual machines and older agents may have no supported readings. Missing,
faulty and disabled sensors are skipped individually.
Temperature history starts after the agent upgrade. The controller stores one
peak per sensor per minute, limited to the CPU summary and up to sixteen NVMe
composite/chipset inputs per host. Longer chart periods show the maximum of
those peaks; a period without readings remains a gap. Brief gaps within an
aggregated period may still leave a peak for that period. **Host resource history
retention** applies to CPU, memory and temperature samples (seven days by default;
zero keeps them indefinitely). Series colors identify hosts/sensors and do not
indicate thermal safety. Temperature does not change placement or trigger alerts.
### CPU and memory capacity
### Host inventory and eligibility ### Host inventory and eligibility
The Inventory panel shows CPU usage with agent 1.4.2 and later. It measures The Inventory panel shows CPU usage with agent 1.4.2 and later. It measures
@@ -370,22 +540,32 @@ The agent uses HTTPS, operating-system certificate validation, outbound heartbea
and polling; Docker has no exposed remote API. Agent endpoints reject HTTP unless and polling; Docker has no exposed remote API. Agent endpoints reject HTTP unless
`EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's `EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's
insecure development option is limited to loopback. Public release assets contain insecure development option is limited to loopback. Public release assets contain
no credentials. The release checksum is authenticated by the controller's HTTPS no credentials. HTTPS protects transport; an offline publisher signature over the
origin; there is no separate offline release-signing infrastructure. release manifest authenticates agent and updater code using the public keyring
fetched directly from the trusted repository before each check. Controller
credentials do not grant release-signing authority or control the key source.
Browser sessions do not authorize agents. Similarly, host credentials never grant Browser sessions do not authorize agents. Similarly, host credentials never grant
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`, access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
configured in each reporter's authorization header; see the configured in each reporter's authorization header; see the
[telemetry contract](beta-test-plans.md#game-reporter-protocol). [telemetry contract](beta-test-plans.md#game-reporter-protocol).
Hosts offers credential rotation and revocation. Rotation persists the new secret Hosts offers credential rotation and revocation. Replacing a credential requires
on the agent before switching controller credentials, so a lost response can be an open administrator-requested rotation operation for that host. The controller
retried using the same proposed secret. Revocation affects one host only and does replaces the credential and completes that operation in one transaction. Rotation
not stop its running game containers. Re-enrollment requires a fresh token and an persists the new secret on the agent before switching controller credentials, so
explicit installer `--reenroll` when local credentials already exist. a lost response can be retried using the same proposed secret. A retry of the
active secret only acknowledges it; it cannot authorize another replacement.
Revocation affects one host only and does not stop its running game containers.
Re-enrollment requires a fresh token and an explicit installer `--reenroll` when
local credentials already exist.
## Host and game-file API ## Host and game-file API
Open **Fleet → Hosts → Operation history** for retained operations across all
hosts, including deleted hosts. A host's **Operation history** action scopes the
same history to that host. Filters and pagination retain the selected scope.
- `GET /api/hosts/:id`: host details and deletion blockers. - `GET /api/hosts/:id`: host details and deletion blockers.
- `GET /api/hosts/operations?page=1&filter=important`: operation history across - `GET /api/hosts/operations?page=1&filter=important`: operation history across
hosts, newest first, with 25 summaries per page and filtered total/page counts. hosts, newest first, with 25 summaries per page and filtered total/page counts.
@@ -397,9 +577,11 @@ explicit installer `--reenroll` when local credentials already exist.
or `409` when the agent needs updating or unresolved ownership remains. or `409` when the agent needs updating or unresolved ownership remains.
- `POST /api/agent/enroll`: consume enrollment token and return host credentials. - `POST /api/agent/enroll`: consume enrollment token and return host credentials.
- `GET /api/agent/release/manifest.json`: public agent version, protocol and - `GET /api/agent/release/manifest.json`: public agent version, protocol and
checksum plus `updaterProtocolVersion` and `updaterSha256` for installer bootstrap. checksum plus `updaterProtocolVersion`, `updaterSha256`, and `installerSha256`.
- `GET /api/agent/release/manifest.json.sig`: detached publisher signature over
the exact manifest bytes; absent for an unsigned build.
- `GET /api/agent/release/ec-agent.py` and `ec-agent-update.py`: public release - `GET /api/agent/release/ec-agent.py` and `ec-agent-update.py`: public release
source, verified against that HTTPS manifest before installation. source, verified against the signed manifest before installation.
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches. - `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs. - `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result. - `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
@@ -475,6 +657,13 @@ restart and create complete only after post-operation health evidence; creation
and rebuild also verify the selected map and running Plutonium revision. The and rebuild also verify the selected map and running Plutonium revision. The
base-game library and runtime revision are independent records. base-game library and runtime revision are independent records.
Agent 1.10.1 prepares and verifies the complete replacement file set before
stopping the existing container, within the 64 MiB operation limit. Staged-file
comparison, replacement validation and writing reuse those bytes, so losing
artifact connectivity after preflight does not prevent replacement. A preflight
download failure leaves the original container running. Prepared bytes last only
for that operation attempt; held or failed work must prepare its files again.
## Rebalance instances ## Rebalance instances
Open **Fleet → Capacity → Rebalance** to spread existing instances across active Open **Fleet → Capacity → Rebalance** to spread existing instances across active
@@ -576,10 +765,11 @@ and invalidates unused installer links. A separate cleanup service removes the
host's downloaded game libraries, instance files, agent journal and configuration, host's downloaded game libraries, instance files, agent journal and configuration,
owned containers, and recorded runtime images unused by other containers. It owned containers, and recorded runtime images unused by other containers. It
stops and removes the agent service and executable, automatic update timer, stops and removes the agent service and executable, automatic update timer,
update service, updater executable and update state, then removes its own files. update service, updater executable and update state (including any legacy local
signing keyring), then removes its own files.
The controller hides the host and revokes its ordinary credentials only after The controller hides the host and revokes its ordinary credentials only after
receiving the final uninstall receipt. Adding the machine again requires a new receiving the final uninstall receipt. Adding the machine again requires a new
host enrollment. host enrollment; the installer fetches release trust from the repository again.
Game folders uploaded through Electric Cherry belong to the controller. Those Game folders uploaded through Electric Cherry belong to the controller. Those
original uploads remain available for other hosts and future deployments; only original uploads remain available for other hosts and future deployments; only
@@ -834,14 +1024,19 @@ npm run build
``` ```
The build packages the agent, standalone updater and installer into the static The build packages the agent, standalone updater and installer into the static
release, generates the agent/updater checksum manifest and serves those same release, generates their checksum manifest and serves those same assets in the
assets in the Node-adapter image. Development serves the freshly packaged static Node-adapter image. Sign production artifacts using the
release even when an older production build exists. The Python programs use only [offline workflow](#publishing-signed-releases) before building. Development
the standard library. serves the freshly packaged static release even when an older production build
exists. The Python programs use only the standard library; signature verification
also requires the host's `gpgv` executable. Real signing tests require `gpg` and
`gpgv`, installed by CI; local runs skip those tests when either tool is absent.
Automated tests cover enrollment/authentication, rotation/revocation, host Automated tests cover enrollment/authentication, rotation/revocation, host
readiness, scoped ports, immutable library publication, streaming/ranges, readiness, scoped ports, immutable library publication, streaming/ranges,
checksums, interrupted downloads, instance generation ownership, local occupancy signed manifests, forged checksums, unknown signing keys, repository key refresh
and rotation, repository failures, interrupted downloads,
instance generation ownership, local occupancy
holds, controller restart/reconnect, ambiguous results, occupied campaign staging, holds, controller restart/reconnect, ambiguous results, occupied campaign staging,
activation before images, collections and draining. Host deletion tests cover activation before images, collections and draining. Host deletion tests cover
ownership guards, interrupted cleanup, uninstall receipts, credential revocation ownership guards, interrupted cleanup, uninstall receipts, credential revocation
@@ -852,13 +1047,18 @@ A real Debian/Docker/Plutonium acceptance run is still required before productio
rollout. Automated simulations do not establish successful deployment on real rollout. Automated simulations do not establish successful deployment on real
game hosts. Run: game hosts. Run:
- Installer on Debian 12 and 13 amd64, then rerun without changing identity/data. - Publish the public key in the repository and run the generated enrollment
- Bootstrap an existing host with the current installer, then publish a newer command on Debian 12 and 13 amd64 without a local keyring, then rerun without
compatible agent release. Verify a timer-driven update, its authenticated changing identity/data.
- Migrate a protocol 1 host with the trusted installer, then publish a newer
compatible signed agent release. Verify a timer-driven update, its authenticated
heartbeat and unchanged game containers. Exercise an active-operation hold, heartbeat and unchanged game containers. Exercise an active-operation hold,
bad checksum, failed startup/heartbeat rollback, controller downgrade, network missing/invalid signatures, an unknown key, a forged manifest matching changed
loss and host reboot during update. Verify updater protocol mismatch requests code, bad checksum, failed startup/heartbeat rollback, controller downgrade,
an installer rerun. network loss and host reboot during update. Verify updater protocol mismatch
requests an installer rerun. Replace the repository keyring and confirm the
next check rejects the removed signer and accepts the new signer; block access
to the repository and confirm no cached key is used and local rollback still works.
- T5 and T6 deployments and a mixed collection from a fresh destination host. - T5 and T6 deployments and a mixed collection from a fresh destination host.
- Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies; - Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies;
verify the running revision and compare first-start downloads and launch times verify the running revision and compare first-start downloads and launch times
@@ -883,3 +1083,40 @@ game hosts. Run:
- Controller restart, agent restart and network loss before execution, during - Controller restart, agent restart and network loss before execution, during
execution and after execution but before acknowledgement. Verify the same execution and after execution but before acknowledgement. Verify the same
operation IDs and absence of duplicate containers/restarts. operation IDs and absence of duplicate containers/restarts.
### Bank journals during server teardown
Agent 1.9.0 introduced `bank.settlement`: removal, rebuild, migration and creation
cancellation finish pending TWS bank decisions before deleting the old runtime.
An unavailable controller or corrupt journal leaves the operation unresolved
and preserves storage. Update all agents before enabling the
[player bank](bank.md). EC generates and injects bank credentials per instance;
no token map or manual server-secret provisioning is required.
Agent **1.9.1+** recognizes both the fixed public game-bank endpoint
`https://ec.thewrightserver.net/api/bank` and its own legacy controller-derived
bank endpoint in journals. It stops generating `tws_bank_url` in the private
game configuration. Agent transport still uses the enrolled controller URL and
host credential; journal URLs never determine request destinations. Upgrade
agents first, then [rebuild servers changing bank endpoints](bank.md#upgrading-from-a-controller-derived-game-endpoint)
to drain pending decisions before the new scripts start. See the
[agent changelog](../../agent/CHANGELOG.md) for release and validation details.
### AFK journals during server teardown
Install agent **1.12.0+** before enabling [persistent AFK reserves](afk.md).
The `afk.settlement` capability adds stopped-process recovery for
`tws-afk/t5/` and `tws-afk/t6/` journals. The agent uses its enrolled controller
and assigned unfinished teardown operation at `/api/agent/afk/settle`.
Journal endpoints must match the fixed public AFK endpoint; they never control
the destination of an agent request.
Removal, rebuild and creation cancellation preserve runtime files until every
decision is acknowledged. Prepared sessions that never activated return their
reservation; confirmed stops retain their original elapsed seconds and IDs.
Active sessions interrupted by a crash become unresolved, retaining their
checkpoint and balance hold for administrator reconciliation. The agent does
not invent a duration from the time of its next heartbeat. Pending purchase or
reward requests only retrieve existing receipts during teardown; they cannot
create new spending after the game stopped. Corrupt, oversized, symlinked or
unacknowledged journals block runtime deletion.
+40 -11
View File
@@ -1,8 +1,7 @@
# Provisioning # Provisioning
Open **Activity → Activity log** for retained lifecycle, host, campaign, and Open **Activity → Provisioning** for server queues, bulk cancellation, lifecycle
maintenance history. Use **Manage servers** from that page or command search actions, and retained lifecycle history. **Fleet → Collections** owns reusable lineups and collection launch
for server queues, bulk cancellation, and lifecycle actions. **Fleet → Collections** owns reusable lineups and collection launch
progress; their individual operations remain visible in Provisioning. progress; their individual operations remain visible in Provisioning.
Provisioning owns the queues, safety checks, execution, and Provisioning owns the queues, safety checks, execution, and
recovery for all six actions. Create a server using **Create server**. For an existing recovery for all six actions. Create a server using **Create server**. For an existing
@@ -12,12 +11,22 @@ are shown. Confirmations name the server and its current players and round;
disruptive actions still wait for a fresh empty-lobby check. **Manage server** disruptive actions still wait for a fresh empty-lobby check. **Manage server**
on the operations page offers the same actions for a selected server. on the operations page offers the same actions for a selected server.
The **Active** tab shows jobs verifying health first, then jobs executing work, Provisioning badges show the recorded lifecycle state consistently on the list
waiting jobs, and queued jobs. File preparation, cleanup, and submitted commands and job details: **Queued**, **Waiting**, **Running**, **Validating**, **Completed**,
awaiting host confirmation count as executing work. Within each group, older **Completed with warnings**, **Failed**, **Interrupted**, or **Cancelled**.
requests appear first, with recording order breaking timestamp ties. **History** **Validating** is the display name for `awaiting-health`. The list's Status column
shows the newest requests first. This display order does not change execution shows only the state badge. Open **Details** or the individual job to inspect
priority or capacity admission. execution progress and waiting reasons. File preparation can occur
while a job is Queued or Waiting; Running begins when the host command is submitted
and includes waiting for its acknowledgment. These progress details do not change
the badge, status counts, or filters.
The **Active** tab shows Validating jobs first, then Running, Waiting, and Queued.
Within each state, older requests appear first, with recording order breaking
timestamp ties. **History** shows the newest requests first. **Needs attention**
filters unresolved Failed, Interrupted, and Completed with warnings outcomes;
their individual state badges remain visible. This display order does not change
execution priority or capacity admission.
**Create server** opens one dialog with game and map choices followed by automatic **Create server** opens one dialog with game and map choices followed by automatic
readiness checks. Blockers explain what needs attention before launch. Runtime readiness checks. Blockers explain what needs attention before launch. Runtime
@@ -140,14 +149,19 @@ and post-action health checks still apply.
## Campaigns, betas and maintenance ## Campaigns, betas and maintenance
Campaigns, beta runs, and maintenance submit durable restart requests to Campaign and beta file changes submit durable rebuild requests to
Provisioning and observe the resulting operation by ID. Their pages link to that Provisioning and observe the resulting operation by ID. Their pages link to that
operation, and its history links back to the requesting work. Provisioning owns operation, and its history links back to the requesting work. Provisioning owns
the empty-server check, conflict handling, restart capacity, remote command, and the empty-server check, conflict handling, capacity limits, remote command, and
health verification. The callers retain file deployment, beta evidence, and health verification. The callers retain file deployment, beta evidence, and
maintenance outcome verification. Jobs remain visible while waiting for players, maintenance outcome verification. Jobs remain visible while waiting for players,
other work, or integration configuration. other work, or integration configuration.
File rebuilds require host agent 1.9.2 or later. They capture rollback snapshots
before replacing the instance, and install the requested files over the promoted
setup. Queued file activations wait for the agent update when necessary.
Maintenance jobs and beta recovery cycles without file changes still use restarts.
[Maintenance](maintenance.md) also submits recovery operations and links each attempt [Maintenance](maintenance.md) also submits recovery operations and links each attempt
to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild
after five minutes; unrelated recovery slots remain held. Drift campaigns follow after five minutes; unrelated recovery slots remain held. Drift campaigns follow
@@ -290,6 +304,21 @@ query parameters. Collection launch links at `/provisioning/collections/:id` or
query parameters. `/api/deployments` remains compatible, including old campaign query parameters. `/api/deployments` remains compatible, including old campaign
detail endpoints. No history or IDs are renamed or discarded. detail endpoints. No history or IDs are renamed or discarded.
The standalone Activity log has been retired. Use a server's **History** tab for
its combined history, **Activity → Maintenance → Activity** for maintenance and
recovery, **Releases → Campaigns** or **Releases → Betas → History** for release
history, and **Fleet → Hosts → Operation history** for retained host operations.
Host operation history remains available after a host is deleted.
Legacy `/activity` and `/operations` bookmarks redirect to Provisioning history
by default. Server-scoped bookmarks open the server's History tab; host-scoped
bookmarks open host operation history. Source-only bookmarks open the matching
Campaigns, Betas, Maintenance, host operations, or autoscaling provisioning
history. Shared history filters are retained when opening server or Maintenance
history; pagination is reset when switching to a different kind of history.
`GET /api/activity` and the shared history views remain available. Historical
records, event collection, and retention are unchanged.
Start and Stop use durable `instance.start` and `instance.stop` host-agent Start and Stop use durable `instance.start` and `instance.stop` host-agent
operations. Rebuild uses `instance.rebuild` and replaces the instance files. operations. Rebuild uses `instance.rebuild` and replaces the instance files.
See [native host management](native-hosts.md) for command delivery and recovery. See [native host management](native-hosts.md) for command delivery and recovery.
+95
View File
@@ -24,6 +24,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.1", "eslint-plugin-svelte": "^3.23.1",
"globals": "^17.13.0", "globals": "^17.13.0",
"happy-dom": "^21.0.1",
"prettier": "^3.9.9", "prettier": "^3.9.9",
"prettier-plugin-svelte": "^3.5.2", "prettier-plugin-svelte": "^3.5.2",
"svelte": "^5.1.0", "svelte": "^5.1.0",
@@ -1531,6 +1532,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.71.1", "version": "8.71.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz",
@@ -2079,6 +2097,19 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/cacheable": { "node_modules/cacheable": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
@@ -2271,6 +2302,19 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-errors": { "node_modules/es-errors": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -2807,6 +2851,25 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/happy-dom": {
"version": "21.0.1",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.1.tgz",
"integrity": "sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/hashery": { "node_modules/hashery": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
@@ -4328,6 +4391,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4377,6 +4450,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1
View File
@@ -46,6 +46,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.1", "eslint-plugin-svelte": "^3.23.1",
"globals": "^17.13.0", "globals": "^17.13.0",
"happy-dom": "^21.0.1",
"prettier": "^3.9.9", "prettier": "^3.9.9",
"prettier-plugin-svelte": "^3.5.2", "prettier-plugin-svelte": "^3.5.2",
"svelte": "^5.1.0", "svelte": "^5.1.0",
+193 -21
View File
@@ -1,32 +1,204 @@
import { readdir, readFile } from 'node:fs/promises'; import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { parse } from 'svelte/compiler';
const root = fileURLToPath(new URL('../src/', import.meta.url)); const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url));
const failures = []; const tokens = await readFile(path.join(sourceRoot, 'lib/ui/styles/tokens.css'), 'utf8');
async function visit(directory) { const sharedBreakpointWidths = new Set(
for (const entry of await readdir(directory, { withFileTypes: true })) { [...tokens.matchAll(/--breakpoint-[\w-]+:\s*([\d.]+px)\s*;/g)].map((match) => match[1])
const file = path.join(directory, entry.name); );
if (entry.isDirectory()) await visit(file);
else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') { // Existing layouts awaiting migration (Refs #130). Remove widths as each file
const source = await readFile(file, 'utf8'); // moves to the shared scale; a listed file cannot introduce other legacy widths.
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) { /** @type {Record<string, number[]>} */
const line = source.slice(0, match.index).split('\n').length; const legacyBreakpointWidths = {
'src/lib/ui/components/ArtifactDetail.svelte': [720, 1000],
'src/lib/ui/components/BetaPlanBuilder.svelte': [480, 850],
'src/lib/ui/components/BetaTestProgress.svelte': [720],
'src/lib/ui/components/BlueprintBuilder.svelte': [620],
'src/lib/ui/components/ConfigEditor.svelte': [600],
'src/lib/ui/components/ConfigInspector.svelte': [600],
'src/lib/ui/components/ConfigLibrary.svelte': [600],
'src/lib/ui/components/ConfigTextEditor.svelte': [600],
'src/lib/ui/components/DeployCollectionDialog.svelte': [440, 600],
'src/lib/ui/components/DeployServerDialog.svelte': [440],
'src/lib/ui/components/ProvisioningActivity.svelte': [600],
'src/lib/ui/components/ProvisioningProgress.svelte': [600],
'src/lib/ui/components/SettingsEditor.svelte': [720],
'src/lib/ui/components/capacity/RebalancePanel.svelte': [600],
'src/lib/ui/components/capacity/RebalanceRunView.svelte': [600],
'src/lib/ui/components/metrics/MetricsExplore.svelte': [540],
'src/lib/ui/components/metrics/MetricsSummary.svelte': [760],
'src/lib/ui/components/primitives/Button.svelte': [720],
'src/lib/ui/components/primitives/Card.svelte': [480],
'src/lib/ui/components/primitives/Dialog.svelte': [600],
'src/lib/ui/styles/components.css': [600],
'src/routes/account/+page.svelte': [540],
'src/routes/autoscaling/+page.svelte': [500],
'src/routes/betas/+page.svelte': [720],
'src/routes/betas/[id]/+page.svelte': [720],
'src/routes/betas/new/+page.svelte': [700],
'src/routes/campaigns/+page.svelte': [720],
'src/routes/campaigns/[id]/+page.svelte': [720],
'src/routes/capacity/+page.svelte': [600],
'src/routes/collections/launches/[id]/+page.svelte': [600],
'src/routes/hosts/operations/+page.svelte': [600],
'src/routes/leaderboard/+page.svelte': [800, 1000],
'src/routes/login/+page.svelte': [540],
'src/routes/matches/[id]/+page.svelte': [480, 800],
'src/routes/metrics/+page.svelte': [540],
'src/routes/plugins/+page.svelte': [720],
'src/routes/provisioning/+page.svelte': [720],
'src/routes/provisioning/[id]/+page.svelte': [600, 1000],
'src/routes/setup/+page.svelte': [540]
};
/**
* @param {string} source
* @param {string} filename
*/
export function checkMediaWidths(source, filename) {
// Parse styles so comments, script strings and CSS declaration values cannot
// be mistaken for media queries. Wrap standalone CSS without adding a line.
const cssSource = filename.endsWith('.css') ? `<style>${source}</style>` : source;
const stylesheet = parse(cssSource, { modern: true }).css;
const legacyWidths = legacyBreakpointWidths[filename.replaceAll('\\', '/')] ?? [];
const allowed = new Set([...sharedBreakpointWidths, ...legacyWidths.map((n) => `${n}px`)]);
/** @type {string[]} */
const failures = [];
/** @param {import('svelte/compiler').AST.CSS.StyleSheet | import('svelte/compiler').AST.CSS.Block | null} block */
function visit(block) {
for (const node of block?.children ?? []) {
if (node.type === 'Atrule' && node.name.toLowerCase() === 'media') {
const query = node.prelude.replace(/\/\*[\s\S]*?\*\//g, ' ');
// Check both sides independently to cover chained range syntax.
const widths = [
...query.matchAll(
/(?<![\w-])(?:min-|max-)?width\s*(?::|[<>]=?|=)\s*([^\s)]+)/gi
),
...query.matchAll(/([\d.]+(?:[a-z%]+)?)\s*(?:[<>]=?|=)\s*width\b/gi)
];
for (const match of widths) {
if (allowed.has(match[1].toLowerCase())) continue;
const line = cssSource.slice(0, node.start).split('\n').length;
failures.push(
`${filename}:${line}: use a shared media width (${[...sharedBreakpointWidths].join(', ')}): ${match[1]}`
);
}
}
if ('block' in node) visit(node.block);
}
}
visit(stylesheet);
return failures;
}
const nativeInputTypes = new Set([
'checkbox',
'radio',
'file',
'hidden',
'range',
'color',
'button',
'submit',
'reset',
'image'
]);
/**
* @param {string} source
* @param {string} filename
*/
export function checkFormControls(source, filename) {
if (
!filename.endsWith('.svelte') ||
/(?:^|\/)primitives\/(?:Input|Select)\.svelte$/.test(filename)
)
return [];
/** @type {string[]} */
const failures = [];
/** @param {unknown} node */
function visit(node) {
if (!node || typeof node !== 'object') return;
if ('type' in node && node.type === 'RegularElement') {
const element = /** @type {import('svelte/compiler').AST.RegularElement} */ (node);
const type = element.attributes.find(
(attribute) => attribute.type === 'Attribute' && attribute.name === 'type'
);
const value =
type?.type === 'Attribute' && type.value !== true
? Array.isArray(type.value)
? type.value[0]
: type.value
: undefined;
const inputType =
value?.type === 'Text'
? value.data
: value?.expression.type === 'Literal'
? value.expression.value
: undefined;
if (
element.name === 'select' ||
(element.name === 'input' &&
(typeof inputType !== 'string' || !nativeInputTypes.has(inputType)))
) {
const line = source.slice(0, element.start).split('\n').length;
failures.push( failures.push(
`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}` `${filename}:${line}: use Field with ${element.name === 'select' ? 'Select' : 'Input'} instead of raw <${element.name}>`
); );
} }
} }
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach(visit);
else if (value && typeof value === 'object') visit(value);
}
} }
visit(parse(source, { modern: true }).fragment);
return failures;
} }
await visit(root);
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8'); async function main() {
for (const match of tokens.matchAll( /** @type {string[]} */
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g const failures = [];
)) { /** @param {string} directory */
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`); async function visit(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(file);
else if (/\.(svelte|css)$/.test(entry.name)) {
const source = await readFile(file, 'utf8');
const filename = path
.relative(path.dirname(sourceRoot), file)
.split(path.sep)
.join('/');
failures.push(...checkMediaWidths(source, filename));
if (file === path.join(sourceRoot, 'lib/ui/styles/tokens.css')) continue;
failures.push(...checkFormControls(source, filename));
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
const line = source.slice(0, match.index).split('\n').length;
failures.push(
`${filename}:${line}: use a shared type token (12 px minimum): ${match[0]}`
);
}
}
}
}
await visit(sourceRoot);
for (const match of tokens.matchAll(
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g
)) {
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
}
if (failures.length) {
console.error(failures.join('\n'));
process.exitCode = 1;
} else
console.log(
'UI styles: shared form controls, media widths, font sizes and 12 px minimum verified.'
);
} }
if (failures.length) {
console.error(failures.join('\n')); if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
process.exitCode = 1; await main();
} else console.log('UI styles: shared font sizes and 12 px minimum verified.');
+1 -1
View File
@@ -370,7 +370,7 @@ export function seedFixtureData(handle: Database.Database, now: number): void {
} }
} }
const metric = handle.prepare( const metric = handle.prepare(
'INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,4)' 'INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,NULL)'
); );
for (let minute = 0; minute < 7 * 1440; minute++) { for (let minute = 0; minute < 7 * 1440; minute++) {
for (const [index, server] of fixtureServers.entries()) { for (const [index, server] of fixtureServers.entries()) {
+87 -31
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env node #!/usr/bin/env node
// Local recovery only: requires filesystem/container access to the controller. // Local recovery only: requires filesystem/container access to the controller.
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { emitKeypressEvents } from 'node:readline'; import { emitKeypressEvents } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { hashPassword } from '../src/lib/server/auth/passwords.js'; import { hashPassword } from '../src/lib/server/auth/passwords.js';
/** @param {string} prompt @returns {Promise<string>} */
function secret(prompt) { function secret(prompt) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let value = ''; let value = '';
@@ -17,6 +21,7 @@ function secret(prompt) {
process.stdin.pause(); process.stdin.pause();
process.stdout.write('\n'); process.stdout.write('\n');
}; };
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
const listener = (text, key) => { const listener = (text, key) => {
if (key?.ctrl && key.name === 'c') { if (key?.ctrl && key.name === 'c') {
cleanup(); cleanup();
@@ -39,7 +44,67 @@ function secret(prompt) {
}); });
} }
try { /** @param {import('better-sqlite3').Database} database @param {string} username */
function administrator(database, username) {
const user = /** @type {{ id: string, username: string } | undefined} */ (
database
.prepare(
"SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0"
)
.get(username)
);
if (!user) throw new Error('An enabled administrator with that username was not found.');
return user;
}
/**
* Local recovery shared with regression tests; the CLI only accepts passwords on its TTY.
* @param {import('better-sqlite3').Database} database
* @param {string} username
* @param {string} password
*/
export async function recoverAdministrator(database, username, password) {
const user = administrator(database, username);
const passwordHash = await hashPassword(password);
const digest = (/** @type {string} */ value) =>
createHash('sha256').update(value).digest('hex');
database
.transaction(() => {
const changed = database
.prepare(
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
)
.run(passwordHash, Date.now(), user.id);
if (changed.changes !== 1)
throw new Error('Administrator changed during recovery. Try again.');
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
// Older databases may not have device credentials yet; recovery still works.
if (
database
.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'"
)
.get()
) {
database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id);
}
database
.prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)')
.run(
`login:account:${digest(user.username.toLowerCase())}:ip:*`,
digest(`login:account:${user.username.toLowerCase()}`),
digest(`password:account:${user.id.toLowerCase()}`)
);
database
.prepare(
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
)
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
})
.immediate();
}
async function main() {
const username = process.argv[2]?.toLowerCase(); const username = process.argv[2]?.toLowerCase();
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>'); throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
@@ -51,34 +116,25 @@ try {
fileMustExist: true fileMustExist: true
}); });
database.pragma('busy_timeout=5000'); database.pragma('busy_timeout=5000');
const user = database try {
.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0") administrator(database, username);
.get(username); const password = await secret('New administrator password (8–128 characters): ');
if (!user) throw new Error('An enabled administrator with that username was not found.'); const confirmation = await secret('Repeat password: ');
const password = await secret('New administrator password (8–128 characters): '); if (password !== confirmation) throw new Error('Passwords did not match.');
const confirmation = await secret('Repeat password: '); await recoverAdministrator(database, username, password);
if (password !== confirmation) throw new Error('Passwords did not match.'); console.log(
const passwordHash = await hashPassword(password); 'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
database );
.transaction(() => { } finally {
const changed = database database.close();
.prepare( }
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0" }
)
.run(passwordHash, Date.now(), user.id); if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (changed.changes !== 1) try {
throw new Error('Administrator changed during recovery. Try again.'); await main();
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id); } catch (cause) {
database console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
.prepare( process.exitCode = 1;
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)' }
)
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
})
.immediate();
database.close();
console.log('Administrator password reset. Existing sessions have been revoked.');
} catch (cause) {
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
process.exitCode = 1;
} }
-6
View File
@@ -85,12 +85,6 @@ async function main() {
}; };
closeDb = database.closeDb; closeDb = database.closeDb;
const handle = database.db(); const handle = database.db();
const hosts = await vite.ssrLoadModule('/src/lib/server/hosts/schema.ts');
const auth = await vite.ssrLoadModule('/src/lib/server/auth/schema.ts');
const files = await vite.ssrLoadModule('/src/lib/server/game-files/schema.ts');
hosts.ensureHostSchema(handle);
auth.ensureAuthSchema(handle);
files.ensureGameFileSchema(handle);
seedFixtureData(handle, options.now); seedFixtureData(handle, options.now);
const password = randomBytes(24).toString('base64url'); const password = randomBytes(24).toString('base64url');
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js'); const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
+2
View File
@@ -7,6 +7,7 @@ import { invalidateFleet } from '$lib/server/pipeline/bus.js';
import { startPerformanceMonitor } from '$lib/server/performance.js'; import { startPerformanceMonitor } from '$lib/server/performance.js';
import { withOperationSource } from '$lib/server/hosts/operation-context.js'; import { withOperationSource } from '$lib/server/hosts/operation-context.js';
import { withHostRequestContext } from '$lib/server/hosts/request-context.js'; import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
import { startDiscordWorker } from '$lib/server/discord/worker.js';
let booted = false; let booted = false;
let rejectionLoggerRegistered = false; let rejectionLoggerRegistered = false;
@@ -21,6 +22,7 @@ function boot(): void {
migrate(); migrate();
interruptDeployments(); interruptDeployments();
startWorker(); startWorker();
startDiscordWorker();
startPerformanceMonitor(); startPerformanceMonitor();
booted = true; booted = true;
} }
+44
View File
@@ -0,0 +1,44 @@
const historyFilters = ['host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'];
/** Route retired activity bookmarks to the existing history that owns their scope. */
export function activityRedirectHref(url: URL): string {
const params = new URLSearchParams();
const copy = (keys: string[]) => {
for (const key of keys) {
const value = url.searchParams.get(key);
if (value) params.set(key, value);
}
};
const server = url.searchParams.get('server')?.trim();
const host = url.searchParams.get('host')?.trim();
const source = url.searchParams.get('source');
let path: string;
let hash = '';
if (server) {
path = `/servers/${encodeURIComponent(server)}`;
params.set('tab', 'history');
copy(historyFilters);
hash = url.hash;
} else if (host || source === 'host') {
path = '/hosts/operations';
if (host) params.set('host', host);
} else if (source === 'maintenance' || source === 'recovery') {
path = '/maintenance';
params.set('view', 'activity');
copy(historyFilters);
hash = url.hash;
} else if (source === 'campaign') {
path = '/campaigns';
copy(['q']);
} else if (source === 'beta') {
path = '/betas';
params.set('view', 'history');
copy(['q']);
} else {
path = '/provisioning';
params.set('view', 'history');
if (source === 'autoscaling') params.set('source', source);
}
copy(['returnTo']);
return path + (params.size ? `?${params}` : '') + hash;
}
+143
View File
@@ -0,0 +1,143 @@
export const afkPageSize = 25;
export const afkLedgerKinds = [
'free_grant',
'purchase',
'reward',
'reserve',
'start',
'release',
'unresolved',
'consume',
'correction'
] as const;
export type AfkLedgerKind = (typeof afkLedgerKinds)[number];
export type AfkSessionState = 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
export interface AfkQuery {
view: 'accounts' | 'sessions' | 'ledger';
q: string;
balance: '' | 'saved' | 'reserved' | 'unresolved';
sort: 'saved' | 'available' | 'recent';
game: '' | 't5' | 't6';
server: string;
state: '' | AfkSessionState;
kind: '' | AfkLedgerKind;
from: string;
to: string;
offset: number;
player: string;
}
export function readAfkQuery(params: URLSearchParams): AfkQuery {
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
const value = params.get(key) || fallback;
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
return value as T;
}
function text(key: string, max: number) {
const value = (params.get(key) ?? '').trim();
if (value.length > max) throw new Error(`${key} is too long.`);
return value;
}
function date(key: string) {
const value = text(key, 10);
if (
value &&
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString().slice(0, 10) !== value)
)
throw new Error('Use a valid date in YYYY-MM-DD format.');
return value;
}
const offset = Number(params.get('offset') || 0);
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid page offset.');
const from = date('from');
const to = date('to');
if (from && to && from > to) throw new Error('The end date must follow the start date.');
return {
view: choice('view', ['accounts', 'sessions', 'ledger'], 'accounts'),
q: text('q', 100),
balance: choice('balance', ['', 'saved', 'reserved', 'unresolved'], ''),
sort: choice('sort', ['saved', 'available', 'recent'], 'saved'),
game: choice('game', ['', 't5', 't6'], ''),
server: text('server', 256),
state: choice(
'state',
['', 'reserved', 'started', 'settled', 'unresolved', 'corrected'],
''
),
kind: choice('kind', ['', ...afkLedgerKinds], ''),
from,
to,
offset: Math.floor(offset / afkPageSize) * afkPageSize,
player: text('player', 128)
};
}
export interface AfkIdentity {
guid: string;
name: string | null;
}
export interface AfkAccount extends AfkIdentity {
freeSeconds: number;
savedSeconds: number;
availableSeconds: number;
reservedSeconds: number;
reservedSavedSeconds: number;
unresolvedSessions: number;
lastActivity: string | null;
freeRenewsAt: string | null;
}
export interface AfkSession extends AfkIdentity {
sessionId: string;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
state: AfkSessionState;
authorizedSeconds: number;
consumedSeconds: number | null;
chargedSeconds: number | null;
confirmedSeconds: number;
endReason: string | null;
createdAt: string;
startedAt: string | null;
endedAt: string | null;
}
export interface AfkLedgerEntry extends AfkIdentity {
id: number;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
kind: AfkLedgerKind;
freeDelta: number;
savedDelta: number;
freeSeconds: number;
savedSeconds: number;
operationId: string;
sessionId: string | null;
eventAt: string;
createdAt: string;
}
export interface AfkPage<T> {
entries: T[];
total: number;
offset: number;
}
export interface AfkOverview {
query: AfkQuery;
checkedAt: string;
capacitySeconds: number;
summary: {
accounts: number;
savedSeconds: number;
reservedSeconds: number;
unresolvedSessions: number;
};
servers: string[];
accounts: AfkPage<AfkAccount>;
sessions: AfkPage<AfkSession>;
ledger: AfkPage<AfkLedgerEntry>;
}
+18 -2
View File
@@ -1,9 +1,25 @@
import type { LaunchFileSummary } from './deployment-plan.js';
/** Keep the upload number visible: two builds may share a publisher's release tag. */ /** Keep the upload number visible: two builds may share a publisher's release tag. */
export function artifactVersionLabel( export function artifactVersionLabel(
version: { version: number; release_version?: string | null } | null | undefined version: { version: number; release_version?: string | null } | null | undefined,
kind?: string
): string { ): string {
if (!version) return '—'; if (!version) return '—';
return version.release_version return version.release_version
? `${version.release_version} (upload ${version.version})` ? `${version.release_version} (upload ${version.version})`
: `v${version.version}`; : kind === 'script'
? `Unversioned (upload ${version.version})`
: `v${version.version}`;
}
export function launchFileVersionLabel(
file: Pick<LaunchFileSummary, 'version' | 'releaseVersion' | 'kind'>
): string {
return file.version === null
? 'Generated'
: artifactVersionLabel(
{ version: file.version, release_version: file.releaseVersion },
file.kind
);
} }
+25 -1
View File
@@ -1,4 +1,6 @@
export type UserRole = 'admin' | 'operator' | 'viewer'; export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'moderator' | 'viewer';
export const moderatorMaximumRestrictionMs = 7 * 24 * 60 * 60 * 1000;
/** Reserved identity for work requested by the controller. */ /** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis'; export const systemUsername = 'Maxis';
@@ -17,6 +19,28 @@ export interface AuthUser {
export const roleLabels: Record<UserRole, string> = { export const roleLabels: Record<UserRole, string> = {
admin: 'Administrator', admin: 'Administrator',
super_viewer: 'Super Viewer',
operator: 'Operator', operator: 'Operator',
moderator: 'Moderator',
viewer: 'Viewer' viewer: 'Viewer'
}; };
export function canOperate(role: UserRole | null | undefined): boolean {
return role === 'admin' || role === 'operator';
}
export function canReadAdmin(role: UserRole | null | undefined): boolean {
return role === 'admin' || role === 'super_viewer';
}
export function canReadSensitive(role: UserRole | null | undefined): boolean {
return canOperate(role) || role === 'super_viewer';
}
export function canModerate(role: UserRole | null | undefined): boolean {
return role === 'admin' || role === 'moderator';
}
export function canReadModeration(role: UserRole | null | undefined): boolean {
return canModerate(role) || role === 'super_viewer';
}
+2
View File
@@ -24,6 +24,8 @@ export interface ScalingMember {
identity: string; identity: string;
state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected'; state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected';
players: number | null; players: number | null;
/** Current occupied match round from fresh telemetry; null when unavailable. */
round: number | null;
observedAt: number | null; observedAt: number | null;
source: string | null; source: string | null;
emptySince: number | null; emptySince: number | null;
+116
View File
@@ -0,0 +1,116 @@
export const bankPageSize = 25;
export const bankAttentionAgeMs = 5 * 60_000;
export interface BankQuery {
view: 'accounts' | 'transactions' | 'pending';
q: string;
balance: '' | 'funded' | 'limit' | 'pending';
sort: 'balance' | 'recent';
game: '' | 't5' | 't6';
server: string;
operation: '' | 'deposit' | 'withdraw';
from: string;
to: string;
offset: number;
player: string;
historyOffset: number;
}
export function readBankQuery(params: URLSearchParams): BankQuery {
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
const value = params.get(key) || fallback;
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
return value as T;
}
function text(key: string, max: number) {
const value = (params.get(key) ?? '').trim();
if (value.length > max) throw new Error(`${key} is too long.`);
return value;
}
function offset(key: string) {
const value = Number(params.get(key) || 0);
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid page offset.');
return Math.floor(value / bankPageSize) * bankPageSize;
}
function date(key: string) {
const value = text(key, 10);
if (
value &&
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString().slice(0, 10) !== value)
)
throw new Error('Use a valid date in YYYY-MM-DD format.');
return value;
}
const from = date('from');
const to = date('to');
if (from && to && from > to) throw new Error('The end date must follow the start date.');
return {
view: choice('view', ['accounts', 'transactions', 'pending'], 'accounts'),
q: text('q', 100),
balance: choice('balance', ['', 'funded', 'limit', 'pending'], ''),
sort: choice('sort', ['balance', 'recent'], 'balance'),
game: choice('game', ['', 't5', 't6'], ''),
server: text('server', 256),
operation: choice('operation', ['', 'deposit', 'withdraw'], ''),
from,
to,
offset: offset('offset'),
player: text('player', 128),
historyOffset: offset('historyOffset')
};
}
export interface BankIdentity {
guid: string;
name: string | null;
}
export interface BankAccount extends BankIdentity {
balance: number;
available: number;
pendingOperation: 'deposit' | 'withdraw' | null;
pendingAmount: number | null;
lastActivity: string | null;
lastAmount: number | null;
}
export interface BankTransfer extends BankIdentity {
id: number;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
amount: number;
balance: number;
createdAt: string;
}
export interface BankPending extends BankIdentity {
requestId: string;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
operation: 'deposit' | 'withdraw';
amount: number;
createdAt: string;
}
export interface BankPage<T> {
entries: T[];
total: number;
offset: number;
}
export interface BankOverview {
query: BankQuery;
maximum: number | null;
checkedAt: string;
summary: { totalBanked: number; fundedAccounts: number; transfers24h: number; pending: number };
servers: string[];
accounts: BankPage<BankAccount>;
transactions: BankPage<BankTransfer>;
pending: BankPage<BankPending>;
detail: {
account: BankAccount;
pending: BankPending | null;
history: BankPage<BankTransfer>;
} | null;
}
+8
View File
@@ -30,6 +30,14 @@ export interface CapacityHistory {
endAt: number; endAt: number;
bucketMs: number; bucketMs: number;
points: CapacityHistoryPoint[]; points: CapacityHistoryPoint[];
temperatures: TemperatureHistorySeries[];
}
export interface TemperatureHistorySeries {
id: string;
hostId: string;
label: string;
kind: 'cpu' | 'nvme' | 'chipset';
points: { at: number; celsius: number | null }[];
} }
export interface CapacityHost { export interface CapacityHost {
id: string; id: string;
+2
View File
@@ -6,6 +6,8 @@ export interface LaunchFileSummary {
path: string; path: string;
versionId: number | null; versionId: number | null;
version: number | null; version: number | null;
/** Missing on snapshots recorded before publisher release versions were retained. */
releaseVersion?: string | null;
sha256: string; sha256: string;
size: number; size: number;
} }
+107
View File
@@ -0,0 +1,107 @@
export type DiscordGame = 't5' | 't6';
export interface DiscordSettings {
enabled: boolean;
guildId: string;
channels: Record<DiscordGame, string>;
excludedServers: string[];
showPlayers: boolean;
showAddresses: boolean;
leaderboardEnabled: boolean;
}
export const defaultDiscordSettings: DiscordSettings = {
enabled: false,
guildId: '',
channels: { t5: '', t6: '' },
excludedServers: [],
showPlayers: false,
showAddresses: false,
leaderboardEnabled: false
};
export const discordSnowflake = (value: unknown): value is string =>
typeof value === 'string' && /^[1-9][0-9]{16,19}$/.test(value);
export function validateDiscordSettings(value: unknown): DiscordSettings {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Provide Discord settings.');
const input = value as Record<string, unknown>;
for (const key of ['enabled', 'showPlayers', 'showAddresses', 'leaderboardEnabled']) {
if (typeof input[key] !== 'boolean') throw new Error(`Invalid ${key} setting.`);
}
if (typeof input.guildId !== 'string' || (input.guildId && !discordSnowflake(input.guildId)))
throw new Error('Provide a valid Discord server ID.');
if (!input.channels || typeof input.channels !== 'object' || Array.isArray(input.channels))
throw new Error('Provide Discord channels for each game.');
const channels = input.channels as Record<string, unknown>;
for (const game of ['t5', 't6'] as const) {
if (
typeof channels[game] !== 'string' ||
(channels[game] && !discordSnowflake(channels[game]))
)
throw new Error('Channel IDs must be valid Discord IDs or empty.');
}
if (
!Array.isArray(input.excludedServers) ||
input.excludedServers.length > 100 ||
input.excludedServers.some(
(name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)
)
)
throw new Error('Choose up to 100 servers to exclude from Discord.');
if (input.enabled && (!input.guildId || (!channels.t5 && !channels.t6)))
throw new Error('Choose a Discord server and at least one board channel before enabling.');
return {
enabled: input.enabled as boolean,
guildId: input.guildId,
channels: { t5: channels.t5 as string, t6: channels.t6 as string },
excludedServers: [...new Set(input.excludedServers as string[])].sort(),
showPlayers: input.showPlayers as boolean,
showAddresses: input.showAddresses as boolean,
leaderboardEnabled: input.leaderboardEnabled as boolean
};
}
export interface DiscordPublicServer {
name: string;
game: DiscordGame;
label: string;
map: string;
mapKey: string;
state: 'empty' | 'in-progress' | 'full' | 'starting' | 'offline' | 'stale' | 'unknown';
players: number | null;
capacity: number | null;
round: number | null;
observedAt: string | null;
playerNames: string[];
connection: string | null;
}
export interface DiscordMessage {
content?: string;
flags?: number;
components?: Record<string, unknown>[];
allowed_mentions: { parse: string[] };
}
export interface DiscordDeliveryStatus {
lastAttemptAt: string | null;
lastSuccessAt: string | null;
lastError: string | null;
boards: { game: DiscordGame; channelId: string; messageCount: number }[];
activeSubscriptions: number;
pendingNotifications: number;
}
export interface DiscordAdminState {
settings: DiscordSettings;
credentials: {
applicationId: string;
publicKeyConfigured: boolean;
botTokenConfigured: boolean;
};
status: DiscordDeliveryStatus;
servers: { name: string; game: DiscordGame; label: string; map: string }[];
preview: Record<DiscordGame, DiscordPublicServer[]>;
}
+19 -3
View File
@@ -58,7 +58,10 @@ export interface FleetOperation {
export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned'; export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned';
export type FleetSort = 'name' | 'players' | 'round' | 'duration'; export type FleetSort = 'name' | 'players' | 'round' | 'duration';
export function freshServer(server: FleetServer, now: number): boolean { export function freshServer(
server: Pick<FleetServer, 'health' | 'last_error' | 'last_seen_at'>,
now: number
): boolean {
return ( return (
server.health === 'online' && server.health === 'online' &&
!server.last_error && !server.last_error &&
@@ -66,7 +69,9 @@ export function freshServer(server: FleetServer, now: number): boolean {
); );
} }
export function fleetIdentity(server: FleetServer) { export function fleetIdentity(
server: Pick<FleetServer, 'name' | 'game' | 'map' | 'map_slug' | 'game_mode' | 'last_map'>
) {
const parsed = parseServerName(server.name); const parsed = parseServerName(server.name);
const game = server.game ?? parsed?.game ?? ''; const game = server.game ?? parsed?.game ?? '';
const mode = server.game_mode ?? parsed?.gameMode; const mode = server.game_mode ?? parsed?.gameMode;
@@ -91,7 +96,18 @@ export function fleetIdentity(server: FleetServer) {
} }
export function currentRound( export function currentRound(
server: FleetServer, server: Pick<
FleetServer,
| 'telemetry'
| 'telemetry_after'
| 'health'
| 'last_error'
| 'last_seen_at'
| 'last_players'
| 'active_since'
| 'game_started_at'
| 'game_ended_at'
>,
report: GameTelemetry | undefined, report: GameTelemetry | undefined,
now: number now: number
): number | null { ): number | null {
+1 -1
View File
@@ -305,7 +305,7 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
'Finished non-update maintenance. Update attempts remain durable to prevent duplicate automatic retries.', 'Finished non-update maintenance. Update attempts remain durable to prevent duplicate automatic retries.',
recoveryKeepDays: recoveryKeepDays:
'Resolved watchdog incidents with no unfinished recovery. Dismissed incidents remain as suppression evidence.', 'Resolved watchdog incidents with no unfinished recovery. Dismissed incidents remain as suppression evidence.',
hostResourceKeepDays: 'Host CPU and memory history.', hostResourceKeepDays: 'Host CPU, memory and temperature history.',
instanceResourceKeepDays: instanceResourceKeepDays:
'Minute resource samples. Capacity learning continues to use its independent 24-hour window.', 'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
metricsKeepDays: 'Historical fleet occupancy and utilization samples.', metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
+16 -8
View File
@@ -4,6 +4,8 @@ export interface ChatMessage {
slot: number; slot: number;
player: string; player: string;
message: string; message: string;
/** Omitted for public chat to preserve existing archived message identities. */
channel?: 'team';
} }
export type ChatEntry = export type ChatEntry =
@@ -17,14 +19,19 @@ export interface ChatContext {
round: number | null; round: number | null;
} }
export type ContextualChatEntry = ChatEntry & { context: ChatContext; matchId?: number }; export type ContextualChatEntry = ChatEntry & {
context: ChatContext;
matchId?: number;
/** Stable archive identity, independent of estimated wall-clock timestamps. */
messageId?: string;
};
export interface ChatMatch { export interface ChatMatch {
id?: number; id?: number;
startedAt: string; startedAt: string;
endedAt: string | null; endedAt: string | null;
lastReceivedAt: string; lastReceivedAt: string;
status: 'active' | 'completed' | 'interrupted'; status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
startObserved: boolean; startObserved: boolean;
} }
@@ -93,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
previousSeconds = seconds; previousSeconds = seconds;
} }
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line); const match = /^\s*(\d+:[0-5]\d)\s+(say|sayteam);([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
if (!match) continue; if (!match) continue;
const message = plainText(match[5]); const message = plainText(match[6]);
if (!message) continue; if (!message) continue;
push({ push({
type: 'message', type: 'message',
message: { message: {
time: match[1], time: match[1],
guid: match[2], guid: match[3],
slot: Number(match[3]), slot: Number(match[4]),
player: plainText(match[4]) || 'Unknown player', player: plainText(match[5]) || 'Unknown player',
message message,
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
} }
}); });
} }
+92
View File
@@ -0,0 +1,92 @@
export const temperatureKinds = ['cpu-package', 'cpu-die', 'cpu-core', 'nvme', 'chipset'] as const;
export type TemperatureKind = (typeof temperatureKinds)[number];
export interface HostTemperatureSensor {
id: string;
device: string;
label: string;
kind: TemperatureKind;
celsius: number;
}
/** Optional telemetry must never make an otherwise valid heartbeat fail. */
export function validateTemperatures(value: unknown): HostTemperatureSensor[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
return value.slice(0, 128).flatMap((sensor) => {
if (
!sensor ||
typeof sensor !== 'object' ||
Array.isArray(sensor) ||
typeof sensor.id !== 'string' ||
!/^[a-zA-Z0-9_-]{1,100}$/.test(sensor.id) ||
seen.has(sensor.id) ||
!['device', 'label'].every(
(key) =>
typeof sensor[key] === 'string' &&
sensor[key].length > 0 &&
sensor[key].length <= 120
) ||
!temperatureKinds.includes(sensor.kind) ||
typeof sensor.celsius !== 'number' ||
!Number.isFinite(sensor.celsius) ||
sensor.celsius < -100 ||
sensor.celsius > 200
)
return [];
seen.add(sensor.id);
return [
{
id: sensor.id,
device: sensor.device,
label: sensor.label,
kind: sensor.kind,
celsius: sensor.celsius
}
];
});
}
export function cpuTemperature(
sensors: HostTemperatureSensor[] = []
): HostTemperatureSensor | null {
// Prefer physical package/die readings. Cores are a fallback, never mixed
// into an average that might hide a hot package on a multi-socket host.
for (const kind of ['cpu-package', 'cpu-die', 'cpu-core'] as const) {
const candidates = sensors.filter((sensor) => sensor.kind === kind);
if (candidates.length)
return candidates.reduce((hot, sensor) =>
sensor.celsius > hot.celsius ? sensor : hot
);
}
return null;
}
export function temperatureLabel(sensor: HostTemperatureSensor): string {
if (sensor.kind === 'cpu-package')
return `CPU package ${sensor.label.replace(/^Package id /, '')}`;
if (sensor.kind === 'cpu-core') return `CPU ${sensor.label.toLowerCase()}`;
if (sensor.kind === 'cpu-die') return `CPU die · ${sensor.label}`;
if (sensor.kind === 'chipset') return 'Chipset';
return `NVMe · ${sensor.label}`;
}
export function formatTemperature(celsius: number | null | undefined): string {
return typeof celsius === 'number' && Number.isFinite(celsius)
? `${Number(celsius.toFixed(1))} °C`
: 'Unavailable';
}
/** One CPU series per host, plus bounded device series for the host drill-down. */
export function historyTemperatures(
sensors: HostTemperatureSensor[] = []
): HostTemperatureSensor[] {
const cpu = cpuTemperature(sensors);
const devices = sensors
.filter(
(sensor) =>
sensor.kind === 'chipset' ||
(sensor.kind === 'nvme' && sensor.label.toLowerCase() === 'composite')
)
.slice(0, 16);
return [...(cpu ? [{ ...cpu, id: 'cpu', label: 'CPU temperature' }] : []), ...devices];
}
+2
View File
@@ -1,4 +1,5 @@
import type { HostLibraryCache } from './game-files.js'; import type { HostLibraryCache } from './game-files.js';
import type { HostTemperatureSensor } from './host-temperatures.js';
export type HostMode = 'active' | 'draining' | 'disabled'; export type HostMode = 'active' | 'draining' | 'disabled';
export type HostReadiness = export type HostReadiness =
@@ -149,6 +150,7 @@ export interface HostInventory {
cpu?: string; cpu?: string;
cpuCount?: number; cpuCount?: number;
cpuUsagePercent?: number; cpuUsagePercent?: number;
temperatures?: HostTemperatureSensor[];
memoryTotal?: number; memoryTotal?: number;
memoryAvailable?: number; memoryAvailable?: number;
storageTotal?: number; storageTotal?: number;
+1 -1
View File
@@ -65,7 +65,7 @@ export interface PlayerProfile {
firstSeen: string; firstSeen: string;
lastSeen: string; lastSeen: string;
stats: PlayerStats; stats: PlayerStats;
records: PersonalBestRecords; records: PersonalBestRecords | null;
maps: PlayerMapRecord[]; maps: PlayerMapRecord[];
matches: PlayerMatch[]; matches: PlayerMatch[];
matchesTotal: number; matchesTotal: number;
+10 -5
View File
@@ -6,10 +6,11 @@ export interface MatchHistoryQuery {
map: string; map: string;
q: string; q: string;
days: number; days: number;
status: '' | 'active' | 'completed' | 'interrupted'; status: '' | 'active' | 'completed' | 'interrupted' | 'end-report-missing';
sort: MatchSort; sort: MatchSort;
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
page: number; page: number;
archive: boolean;
} }
export const matchQueryKeys = [ export const matchQueryKeys = [
'game', 'game',
@@ -19,7 +20,8 @@ export const matchQueryKeys = [
'status', 'status',
'sort', 'sort',
'direction', 'direction',
'page' 'page',
'archive'
] as const; ] as const;
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery { export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const game = params.get('game') ?? ''; const game = params.get('game') ?? '';
@@ -30,17 +32,19 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const sort = params.get('sort') || 'started'; const sort = params.get('sort') || 'started';
const direction = params.get('direction') || 'desc'; const direction = params.get('direction') || 'desc';
const page = Number(params.get('page') || 1); const page = Number(params.get('page') || 1);
const archive = params.get('archive') ?? '';
if ( if (
!['', 't5', 't6'].includes(game) || !['', 't5', 't6'].includes(game) ||
map.length > 1024 || map.length > 1024 ||
q.length > 100 || q.length > 100 ||
![0, 7, 30, 90].includes(days) || ![0, 7, 30, 90].includes(days) ||
!['', 'active', 'completed', 'interrupted'].includes(status) || !['', 'active', 'completed', 'interrupted', 'end-report-missing'].includes(status) ||
!matchSorts.includes(sort as MatchSort) || !matchSorts.includes(sort as MatchSort) ||
!['asc', 'desc'].includes(direction) || !['asc', 'desc'].includes(direction) ||
!Number.isSafeInteger(page) || !Number.isSafeInteger(page) ||
page < 1 || page < 1 ||
page > 1000000 page > 1000000 ||
!['', '0', '1'].includes(archive)
) )
throw new Error('Invalid match history filters.'); throw new Error('Invalid match history filters.');
return { return {
@@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
status: status as MatchHistoryQuery['status'], status: status as MatchHistoryQuery['status'],
sort: sort as MatchSort, sort: sort as MatchSort,
direction: direction as 'asc' | 'desc', direction: direction as 'asc' | 'desc',
page page,
archive: archive === '1'
}; };
} }
/** Namespaced filters also work inside an instance's Matches tab. */ /** Namespaced filters also work inside an instance's Matches tab. */
+7 -2
View File
@@ -11,8 +11,12 @@ export interface ServerMatch {
server_deleted_at?: string | null; server_deleted_at?: string | null;
map: string; map: string;
startedAt: string; startedAt: string;
lastReportedAt: string | null;
endedAt: string | null; endedAt: string | null;
status: 'active' | 'completed' | 'interrupted'; status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
reporterMatchKey: string | null;
// Unknown without sequence reports; otherwise gaps through the highest received sequence.
missingReports: number | null;
startPlayers: number | null; startPlayers: number | null;
uniquePlayers: number; uniquePlayers: number;
endPlayers: number | null; endPlayers: number | null;
@@ -38,10 +42,11 @@ export interface MatchPlayer {
downs: number | null; downs: number | null;
revives: number | null; revives: number | null;
bestScore: number | null; bestScore: number | null;
/** Highest reported cumulative match score; null when the reporter omitted it. */
scoreTotal: number | null;
} }
export interface MatchDetail extends ServerMatch { export interface MatchDetail extends ServerMatch {
status: 'completed';
game: string; game: string;
startObserved: boolean; startObserved: boolean;
players: MatchPlayer[]; players: MatchPlayer[];
+79 -62
View File
@@ -1,113 +1,130 @@
import type { MapMetrics } from './metrics.js'; import type { MapMetrics } from './metrics.js';
import { metricDuration, metricPercent } from './ui/metrics.js'; import { metricDuration, metricPercent } from './ui/metrics.js';
/** Product hypotheses for advisory experiments; these are not demand forecasts. */ /** Advisory thresholds for reviewing a policy, not demand forecasts or scaling commands. */
export const HOSTING_RULES = { export const POLICY_SIGNAL_RULES = {
pressureExpansion: 0.2, noEmptyShare: 0.02,
occupancyExpansion: 0.8, minimumEvidenceHours: 1,
idleUtilization: 0.1, minimumKnownShare: 0.8
quietPressure: 0.01,
minimumEvidenceHours: 1
} as const; } as const;
export interface MetricRecommendation { export interface MetricRecommendation {
game: string; game: string;
map: string; map: string;
kind: 'expand' | 'consolidate' | 'capacity' | 'watch'; kind: 'maximum' | 'spares' | 'policy' | 'reporting' | 'watch';
title: string; title: string;
reason: string; reason: string;
evidence: string; evidence: string;
outcome: string; outcome: string;
tentative: boolean; tentative: boolean;
policySummary: string | null;
href: string | null;
action: string | null;
} }
/** Apply the same visible rules to all selected maps, including sparse observations. */ /** Compare captured availability with the current policy; historical settings are not recorded. */
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] { export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
return maps return maps
.map((map) => { .map((map): MetricRecommendation => {
const utilization = map.observed > 0 ? map.active / map.observed : null; const availability = map.availabilityObserved + map.availabilityUnknown;
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null; const knownShare = availability > 0 ? map.availabilityObserved / availability : 0;
const knownShare = const lobbyHours = map.observed + map.unknown;
map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0; const knownLobbyShare = lobbyHours > 0 ? map.observed / lobbyHours : 0;
const noEmptyShare =
map.availabilityObserved > 0 ? map.noEmpty / map.availabilityObserved : null;
const policy = map.policy;
const base = { const base = {
game: map.game, game: map.game,
map: map.map, map: map.map,
evidence: evidence:
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` + `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known lobby time (${metricPercent(map.active, map.observed)}). ` +
(pressure === null (map.unknown > 0
? 'Capacity unavailable.' ? `${metricDuration(map.unknown)} of lobby time has unknown occupancy. `
: `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`), : '') +
(noEmptyShare === null
? 'Lobby availability is unknown.'
: `No empty lobby reported for ${metricDuration(map.noEmpty)} / ${metricDuration(map.availabilityObserved)} known map time (${metricPercent(map.noEmpty, map.availabilityObserved)}).`) +
(policy && noEmptyShare !== null
? ` At or above the current maximum (${policy.maximum}): ${metricDuration(map.noEmptyAtMaximum)}; below it: ${metricDuration(map.noEmptyBelowMaximum)}.`
: ''),
tentative: tentative:
map.observed < HOSTING_RULES.minimumEvidenceHours || map.availabilityObserved < POLICY_SIGNAL_RULES.minimumEvidenceHours ||
pressure === null || knownShare < POLICY_SIGNAL_RULES.minimumKnownShare ||
map.capacityObserved < HOSTING_RULES.minimumEvidenceHours || knownLobbyShare < POLICY_SIGNAL_RULES.minimumKnownShare,
knownShare < 0.8 policySummary: policy
? `Current policy: ${policy.minimum}–${policy.maximum} lobbies, ${policy.spares} spare${policy.spares === 1 ? '' : 's'}`
: null,
href: policy ? `/autoscaling#${encodeURIComponent(policy.id)}` : null,
action: policy ? 'Review policy' : null
}; };
if (utilization === null) if (noEmptyShare === null)
return { return {
...base, ...base,
kind: 'capacity' as const, kind: 'reporting',
title: title:
map.unknown > 0 map.availabilityUnknown > 0 || map.unknown > 0
? 'Check occupancy reporting' ? 'Check occupancy reporting'
: 'Let observations accumulate', : 'Let observations accumulate',
reason: reason:
map.unknown > 0 map.availabilityUnknown > 0 || map.unknown > 0
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.' ? 'Captured occupancy is unknown. Check a lobby’s telemetry or RCON diagnostics before choosing a policy change.'
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.', : 'This map has no elapsed availability observations in the selected window. Wait for another status check or try a wider range.',
outcome: 'Watch for known server time in the next report.', outcome: 'Watch for known lobby availability in the next report.',
tentative: true tentative: true
}; };
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion) if (!policy)
return { return {
...base, ...base,
kind: 'expand' as const, kind: 'policy',
title: 'Trial one additional lobby', title: 'Consider adding an autoscaling policy',
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.', reason: 'This map has no current autoscaling policy. Set a minimum, maximum, and spare target to maintain an empty lobby as players arrive.',
outcome: outcome:
'Compare all-full time and use of the additional lobby over the next comparable period.' 'Choose initial limits from observed lobby use, then compare empty-lobby availability over a comparable period.',
href: '/autoscaling',
action: 'Add policy'
}; };
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion) if (policy.pausedReason || policy.mode === 'off')
return { return {
...base, ...base,
kind: 'expand' as const, kind: 'policy',
title: 'Trial an extra lobby during busy hours', title: policy.pausedReason
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.', ? 'Review and resume the autoscaling policy'
: 'Review and enable the autoscaling policy',
reason: policy.pausedReason
? `The current policy is paused: ${policy.pausedReason}`
: 'The current policy is off, so it cannot maintain the spare target.',
outcome: outcome:
'Check whether players use the extra lobby, and measure capacity if the server reports it.', 'Review the policy and any blockers before enabling it, then observe availability before adjusting its limits.'
tentative: true
}; };
if ( if (noEmptyShare >= POLICY_SIGNAL_RULES.noEmptyShare) {
utilization <= HOSTING_RULES.idleUtilization && if (map.noEmptyAtMaximum >= map.noEmptyBelowMaximum)
(pressure === null || pressure < HOSTING_RULES.quietPressure) return {
) ...base,
kind: 'maximum',
title: 'Consider raising the policy maximum',
reason: `No empty lobby was reported for at least 2% of known map time. At least half of that time had a tracked lobby count at or above the current maximum of ${policy.maximum}; review whether that limit leaves room for the spare target.`,
outcome:
'Review host resources and create time, then compare empty-lobby availability after a maximum change.'
};
return { return {
...base, ...base,
kind: 'consolidate' as const, kind: 'spares',
title: 'Trial less idle hosting', title: 'Review the spare target and create time',
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.', reason: `No empty lobby was reported for at least 2% of known map time. Most of that time was below the current maximum of ${policy.maximum}; consider raising the spare target from ${policy.spares} and check how long new lobbies take to become ready.`,
outcome: outcome:
'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.' 'Review creation delays and blockers, then compare empty-lobby availability after a spare-target change.'
};
if (pressure === null)
return {
...base,
kind: 'capacity' as const,
title: 'Check capacity during busy hours',
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
outcome:
'Look for known-capacity time and all-full periods before the next hosting trial.'
}; };
}
return { return {
...base, ...base,
kind: 'watch' as const, kind: 'watch',
title: 'Keep the lineup and watch peaks', title: 'Keep the policy and watch availability',
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.', reason: 'No-empty-lobby time is below the policy review threshold. These observations do not suggest increasing the maximum or spare target.',
outcome: 'Watch whether occupied time and measured capacity pressure change.' outcome: 'Watch for recurring periods without a reported empty lobby.'
}; };
}) })
.sort((a, b) => { .sort((a, b) => {
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 }; const priority = { maximum: 0, spares: 1, policy: 2, reporting: 3, watch: 4 };
return ( return (
priority[a.kind] - priority[b.kind] || priority[a.kind] - priority[b.kind] ||
a.map.localeCompare(b.map) || a.map.localeCompare(b.map) ||
+80 -13
View File
@@ -1,4 +1,5 @@
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js'; import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
import type { AutoscalingMode } from './autoscaling.js';
export type MetricsDays = 1 | 7 | 30; export type MetricsDays = 1 | 7 | 30;
@@ -49,32 +50,76 @@ export interface Utilization {
playerHours: number; playerHours: number;
} }
export interface ServerMetrics extends Utilization { export interface MatchMetrics {
server: string; /** Retained matches whose recorded start is within the selected window. */
/** A current registration with this name exists; historical identity may differ. */ started: number;
currentRegistered: boolean; /** Matches with at least one distinct recorded participant. */
withRecordedPlayers: number;
/** Distinct participants over each match's lifetime, not simultaneous players. */
averageGroupSize: number | null;
solo: number;
/** Percentage of matches with recorded participants, from 0 to 100. */
soloShare: number | null;
} }
export interface MapMetrics extends Utilization { export interface MapMetrics extends Utilization {
game: string; game: string;
map: string; map: string;
/** Map wall time, not summed server time. */ matches: MatchMetrics;
full: number; /** Concurrent lobbies over this map's captured wall time, including unknown occupancy. */
capacityObserved: number; running: RunningMetrics;
/** Map wall time, not summed lobby time. Capacity does not affect availability. */
noEmpty: number;
availabilityObserved: number;
availabilityUnknown: number;
/** Historical lobby counts compared with the current policy's maximum. */
noEmptyAtMaximum: number;
noEmptyBelowMaximum: number;
policy: {
id: string;
mode: AutoscalingMode;
minimum: number;
maximum: number;
spares: number;
pausedReason: string | null;
} | null;
} }
export interface MetricPoint extends Utilization { export interface RunningMetrics {
/** Concurrent tracked lobbies, including unknown player status, during captured scope time. */
average: number | null;
peak: number;
capturedHours: number;
}
export interface PlayerCounts {
/** Distinct GUIDs, across games; null means this interval predates retained history. */
uniquePlayers: number | null;
newPlayers: number | null;
}
export interface PlayerMetrics extends PlayerCounts {
/** Global earliest retained match, independent of the selected game/map. */
historyStartAt: number | null;
}
export interface MetricPoint extends Utilization, PlayerCounts {
/** UTC bucket boundary, including when the selected interval starts later. */ /** UTC bucket boundary, including when the selected interval starts later. */
at: number; at: number;
startAt: number; startAt: number;
endAt: number; endAt: number;
partial: boolean; partial: boolean;
/** Captured scope wall time, excluding collection gaps. */
capturedHours: number;
/** Retained player history begins inside this period. */
playerHistoryPartial: boolean;
} }
export interface PreviousMetrics { export interface PreviousMetrics {
since: number; since: number;
until: number; until: number;
summary: Utilization; summary: Utilization;
running: RunningMetrics;
firstSampleAt: number | null; firstSampleAt: number | null;
lastSampleAt: number | null; lastSampleAt: number | null;
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */ /** Retained scope evidence starts late or is absent, not a completeness guarantee. */
@@ -89,32 +134,54 @@ export interface HeatmapCell extends Utilization {
distinctDates: number; distinctDates: number;
} }
export interface PooledPressure { export interface PooledAvailability {
/** Share of known map-hours with every tracked lobby full, from 0 to 100. */ /** Share of known map-hours with no tracked lobby empty, from 0 to 100. */
value: number | null; value: number | null;
fullMapHours: number; noEmptyMapHours: number;
knownMapHours: number; knownMapHours: number;
unknownMapHours: number;
coveredMaps: number; coveredMaps: number;
totalMaps: number; totalMaps: number;
} }
export interface AutoscalingMetrics {
/** Retained Provisioning outcomes whose finish time is within the selected window. */
added: number;
removed: number;
failed: number;
cancelled: number;
/** Successful creates with valid request/finish timestamps; includes queue time. */
readinessSamples: number;
readinessMeanSeconds: number | null;
readinessMaxSeconds: number | null;
}
export interface MetricsData { export interface MetricsData {
days: MetricsDays; days: MetricsDays;
/** Configured occupancy history retention; zero keeps samples indefinitely. */
metricsKeepDays: number;
since: number; since: number;
now: number; now: number;
/** Retained sample timestamps describe collection, not gameplay freshness. */ /** Retained sample timestamps describe collection, not gameplay freshness. */
firstRetainedSampleAt: number | null; firstRetainedSampleAt: number | null;
latestSampleAt: number | null; latestSampleAt: number | null;
registeredServerCount: number; registeredServerCount: number;
/** Current fleet-wide pause, separate from each policy's own state. */
autoscalingPaused: boolean;
scope: MetricsScope; scope: MetricsScope;
scopeOptions: { games: string[]; maps: { game: string; map: string }[] }; scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
scopeFirstSampleAt: number | null; scopeFirstSampleAt: number | null;
scopeLatestSampleAt: number | null; scopeLatestSampleAt: number | null;
summary: Utilization; summary: Utilization;
servers: ServerMetrics[]; running: RunningMetrics;
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
sampledServerCount: number;
matches: MatchMetrics;
players: PlayerMetrics;
autoscaling: AutoscalingMetrics;
maps: MapMetrics[]; maps: MapMetrics[];
points: MetricPoint[]; points: MetricPoint[];
previous: PreviousMetrics; previous: PreviousMetrics;
heatmap: { timeZone: string; cells: HeatmapCell[] }; heatmap: { timeZone: string; cells: HeatmapCell[] };
pooledPressure: PooledPressure; pooledAvailability: PooledAvailability;
} }
+284
View File
@@ -0,0 +1,284 @@
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';
export type ModerationState =
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
export const moderationCaseOutcomes = [
'resolved',
'dismissed',
'warning_issued',
'restriction_applied',
'no_action',
'duplicate',
'appeal_upheld',
'appeal_denied'
] as const;
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
export type ModerationCaseRelation = (typeof moderationCaseRelations)[number];
export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string> = {
same_incident: 'Same incident',
appeal_of: 'Appeal of',
duplicate_of: 'Duplicate of'
};
export const moderationCaseStatusLabels: Record<
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
string
> = {
open: 'Open',
in_review: 'In progress',
closed: 'Closed',
escalated: 'Escalated',
resolved: 'Resolved',
dismissed: 'Dismissed',
warning_issued: 'Warning issued',
restriction_applied: 'Restriction applied',
no_action: 'No action needed',
duplicate: 'Duplicate',
appeal_upheld: 'Appeal upheld',
appeal_denied: 'Appeal denied'
};
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
return status === 'open' || status === 'in_review';
}
export const moderationConcernCategories = [
'hate_speech',
'harassment',
'threats',
'safeguarding',
'spam',
'cheating',
'other'
] as const;
export type ModerationConcernCategory = (typeof moderationConcernCategories)[number];
export const moderationConcernLabels: Record<ModerationConcernCategory, string> = {
hate_speech: 'Hate speech',
harassment: 'Harassment',
threats: 'Threats',
safeguarding: 'Safeguarding',
spam: 'Spam',
cheating: 'Cheating',
other: 'Other'
};
export const moderationFindingOutcomes = [
'substantiated',
'inconclusive',
'unsubstantiated'
] as const;
export type ModerationFindingOutcome = (typeof moderationFindingOutcomes)[number];
export const moderationFindingLabels: Record<ModerationFindingOutcome, string> = {
substantiated: 'Substantiated',
inconclusive: 'Inconclusive',
unsubstantiated: 'Unsubstantiated'
};
export interface ModerationCaseAssessment {
revision: number;
summary: string;
rules: string[];
findings: {
category: ModerationConcernCategory;
finding: ModerationFindingOutcome;
detail: string;
}[];
actor: string;
createdAt: string;
}
export interface ModerationPlayer {
game: ModerationGame;
guid: string;
name: string;
aliases: string[];
firstSeen: string | null;
lastSeen: string | null;
servers: string[];
restrictions: number;
}
export interface ModerationSanction {
id: string;
caseId?: string | null;
game: ModerationGame;
guid: string;
kind: 'ban' | 'mute';
scope: ModerationScope;
server: string | null;
reason: string;
note: string;
createdAt: string;
expiresAt: string | null;
revokedAt: string | null;
actorId: string;
actor: string;
revision: number;
active: boolean;
}
export function canRevokeModerationRestriction(
actor: Pick<AuthUser, 'id' | 'role'> | null | undefined,
restriction: Pick<ModerationSanction, 'actorId' | 'scope' | 'createdAt' | 'expiresAt'>
): 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;
guid: string | null;
server: string;
kind: ModerationActionKind;
reason: string;
actor: string;
createdAt: string;
expiresAt: string;
state: ModerationState;
detail: string | null;
}
export interface ModerationCase {
id: string;
game: ModerationGame;
guid: string;
title: string;
status: ModerationCaseStatus;
priority: ModerationCasePriority;
assigneeId: string | null;
assignee: string | null;
actor: string;
createdAt: string;
updatedAt: string;
revision: number;
assessment?: ModerationCaseAssessment | null;
reviewAt?: string | null;
reviewReason?: string;
reviewSanctionId?: string | null;
unreadUpdates?: number;
evidence: {
id: string;
text: string;
source: string;
snapshot?: string | null;
createdAt: string;
actor: string;
}[];
}
export interface ModerationCaseDetail {
case: ModerationCase;
player: ModerationPlayer;
sanctions: ModerationSanction[];
linkedActions?: ModerationCaseAction[];
availableActions?: ModerationCaseAction[];
assessment?: ModerationCaseAssessment | null;
assessmentHistory?: ModerationCaseAssessment[];
relatedCases?: ModerationCaseRelationship[];
candidateCases?: ModerationCaseCandidate[];
notifications?: ModerationCaseNotification[];
events: ModerationEvent[];
coverage: ModerationCoverage[];
assignees: { id: string; username: string }[];
checkedAt: string;
}
export type ModerationCaseReference = Pick<
ModerationCase,
'id' | 'title' | 'status' | 'game' | 'guid'
>;
export interface ModerationCaseRelationship {
id: string;
relation: ModerationCaseRelation;
direction: 'outgoing' | 'incoming';
case: ModerationCaseReference;
actor: string;
createdAt: string;
}
export interface ModerationCaseCandidate extends ModerationCaseReference {
sharedMatch: boolean;
}
export interface ModerationCaseNotification {
id: number;
caseId: string;
caseTitle: string;
kind: 'evidence' | 'assignment';
createdAt: string;
readAt: string | null;
}
export interface ModerationCaseAction {
id: string;
type: 'restriction' | 'action';
kind: string;
reason: string;
actor: string;
createdAt: string;
state: string;
expiresAt: string | null;
}
export interface ModerationEvent {
id: number;
game: ModerationGame | null;
guid: string | null;
action: string;
subjectId: string | null;
actor: string;
detail: string;
createdAt: string;
}
export interface ModerationCoverage {
server: string;
game: ModerationGame;
connectedAt: string | null;
revision: number | null;
desiredRevision: number;
state: 'current' | 'pending' | 'offline' | 'unavailable';
capabilities: string[];
}
export interface ModerationQuery {
view: 'players' | 'restrictions' | 'cases' | 'history';
q: string;
game: '' | ModerationGame;
guid: string;
offset: number;
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
priority?: '' | ModerationCasePriority;
assignedTo?: string;
review?: '' | 'due' | 'scheduled' | 'updates';
}
export interface ModerationOverview {
query: ModerationQuery;
players: ModerationPlayer[];
sanctions: ModerationSanction[];
cases: ModerationCase[];
events: ModerationEvent[];
actions: ModerationAction[];
coverage: ModerationCoverage[];
total: number;
limit: number;
checkedAt: string;
notifications?: ModerationCaseNotification[];
unreadCount?: number;
}
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
export function moderationGuid(
game: ModerationGame,
guid: string,
source: 'game' | 'rcon' = 'game'
): string {
const value = guid.trim();
if (game === 't6' && source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(value))
return BigInt(`0x${value.replace(/^0x/i, '')}`).toString();
if (/^\d+$/.test(value)) return BigInt(value).toString();
return value;
}
+7 -3
View File
@@ -1,6 +1,8 @@
import { activityRedirectHref } from './activity-redirect.js';
const origin = 'https://electric-cherry.invalid'; const origin = 'https://electric-cherry.invalid';
const pagePath = const pagePath =
/^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/; /^\/(?:$|activity|operations|autoscaling|account|bank|afk|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/** Only return to application pages, never an external URL or a mutation/API endpoint. */ /** Only return to application pages, never an external URL or a mutation/API endpoint. */
export function safeReturnHref(value: string | null | undefined, fallback: string): string { export function safeReturnHref(value: string | null | undefined, fallback: string): string {
@@ -16,6 +18,8 @@ export function safeReturnHref(value: string | null | undefined, fallback: strin
try { try {
const url = new URL(value, origin); const url = new URL(value, origin);
if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback; if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback;
if (url.pathname === '/activity' || url.pathname === '/operations')
return activityRedirectHref(url);
return url.pathname + url.search + url.hash; return url.pathname + url.search + url.hash;
} catch { } catch {
return fallback; return fallback;
@@ -45,8 +49,6 @@ export function returnLabel(href: string): string {
if (path.startsWith('/hosts/')) return 'Back to host'; if (path.startsWith('/hosts/')) return 'Back to host';
const names: Record<string, string> = { const names: Record<string, string> = {
'': 'Servers', '': 'Servers',
activity: 'Activity',
operations: 'Activity',
autoscaling: 'Autoscaling', autoscaling: 'Autoscaling',
collections: 'Collections', collections: 'Collections',
hosts: 'Hosts', hosts: 'Hosts',
@@ -60,6 +62,8 @@ export function returnLabel(href: string): string {
maintenance: 'Maintenance', maintenance: 'Maintenance',
provisioning: 'Server queues', provisioning: 'Server queues',
leaderboard: 'Leaderboard', leaderboard: 'Leaderboard',
bank: 'Bank',
afk: 'AFK',
matches: 'Match History', matches: 'Match History',
metrics: 'Metrics' metrics: 'Metrics'
}; };
+13 -11
View File
@@ -1,9 +1,10 @@
import type { UserRole } from './auth.js'; import { canReadAdmin, canReadModeration, type UserRole } from './auth.js';
export interface NavigationItem { export interface NavigationItem {
label: string; label: string;
href: string; href: string;
shortcut?: string; shortcut?: string;
moderation?: boolean;
matches?: (path: string) => boolean; matches?: (path: string) => boolean;
} }
export interface NavigationGroup { export interface NavigationGroup {
@@ -28,8 +29,7 @@ export const navigationGroups: NavigationGroup[] = [
label: 'Hosts', label: 'Hosts',
href: '/hosts', href: '/hosts',
shortcut: 'o', shortcut: 'o',
matches: (path) => matches: (path) => path === '/hosts' || path.startsWith('/hosts/')
path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path)
}, },
{ label: 'Capacity', href: '/capacity' }, { label: 'Capacity', href: '/capacity' },
{ label: 'Autoscaling', href: '/autoscaling' }, { label: 'Autoscaling', href: '/autoscaling' },
@@ -52,13 +52,6 @@ export const navigationGroups: NavigationGroup[] = [
id: 'activity', id: 'activity',
label: 'Activity', label: 'Activity',
items: [ items: [
{
label: 'Activity log',
href: '/activity',
shortcut: 'd',
matches: (path) =>
path === '/activity' || path === '/operations' || path === '/hosts/operations'
},
{ {
label: 'Provisioning', label: 'Provisioning',
href: '/provisioning', href: '/provisioning',
@@ -78,6 +71,9 @@ export const navigationGroups: NavigationGroup[] = [
items: [ items: [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' }, { label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Matches', href: '/matches' }, { label: 'Matches', href: '/matches' },
{ label: 'Bank', href: '/bank' },
{ label: 'AFK', href: '/afk' },
{ label: 'Moderation', href: '/moderation', moderation: true },
{ label: 'Usage', href: '/metrics', shortcut: 'm' } { label: 'Usage', href: '/metrics', shortcut: 'm' }
] ]
}, },
@@ -88,6 +84,7 @@ export const navigationGroups: NavigationGroup[] = [
items: [ items: [
{ label: 'Users', href: '/settings/users' }, { label: 'Users', href: '/settings/users' },
{ label: 'Keys', href: '/server-keys', shortcut: 'k' }, { label: 'Keys', href: '/server-keys', shortcut: 'k' },
{ label: 'Discord', href: '/settings/integrations/discord' },
{ {
label: 'Settings', label: 'Settings',
href: '/settings', href: '/settings',
@@ -99,7 +96,12 @@ export const navigationGroups: NavigationGroup[] = [
]; ];
export function visibleNavigation(role: UserRole): NavigationGroup[] { export function visibleNavigation(role: UserRole): NavigationGroup[] {
return navigationGroups.filter((group) => !group.admin || role === 'admin'); 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 { export function navigationItemActive(item: NavigationItem, path: string): boolean {
+7
View File
@@ -117,6 +117,13 @@ export interface ProvisioningCapacity {
}[]; }[];
} }
export interface ProvisioningDetail { export interface ProvisioningDetail {
generationRecovery?: {
expectedGeneration: number;
replacementGeneration: number;
observedGeneration: number | null;
available: boolean;
reason: string | null;
} | null;
canOverrideSafety?: boolean; canOverrideSafety?: boolean;
safetyOverrideUnavailableReason?: string | null; safetyOverrideUnavailableReason?: string | null;
safetyOverride?: { actorId: string; actorName: string; confirmedAt: string } | null; safetyOverride?: { actorId: string; actorName: string; confirmedAt: string } | null;
+5 -9
View File
@@ -1,17 +1,12 @@
import type { ProvisioningAction } from './provisioning.js'; import { provisioningStates, type ProvisioningAction } from './provisioning.js';
import type { ProvisioningDeployment } from './provisioning-detail.js'; import type { ProvisioningDeployment } from './provisioning-detail.js';
import type { ProvisioningSource } from './provisioning-detail.js'; import type { ProvisioningSource } from './provisioning-detail.js';
export const provisioningListStatuses = [ export const provisioningListStatuses = [
...provisioningStates,
// Retain the combined filter for existing links and API clients.
'pending', 'pending',
'running', 'attention'
'awaiting-health',
'done',
'attention',
'failed',
'interrupted',
'warning',
'cancelled'
] as const; ] as const;
export const provisioningListSources = [ export const provisioningListSources = [
'manual', 'manual',
@@ -94,6 +89,7 @@ export type ProvisioningListRow = Pick<
phase?: string | null; phase?: string | null;
phaseStartedAt?: string | null; phaseStartedAt?: string | null;
waitCode?: string | null; waitCode?: string | null;
waitMessage?: string | null;
}; };
export interface ProvisioningListOptions { export interface ProvisioningListOptions {
+13
View File
@@ -1,3 +1,16 @@
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export const provisioningStates = [
'queued',
'waiting',
'running',
'awaiting-health',
'done',
'warning',
'failed',
'interrupted',
'cancelled'
] as const;
export const provisioningActions = [ export const provisioningActions = [
'create', 'create',
'start', 'start',
+6 -1
View File
@@ -4,6 +4,7 @@ import { gameTitle } from './ui/server-labels.js';
export interface LibraryVersion { export interface LibraryVersion {
id: number; id: number;
version: number; version: number;
release_version?: string | null;
sha256: string; sha256: string;
size: number; size: number;
state: 'untested' | 'tested' | 'test-failed'; state: 'untested' | 'tested' | 'test-failed';
@@ -37,7 +38,11 @@ export function scriptStatus(s: LibraryScript): ScriptStatus {
if (s.latestVersion.id === s.current_version_id) return 'promoted'; if (s.latestVersion.id === s.current_version_id) return 'promoted';
return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state; return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state;
} }
export function canTest(s: LibraryScript): boolean { export function canTest(s: {
runningBetaId: number | null;
latestVersion: { id: number } | null;
current_version_id: number | null;
}): boolean {
return ( return (
s.runningBetaId === null && s.runningBetaId === null &&
s.latestVersion !== null && s.latestVersion !== null &&
+33
View File
@@ -0,0 +1,33 @@
/** TWS source releases use three numeric components, without tags or suffixes. */
export function scriptVersionError(value: unknown): string {
if (typeof value !== 'string' || !value.trim()) return 'A script release version is required.';
if (value.length > 80 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value.trim()))
return 'Use MAJOR.MINOR.PATCH for the script release version, such as 1.2.0.';
return '';
}
/** Ignore non-script components (for example native plugins) in a TWS manifest. */
export function parseScriptVersions(text: string): Record<string, string> {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error('Choose a valid versions.json file.');
}
if (!data || typeof data !== 'object' || Array.isArray(data))
throw new Error('versions.json must map script filenames to release versions.');
const entries = Object.entries(data).filter(([name]) => name.endsWith('.gsc'));
if (!entries.length) throw new Error('versions.json contains no .gsc filenames.');
for (const [name, value] of entries) {
if (name.includes('/') || name.includes('\\'))
throw new Error(`Use an exact filename in versions.json: ${name}`);
const error = scriptVersionError(value);
if (error) throw new Error(`${name}: ${error}`);
}
return Object.fromEntries(entries.map(([name, value]) => [name, (value as string).trim()]));
}
/** Uploaded filenames must never resolve inherited object properties. */
export function manifestVersion(versions: Record<string, string>, filename: string): string {
return Object.hasOwn(versions, filename) ? versions[filename] : '';
}
-2
View File
@@ -9,7 +9,6 @@ import {
} from '../activity.js'; } from '../activity.js';
import { serverTitle } from '../ui/server-labels.js'; import { serverTitle } from '../ui/server-labels.js';
import { db } from './db/index.js'; import { db } from './db/index.js';
import { ensureHostSchema } from './hosts/schema.js';
import { importantOperationSql } from './hosts/observations.js'; import { importantOperationSql } from './hosts/observations.js';
const initialized = new WeakSet<Database.Database>(); const initialized = new WeakSet<Database.Database>();
@@ -190,7 +189,6 @@ function present(row: SqlRow): ActivityRow {
export function activityHistory(input: ActivityQuery = {}, now = Date.now()): ActivityPage { export function activityHistory(input: ActivityQuery = {}, now = Date.now()): ActivityPage {
const query = normalizeActivityQuery(input); const query = normalizeActivityQuery(input);
const handle = db(); const handle = db();
ensureHostSchema(handle);
if (!initialized.has(handle)) { if (!initialized.has(handle)) {
handle.function('activity_server_title', { deterministic: true }, (name: unknown) => handle.function('activity_server_title', { deterministic: true }, (name: unknown) =>
typeof name === 'string' ? serverTitle(name) : '' typeof name === 'string' ? serverTitle(name) : ''
+38 -7
View File
@@ -1,7 +1,16 @@
import type { UserRole } from '../../auth.js'; import { canModerate, canReadModeration, type UserRole } from '../../auth.js';
export type RoutePermission = export type RoutePermission =
'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin'; | 'public'
| 'machine'
| 'authenticated'
| 'viewer'
| 'operator-read'
| 'admin-read'
| 'moderation-read'
| 'moderation'
| 'operator'
| 'admin';
const within = (route: string, base: string): boolean => const within = (route: string, base: string): boolean =>
route === base || route.startsWith(base + '/'); route === base || route.startsWith(base + '/');
@@ -30,7 +39,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
return 'admin'; return 'admin';
if (within(routeId, '/api/agent')) return 'machine'; if (within(routeId, '/api/agent')) return 'machine';
if (routeId === '/api/afk/corrections') return 'admin';
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine'; if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if (routeId === '/api/discord/interactions' && method === 'POST') return 'machine';
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
if (routeId === '/api/leaderboard' && reading) return 'public'; if (routeId === '/api/leaderboard' && reading) return 'public';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public'; if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
@@ -41,16 +55,23 @@ export function routePermission(routeId: string | null, method: string): RoutePe
) )
return 'authenticated'; return 'authenticated';
// This POST only calculates a preview; it never saves settings or runs cleanup.
if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
if (within(routeId, '/api/auth')) return 'admin';
if (within(routeId, '/moderation') || within(routeId, '/api/moderation'))
return reading ? 'moderation-read' : 'moderation';
if ( if (
within(routeId, '/api/users') || within(routeId, '/api/users') ||
within(routeId, '/api/integrations') ||
within(routeId, '/settings') || within(routeId, '/settings') ||
within(routeId, '/api/settings') || within(routeId, '/api/settings') ||
within(routeId, '/api/auth') ||
within(routeId, '/api/server-keys') || within(routeId, '/api/server-keys') ||
within(routeId, '/server-keys') || within(routeId, '/server-keys') ||
within(routeId, '/keys') within(routeId, '/keys')
) )
return 'admin'; return reading ? 'admin-read' : 'admin';
if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base))) if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base)))
return 'admin'; return 'admin';
@@ -61,7 +82,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
return 'admin'; return 'admin';
// These reads initiate fleet file scans or spend external API quota. // These reads initiate fleet file scans or spend external API quota.
if (routeId === '/api/drift' || routeId === '/api/plugins/github/release') return 'operator'; if (
routeId === '/api/drift' ||
routeId === '/api/plugins/github/release' ||
routeId === '/api/plugins/updates'
)
return 'operator';
// Summaries remain visible to viewers; raw configuration, historical file // Summaries remain visible to viewers; raw configuration, historical file
// bytes, and unfiltered diagnostics may contain runtime credentials. // bytes, and unfiltered diagnostics may contain runtime credentials.
@@ -76,17 +102,22 @@ export function routePermission(routeId: string | null, method: string): RoutePe
routeId === '/api/servers/[name]/telemetry-requests' || routeId === '/api/servers/[name]/telemetry-requests' ||
routeId === '/api/servers/[name]/rcon-diagnostics' routeId === '/api/servers/[name]/rcon-diagnostics'
) )
return 'operator'; return reading ? 'operator-read' : 'operator';
return reading ? 'viewer' : 'operator'; return reading ? 'viewer' : 'operator';
} }
/** A browser role never substitutes for a machine credential. */ /** A browser role never substitutes for a machine credential. */
export function roleAllows(role: UserRole, permission: RoutePermission): boolean { export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
if (!['viewer', 'operator', 'admin'].includes(role)) return false; if (!['viewer', 'super_viewer', 'operator', 'moderator', 'admin'].includes(role)) return false;
if (permission === 'machine') return false; if (permission === 'machine') return false;
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
return true; return true;
if (permission === 'operator') return role === 'operator' || role === 'admin'; if (permission === 'operator') return role === 'operator' || role === 'admin';
if (permission === 'moderation') return canModerate(role);
if (permission === 'moderation-read') return canReadModeration(role);
if (permission === 'operator-read')
return role === 'super_viewer' || role === 'operator' || role === 'admin';
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
return permission === 'admin' && role === 'admin'; return permission === 'admin' && role === 'admin';
} }
+46
View File
@@ -0,0 +1,46 @@
import { createHash, randomBytes } from 'node:crypto';
import { db } from '../db/index.js';
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
/** Private controller state, reused only by the same immutable native instance. */
export function bankCredential(instanceId: string): string {
return db()
.transaction(() => {
const instance = db()
.prepare('SELECT server FROM native_instances WHERE id=? AND retired_at IS NULL')
.get(instanceId) as { server: string } | undefined;
if (!instance) throw new Error('Bank credentials require an active native instance.');
const saved = db()
.prepare('SELECT token FROM bank_credentials WHERE instance_id=?')
.get(instanceId) as { token: string } | undefined;
if (saved) return saved.token;
const token = 'ec_bank_' + randomBytes(32).toString('base64url');
db()
.prepare(
'INSERT INTO bank_credentials(instance_id,server,token,token_hash,created_at) VALUES (?,?,?,?,?)'
)
.run(instanceId, instance.server, token, digest(token), new Date().toISOString());
return token;
})
.immediate();
}
/** Retiring an instance immediately revokes its game credential, including reads. */
export function authenticateBank(
request: Request
): { ok: true; server: string; instanceId: string } | { ok: false; status: 401; code: string } {
const token = request.headers
.get('authorization')
?.match(/^Bearer (ec_bank_[A-Za-z0-9_-]{32,128})$/i)?.[1];
const matched = token
? (db()
.prepare(
`SELECT c.server, c.instance_id AS instanceId
FROM bank_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.server=c.server
WHERE c.token_hash=? AND n.retired_at IS NULL`
)
.get(digest(token)) as { server: string; instanceId: string } | undefined)
: undefined;
return matched ? { ok: true, ...matched } : { ok: false, status: 401, code: 'unauthorized' };
}
+11 -2
View File
@@ -20,7 +20,8 @@ function publicAsset(routeId: string | null, path: string, method: string) {
'/agent/ec-agent.py', '/agent/ec-agent.py',
'/agent/ec-agent-update.py', '/agent/ec-agent-update.py',
'/agent/install.sh', '/agent/install.sh',
'/agent/manifest.json' '/agent/manifest.json',
'/agent/manifest.json.sig'
].includes(path)) ].includes(path))
); );
} }
@@ -164,7 +165,15 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
const message = const message =
permission === 'admin' permission === 'admin'
? 'Administrator access is required.' ? 'Administrator access is required.'
: 'Operator access is required for this action.'; : permission === 'admin-read'
? 'Administrator or Super Viewer access is required.'
: permission === 'moderation'
? 'Moderator or Administrator access is required.'
: permission === 'moderation-read'
? 'Moderator, Administrator or Super Viewer access is required.'
: permission === 'operator-read'
? 'Operator, Administrator or Super Viewer access is required.'
: 'Operator access is required for this action.';
if (api) if (api)
return json( return json(
{ error: message }, { error: message },
+12 -1
View File
@@ -1,11 +1,14 @@
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit'; import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js'; import { AuthError, LOGIN_DEVICE_COOKIE, SESSION_COOKIE, type AuthResult } from './index.js';
import { PasswordServiceBusyError } from './passwords.js'; import { PasswordServiceBusyError } from './passwords.js';
import type { AuthUser } from '../../auth.js'; import type { AuthUser } from '../../auth.js';
export function sessionCookie(url: URL): string { export function sessionCookie(url: URL): string {
return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev'; return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev';
} }
export function loginDeviceCookie(url: URL): string {
return url.protocol === 'https:' ? LOGIN_DEVICE_COOKIE : 'ec_login_device_dev';
}
export function requireUserTransport(url: URL): void { export function requireUserTransport(url: URL): void {
if (url.protocol === 'https:') return; if (url.protocol === 'https:') return;
if ( if (
@@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL)
sameSite: 'lax', sameSite: 'lax',
expires: new Date(result.session.expiresAt) expires: new Date(result.session.expiresAt)
}); });
if (result.loginDevice)
cookies.set(loginDeviceCookie(url), result.loginDevice.token, {
path: '/',
httpOnly: true,
secure: url.protocol === 'https:',
sameSite: 'lax',
expires: new Date(result.loginDevice.expiresAt)
});
return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } }); return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } });
} }
export function requireUser(user: AuthUser | null): AuthUser { export function requireUser(user: AuthUser | null): AuthUser {
+132 -33
View File
@@ -1,17 +1,17 @@
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { ensureAuthSchema } from './schema.js';
import { hashPassword, verifyPassword } from './passwords.js'; import { hashPassword, verifyPassword } from './passwords.js';
import { systemUsername, type AuthUser, type UserRole } from '../../auth.js'; import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
export const SESSION_COOKIE = '__Host-ec_session'; export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000; export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
export const SESSION_IDLE_MS = 30 * 60_000; export const SESSION_IDLE_MS = 30 * 60_000;
export const REMEMBERED_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60_000;
export const LOGIN_DEVICE_COOKIE = '__Host-ec_login_device';
export const LOGIN_DEVICE_LIFETIME_MS = 30 * 24 * 60 * 60_000;
const digest = (value: string) => createHash('sha256').update(value).digest('hex'); const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() { function database() {
const handle = db(); return db();
ensureAuthSchema(handle);
return handle;
} }
type UserRow = { type UserRow = {
id: string; id: string;
@@ -84,8 +84,8 @@ function displayName(value: unknown, fallback: string): string {
return name.trim(); return name.trim();
} }
function role(value: unknown): UserRole { function role(value: unknown): UserRole {
if (!['admin', 'operator', 'viewer'].includes(value as string)) if (!['admin', 'super_viewer', 'operator', 'moderator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, operator or viewer.'); throw new AuthError('Choose administrator, super viewer, operator, moderator or viewer.');
return value as UserRole; return value as UserRole;
} }
export function recordAudit( export function recordAudit(
@@ -109,21 +109,43 @@ export function listAuthAudit() {
.all(); .all();
} }
/** Persistent fixed windows are consumed before hashing, including failed setup. */ /** Persistent fixed windows are consumed before hashing, except for enabled admin roles. */
export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void { export function consumeAuthAttempt(
clientAddress: string,
account: string,
kind: 'login' | 'setup' | 'password' = 'login',
loginDevice?: string
): void {
// Resolve the current role from storage; setup never has an existing account exemption.
if (
kind !== 'setup' &&
database()
.prepare(
`SELECT 1 FROM auth_users WHERE ${kind === 'login' ? 'username' : 'id'}=?
AND disabled=0 AND role IN ('admin','super_viewer')`
)
.get(kind === 'login' ? account.toLowerCase() : account)
)
return;
const now = Date.now(); const now = Date.now();
const windowMs = 15 * 60_000; const windowMs = 15 * 60_000;
const ipKey = digest(`${kind}:ip:${clientAddress}`); const ipKey = digest(`${kind}:ip:${clientAddress}`);
const accountKey = digest(`${kind}:account:${account.toLowerCase()}`); // Keep the hashed account prefix identifiable for local account recovery.
const accountKey =
kind === 'login'
? `login:account:${digest(account.toLowerCase())}:ip:${digest(clientAddress)}`
: digest(`${kind}:account:${account.toLowerCase()}`);
// A prior successful login permits another password check even when this
// account/address pair is exhausted. The shared address limit still applies.
const knownDevice = kind === 'login' && readLoginDevice(account, loginDevice);
const limits: [string, number][] = [[ipKey, 30]];
if (!knownDevice) limits.push([accountKey, 10]);
database() database()
.transaction(() => { .transaction(() => {
database() database()
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?') .prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
.run(now - 24 * 60 * 60_000); .run(now - 24 * 60 * 60_000);
for (const [key, limit] of [ for (const [key, limit] of limits) {
[ipKey, 30],
[accountKey, 10]
] as const) {
const previous = database() const previous = database()
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?') .prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
.get(key) as { window_start: number; attempts: number } | undefined; .get(key) as { window_start: number; attempts: number } | undefined;
@@ -134,7 +156,7 @@ export function consumeAuthAttempt(clientAddress: string, account: string, kind
) )
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429); throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
} }
for (const key of [ipKey, accountKey]) for (const [key] of limits)
database() database()
.prepare( .prepare(
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1) `INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
@@ -176,6 +198,7 @@ function insertUser(input: {
export interface AuthResult { export interface AuthResult {
user: AuthUser; user: AuthUser;
session: { token: string; expiresAt: number }; session: { token: string; expiresAt: number };
loginDevice?: { token: string; expiresAt: number };
} }
export async function bootstrapAdmin( export async function bootstrapAdmin(
input: Record<string, unknown>, input: Record<string, unknown>,
@@ -217,11 +240,14 @@ export async function bootstrapAdmin(
} }
export async function authenticatePassword( export async function authenticatePassword(
input: Record<string, unknown>, input: Record<string, unknown>,
clientAddress: string clientAddress: string,
loginDevice?: string
): Promise<AuthResult> { ): Promise<AuthResult> {
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
throw new AuthError('Keep me signed in must be true or false.');
const name = const name =
typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : ''; typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : '';
consumeAuthAttempt(clientAddress, name); consumeAuthAttempt(clientAddress, name, 'login', loginDevice);
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as
UserRow | undefined; UserRow | undefined;
const valid = await verifyPassword(input.password, row?.password_hash ?? null); const valid = await verifyPassword(input.password, row?.password_hash ?? null);
@@ -241,54 +267,94 @@ export async function authenticatePassword(
.prepare('UPDATE auth_users SET last_login_at=? WHERE id=?') .prepare('UPDATE auth_users SET last_login_at=? WHERE id=?')
.run(Date.now(), current.id); .run(Date.now(), current.id);
recordAudit(current.id, 'login', current.id); recordAudit(current.id, 'login', current.id);
return { user: getUser(current.id)!, session: createSession(current.id) }; return {
user: getUser(current.id)!,
session: createSession(current.id, input.rememberMe === true),
loginDevice: createLoginDevice(current, loginDevice)
};
}) })
.immediate(); .immediate();
if (!result) throw new AuthError('Invalid username or password.', 401); if (!result) throw new AuthError('Invalid username or password.', 401);
return result; return result;
} }
export function createSession(userId: string): { token: string; expiresAt: number } { function readLoginDevice(account: string, token: string | undefined) {
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return database()
.prepare(
`SELECT d.expires_at FROM auth_login_devices d JOIN auth_users u ON u.id=d.user_id
WHERE d.token_hash=? AND d.expires_at>? AND u.username=? AND u.disabled=0`
)
.get(digest(token), Date.now(), account) as { expires_at: number } | undefined;
}
/** Proof of a previous password login only; this token never authenticates a session. */
function createLoginDevice(user: UserRow, previousToken?: string) {
const now = Date.now();
database().prepare('DELETE FROM auth_login_devices WHERE expires_at<=?').run(now);
const previous = readLoginDevice(user.username, previousToken);
if (previous && previousToken) return { token: previousToken, expiresAt: previous.expires_at };
const token = randomBytes(32).toString('base64url');
const expiresAt = now + LOGIN_DEVICE_LIFETIME_MS;
database()
.prepare(
'INSERT INTO auth_login_devices(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)'
)
.run(digest(token), user.id, now, expiresAt);
return { token, expiresAt };
}
export function createSession(
userId: string,
remembered = false
): { token: string; expiresAt: number } {
const now = Date.now(); const now = Date.now();
const user = userRow(userId); const user = userRow(userId);
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401); if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
const token = randomBytes(32).toString('base64url'); const token = randomBytes(32).toString('base64url');
const expiresAt = now + SESSION_LIFETIME_MS; const expiresAt = now + (remembered ? REMEMBERED_SESSION_LIFETIME_MS : SESSION_LIFETIME_MS);
database() database()
.prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?') .prepare(
'DELETE FROM auth_sessions WHERE expires_at <= ? OR (remembered=0 AND last_seen_at <= ?)'
)
.run(now, now - SESSION_IDLE_MS); .run(now, now - SESSION_IDLE_MS);
database() database()
.prepare( .prepare(
'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)' 'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at,remembered) VALUES(?,?,?,?,?,?)'
) )
.run(digest(token), userId, now, expiresAt, now); .run(digest(token), userId, now, expiresAt, now, Number(remembered));
return { token, expiresAt }; return { token, expiresAt };
} }
export function readSession( export function readSession(
token: string | undefined, token: string | undefined,
touch = true touch = true
): { user: AuthUser; tokenHash: string; expiresAt: number } | null { ): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null; if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return readSessionHash(digest(token), touch); return readSessionHash(digest(token), touch);
} }
export function readSessionHash( export function readSessionHash(
tokenHash: string, tokenHash: string,
touch = false touch = false
): { user: AuthUser; tokenHash: string; expiresAt: number } | null { ): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
const now = Date.now(); const now = Date.now();
const row = database() const row = database()
.prepare( .prepare(
`SELECT u.*,s.expires_at,s.last_seen_at FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id `SELECT u.*,s.expires_at,s.last_seen_at,s.remembered FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at>? AND s.last_seen_at>? AND u.disabled=0` WHERE s.token_hash=? AND s.expires_at>? AND (s.remembered=1 OR s.last_seen_at>?) AND u.disabled=0`
) )
.get(tokenHash, now, now - SESSION_IDLE_MS) as .get(tokenHash, now, now - SESSION_IDLE_MS) as
(UserRow & { expires_at: number; last_seen_at: number }) | undefined; (UserRow & { expires_at: number; last_seen_at: number; remembered: number }) | undefined;
if (!row) return null; if (!row) return null;
if (touch && row.last_seen_at < now - 60_000) if (touch && row.last_seen_at < now - 60_000)
database() database()
.prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?') .prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?')
.run(now, tokenHash); .run(now, tokenHash);
return { user: present(row), tokenHash, expiresAt: row.expires_at }; return {
user: present(row),
tokenHash,
expiresAt: row.expires_at,
remembered: !!row.remembered
};
} }
export function revokeSession(token: string | undefined): void { export function revokeSession(token: string | undefined): void {
if (token) if (token)
@@ -299,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void {
requireAdministrator(actorId); requireAdministrator(actorId);
if (!userRow(userId)) throw new AuthError('User not found.', 404); if (!userRow(userId)) throw new AuthError('User not found.', 404);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId); database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
recordAudit(actorId, 'sessions.revoked', userId); recordAudit(actorId, 'sessions.revoked', userId);
})(); })();
} }
@@ -307,11 +374,30 @@ function requireAdministrator(actorId: string) {
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password) if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password)
throw new AuthError('Administrator access is required.', 403); throw new AuthError('Administrator access is required.', 403);
} }
type AccountSession = { tokenHash: string; clientAddress: string };
function requireSessionUser(userId: string, tokenHash: string) {
const session = readSessionHash(tokenHash);
if (!session || session.user.id !== userId) throw new AuthError('Sign in to continue.', 401);
return session;
}
async function confirmAccountPassword(
actorId: string,
currentPassword: unknown,
session: AccountSession
): Promise<void> {
if (!requireSessionUser(actorId, session.tokenHash).remembered) return;
consumeAuthAttempt(session.clientAddress, actorId, 'password');
if (!(await verifyPassword(currentPassword, userRow(actorId)?.password_hash ?? null)))
throw new AuthError('Enter your current password to confirm account changes.', 403);
requireSessionUser(actorId, session.tokenHash);
}
export async function createUser( export async function createUser(
actorId: string, actorId: string,
input: Record<string, unknown> input: Record<string, unknown>,
session?: AccountSession
): Promise<AuthUser> { ): Promise<AuthUser> {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
const name = username(input.username), const name = username(input.username),
label = displayName(input.displayName, name), label = displayName(input.displayName, name),
userRole = role(input.role); userRole = role(input.role);
@@ -319,6 +405,7 @@ export async function createUser(
return database() return database()
.transaction(() => { .transaction(() => {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) requireSessionUser(actorId, session.tokenHash);
const user = insertUser({ const user = insertUser({
username: name, username: name,
displayName: label, displayName: label,
@@ -334,9 +421,11 @@ export async function createUser(
export async function updateUser( export async function updateUser(
actorId: string, actorId: string,
id: string, id: string,
input: Record<string, unknown> input: Record<string, unknown>,
session?: AccountSession
): Promise<AuthUser> { ): Promise<AuthUser> {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
if (input.disabled !== undefined && typeof input.disabled !== 'boolean') if (input.disabled !== undefined && typeof input.disabled !== 'boolean')
throw new AuthError('Disabled must be true or false.'); throw new AuthError('Disabled must be true or false.');
if (input.password !== undefined && actorId === id) if (input.password !== undefined && actorId === id)
@@ -345,6 +434,7 @@ export async function updateUser(
return database() return database()
.transaction(() => { .transaction(() => {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) requireSessionUser(actorId, session.tokenHash);
const current = userRow(id); const current = userRow(id);
if (!current) throw new AuthError('User not found.', 404); if (!current) throw new AuthError('User not found.', 404);
const nextRole = input.role === undefined ? current.role : role(input.role); const nextRole = input.role === undefined ? current.role : role(input.role);
@@ -376,8 +466,10 @@ export async function updateUser(
Date.now(), Date.now(),
id id
); );
if (passwordHash || disabled !== current.disabled || nextRole !== current.role) if (passwordHash || disabled !== current.disabled || nextRole !== current.role) {
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id); database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id);
}
recordAudit(actorId, 'user.updated', id, { recordAudit(actorId, 'user.updated', id, {
role: nextRole, role: nextRole,
disabled: !!disabled, disabled: !!disabled,
@@ -390,7 +482,8 @@ export async function updateUser(
export async function changePassword( export async function changePassword(
userId: string, userId: string,
currentPassword: unknown, currentPassword: unknown,
newPassword: unknown newPassword: unknown,
sessionHash?: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const original = userRow(userId); const original = userRow(userId);
if ( if (
@@ -403,6 +496,8 @@ export async function changePassword(
const passwordHash = await hashPassword(newPassword); const passwordHash = await hashPassword(newPassword);
return database() return database()
.transaction(() => { .transaction(() => {
const session =
sessionHash === undefined ? null : requireSessionUser(userId, sessionHash);
const current = userRow(userId); const current = userRow(userId);
if (!current || current.disabled || current.password_hash !== original.password_hash) if (!current || current.disabled || current.password_hash !== original.password_hash)
throw new AuthError( throw new AuthError(
@@ -415,8 +510,12 @@ export async function changePassword(
) )
.run(passwordHash, Date.now(), userId); .run(passwordHash, Date.now(), userId);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId); database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
recordAudit(userId, 'password.changed', userId); recordAudit(userId, 'password.changed', userId);
return { user: getUser(userId)!, session: createSession(userId) }; return {
user: getUser(userId)!,
session: createSession(userId, session?.remembered ?? false)
};
}) })
.immediate(); .immediate();
} }
+79 -4
View File
@@ -1,7 +1,5 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
const initialized = new WeakSet<Database.Database>(); export function migrateAuthSchema(handle: Database.Database): void {
export function ensureAuthSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
handle.exec(` handle.exec(`
CREATE TABLE IF NOT EXISTS auth_users ( CREATE TABLE IF NOT EXISTS auth_users (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE, id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
@@ -24,5 +22,82 @@ export function ensureAuthSchema(handle: Database.Database): void {
); );
CREATE INDEX IF NOT EXISTS auth_audit_created_at ON auth_audit(created_at); CREATE INDEX IF NOT EXISTS auth_audit_created_at ON auth_audit(created_at);
`); `);
initialized.add(handle); }
export function migrateRememberedSessions(handle: Database.Database): void {
const columns = handle.pragma('table_info(auth_sessions)') as { name: string }[];
if (columns.some((column) => column.name === 'remembered')) return;
handle.exec(`
ALTER TABLE auth_sessions ADD COLUMN remembered INTEGER NOT NULL DEFAULT 0
CHECK(remembered IN (0,1));
`);
}
export function migrateLoginDevices(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS auth_login_devices (
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS auth_login_devices_user ON auth_login_devices(user_id);
CREATE INDEX IF NOT EXISTS auth_login_devices_expires_at ON auth_login_devices(expires_at);
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateSuperViewerRole(handle: Database.Database): void {
const schema = handle
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'")
.get() as { sql: string };
if (schema.sql.includes("'super_viewer'")) return;
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The auth role migration requires foreign keys to be disabled');
// Rebuild the parent table without renaming it first, so session references
// keep pointing to auth_users and existing sessions are not cascade-deleted.
handle.exec(`
CREATE TABLE auth_users_super_viewer (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','viewer')),
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
);
INSERT INTO auth_users_super_viewer (
id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at
) SELECT id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at FROM auth_users;
DROP TABLE auth_users;
ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateModeratorRole(handle: Database.Database): void {
const schema = handle
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'")
.get() as { sql: string };
if (schema.sql.includes("'moderator'")) return;
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The auth role migration requires foreign keys to be disabled');
// Rebuild without renaming the parent: existing session references must stay
// attached to auth_users, including remembered sessions added after v17.
handle.exec(`
CREATE TABLE auth_users_moderator (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','moderator','viewer')),
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
);
INSERT INTO auth_users_moderator (
id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at
) SELECT id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at FROM auth_users;
DROP TABLE auth_users;
ALTER TABLE auth_users_moderator RENAME TO auth_users;
`);
} }
+15
View File
@@ -120,6 +120,21 @@ export function planScaling(
) )
return result('wait', 'Waiting for fresh occupancy evidence.'); return result('wait', 'Waiting for fresh occupancy evidence.');
if (occupied + empty < desired) { if (occupied + empty < desired) {
// Brief joins do not increase spare demand until their match reaches round two.
// Keep the ordinary desired count for reporting and surplus removal so a
// new match or missing telemetry cannot retire an existing spare.
const established = members.filter(
(m) => m.state === 'occupied' && m.round !== null && m.round >= 2
).length;
const growthTarget = Math.min(
policy.maximum,
Math.max(policy.minimum, established + policy.spares)
);
if (occupied + empty >= growthTarget)
return result(
'wait',
'Waiting for occupied lobbies to reach round 2 before adding a spare.'
);
if (snapshot.reservedNames.length >= policy.maximum) if (snapshot.reservedNames.length >= policy.maximum)
return result('wait', 'Maximum lobby reservations reached.'); return result('wait', 'Maximum lobby reservations reached.');
if (!snapshot.keyPool.available) if (!snapshot.keyPool.available)
+8
View File
@@ -4,6 +4,7 @@ import { listServers, type ServerRow } from '../fleet/servers.js';
import { observationReader } from '../fleet/status.js'; import { observationReader } from '../fleet/status.js';
import { parseServerName, zombieMaps } from '../../map-aliases.js'; import { parseServerName, zombieMaps } from '../../map-aliases.js';
import { freshStatusTimestamp } from '../../server-freshness.js'; import { freshStatusTimestamp } from '../../server-freshness.js';
import { currentRound } from '../../fleet-overview.js';
import type { ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js'; import type { ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js';
import { emptyObservation, type EmptyObservation } from './planner.js'; import { emptyObservation, type EmptyObservation } from './planner.js';
@@ -164,6 +165,12 @@ export function scalingSnapshot(
identity, identity,
state, state,
players, players,
round:
state === 'occupied' &&
observation.telemetry &&
Date.parse(observation.telemetry.receivedAt) <= now
? currentRound(observation, observation.telemetry, now)
: null,
observedAt, observedAt,
source: source ?? null, source: source ?? null,
emptySince: null, emptySince: null,
@@ -181,6 +188,7 @@ export function scalingSnapshot(
identity: 'reserved', identity: 'reserved',
state: 'creating', state: 'creating',
players: null, players: null,
round: null,
observedAt: null, observedAt: null,
source: null, source: null,
emptySince: null, emptySince: null,
+8
View File
@@ -1,3 +1,4 @@
import { isPrivateConfigPath } from '../fleet/private-config.js';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import { join, posix, relative, resolve, sep } from 'node:path'; import { join, posix, relative, resolve, sep } from 'node:path';
import { config } from '../config.js'; import { config } from '../config.js';
@@ -85,6 +86,13 @@ export async function nativeFileManifest(
} }
export async function readFile(path: string): Promise<Buffer> { export async function readFile(path: string): Promise<Buffer> {
for (const root of [config.serversRoot, config.t6ServersRoot]) {
const [, ...parts] = relative(resolve(root), resolve(path)).split(sep);
if (isPrivateConfigPath(parts.join('/')))
throw new Error(
'Private bank configuration is not available through shared file reads.'
);
}
const target = remote(path); const target = remote(path);
if (!target) return fs.readFile(path); if (!target) return fs.readFile(path);
const result = await nativeRequest<{ const result = await nativeRequest<{
+1
View File
@@ -16,6 +16,7 @@ function num(name: string, fallback: number): number {
} }
export const config = { export const config = {
publicUrl: env.EC_PUBLIC_URL ?? '',
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'), serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
t6ServersRoot: t6ServersRoot:
env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'), env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
+49
View File
@@ -0,0 +1,49 @@
import type Database from 'better-sqlite3';
/** Accounts, receipts and audit evidence deliberately outlive matches and servers. */
export function migrateAfk(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS afk_accounts (
guid TEXT PRIMARY KEY, free_seconds INTEGER NOT NULL CHECK(free_seconds >= 0),
saved_seconds INTEGER NOT NULL DEFAULT 0 CHECK(saved_seconds >= 0),
free_started_at INTEGER NOT NULL, active_seconds INTEGER NOT NULL DEFAULT 0,
kill_progress INTEGER NOT NULL DEFAULT 0, headshot_progress INTEGER NOT NULL DEFAULT 0,
bonus_hour INTEGER NOT NULL DEFAULT 0, bonus_seconds INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS afk_operations (
server TEXT NOT NULL, request_id TEXT NOT NULL, instance_id TEXT NOT NULL,
guid TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL,
created_at TEXT NOT NULL, PRIMARY KEY(server,request_id)
);
CREATE TABLE IF NOT EXISTS afk_progress (
instance_id TEXT NOT NULL, match_key TEXT NOT NULL, guid TEXT NOT NULL,
seq INTEGER NOT NULL, active_seconds INTEGER NOT NULL, kills INTEGER NOT NULL,
headshots INTEGER NOT NULL, rounds INTEGER NOT NULL, revives INTEGER NOT NULL,
PRIMARY KEY(instance_id,match_key,guid)
);
CREATE TABLE IF NOT EXISTS afk_bonus_rewards (
guid TEXT NOT NULL, active_second INTEGER NOT NULL, seconds INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS afk_bonus_rewards_guid ON afk_bonus_rewards(guid,active_second);
CREATE TABLE IF NOT EXISTS afk_sessions (
session_id TEXT PRIMARY KEY, server TEXT NOT NULL, instance_id TEXT NOT NULL,
guid TEXT NOT NULL, match_key TEXT NOT NULL, match_id INTEGER,
state TEXT NOT NULL CHECK(state IN ('reserved','started','settled','unresolved','corrected')),
authorized_seconds INTEGER NOT NULL, free_seconds INTEGER NOT NULL,
saved_seconds INTEGER NOT NULL, free_started_at INTEGER NOT NULL,
consumed_seconds INTEGER, charged_seconds INTEGER, free_charge_at INTEGER, confirmed_seconds INTEGER NOT NULL DEFAULT 0,
end_reason TEXT, created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS afk_pending_guid ON afk_sessions(guid)
WHERE state IN ('reserved','started','unresolved');
CREATE TABLE IF NOT EXISTS afk_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL, server TEXT NOT NULL,
match_id INTEGER, operation_id TEXT NOT NULL, session_id TEXT,
kind TEXT NOT NULL, free_delta INTEGER NOT NULL, saved_delta INTEGER NOT NULL,
free_seconds INTEGER NOT NULL, saved_seconds INTEGER NOT NULL,
event_at TEXT NOT NULL, created_at TEXT NOT NULL, details TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS afk_ledger_guid ON afk_ledger(guid,id DESC);
CREATE INDEX IF NOT EXISTS afk_sessions_guid ON afk_sessions(guid,created_at DESC);
`);
}
+22
View File
@@ -0,0 +1,22 @@
import type Database from 'better-sqlite3';
export function migrateBankCredentials(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS bank_credentials (
instance_id TEXT PRIMARY KEY, server TEXT NOT NULL,
token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL
);
`);
if (
!(handle.pragma('table_info(bank_requests)') as { name: string }[]).some(
(column) => column.name === 'instance_id'
)
) {
handle.exec(`
ALTER TABLE bank_requests ADD COLUMN instance_id TEXT;
UPDATE bank_requests SET instance_id = (
SELECT id FROM native_instances WHERE server = bank_requests.server AND retired_at IS NULL
);
`);
}
}
+27
View File
@@ -0,0 +1,27 @@
import type Database from 'better-sqlite3';
/** No match/server foreign keys: money and idempotency outlive fleet/history cleanup. */
export function migrateBank(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS bank_accounts (
guid TEXT PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0 CHECK (typeof(balance) = 'integer' AND balance BETWEEN 0 AND 1000000)
);
CREATE TABLE IF NOT EXISTS bank_requests (
server TEXT NOT NULL, request_id TEXT NOT NULL, guid TEXT NOT NULL,
fingerprint TEXT NOT NULL, operation TEXT NOT NULL CHECK (operation IN ('deposit','withdraw')),
match_id INTEGER, amount INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL CHECK (state IN ('prepared','committed','cancelled','rejected')),
result TEXT NOT NULL, created_at TEXT NOT NULL, settled_at TEXT,
PRIMARY KEY(server, request_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS bank_pending_guid ON bank_requests(guid) WHERE state = 'prepared';
CREATE TABLE IF NOT EXISTS bank_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL,
server TEXT NOT NULL, request_id TEXT NOT NULL, match_id INTEGER,
amount INTEGER NOT NULL, balance INTEGER NOT NULL, created_at TEXT NOT NULL,
UNIQUE(server, request_id)
);
CREATE INDEX IF NOT EXISTS bank_ledger_guid ON bank_ledger(guid, id DESC);
`);
}
+18
View File
@@ -1,3 +1,4 @@
import { isPrivateConfigEvidencePath } from '../fleet/private-config.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { db } from './index.js'; import { db } from './index.js';
@@ -22,7 +23,18 @@ export function putFileBlob(
return sha256; return sha256;
} }
export function isPrivateFileBlob(sha256: string, handle: Database.Database = db()): boolean {
const rows = handle
.prepare(
`SELECT rel_path FROM deployment_files
WHERE content_before_hash=? OR content_after_hash=?`
)
.all(sha256, sha256) as { rel_path: string }[];
return rows.some((row) => isPrivateConfigEvidencePath(row.rel_path));
}
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer { export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
if (isPrivateFileBlob(sha256, handle)) throw new Error('Private file content is unavailable.');
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as
{ content: Buffer } | undefined; { content: Buffer } | undefined;
if (!row) throw new Error('File content is unavailable or has expired.'); if (!row) throw new Error('File content is unavailable or has expired.');
@@ -58,6 +70,12 @@ export function packFileJson(
if (Array.isArray(value)) if (Array.isArray(value))
return { value: value.map((item) => packFileJson(item, handle, hashes).value), hashes }; return { value: value.map((item) => packFileJson(item, handle, hashes).value), hashes };
if (!value || typeof value !== 'object') return { value, hashes }; if (!value || typeof value !== 'object') return { value, hashes };
if (
'path' in value &&
typeof value.path === 'string' &&
isPrivateConfigEvidencePath(value.path)
)
return { value: { path: value.path, redacted: true }, hashes };
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) { for (const [key, item] of Object.entries(value)) {
if ( if (
+4 -614
View File
@@ -1,417 +1,15 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { installReportRevisions } from '../reports/revisions.js';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { config } from '../config.js'; import { config } from '../config.js';
import schemaSql from './schema.sql?raw'; import { migrations } from './migrations.js';
import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-plan.js'; import { runMigrations } from './migration-runner.js';
import { emptyMetrics } from '../fleet/beta-evaluation.js'; import { tuneConnection, tuneMigrationConnection } from './connection.js';
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
import { initializeFleetSettings } from './fleet-settings.js';
import { migrateServerHistory } from './server-history.js';
import { migrateNativeInstances } from '../backends/schema.js';
import { migrateServerKeys } from './server-keys.js';
import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import { migrateFileBlobs } from './file-blobs.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrationStep, tuneConnection, tuneMigrationConnection } from './connection.js';
import { timeStatement } from './slow-statements.js'; import { timeStatement } from './slow-statements.js';
let _db: Database.Database | null = null; let _db: Database.Database | null = null;
let optimizeTimer: NodeJS.Timeout | undefined; let optimizeTimer: NodeJS.Timeout | undefined;
// Desired shape of the `servers` table. Every column here is added to the
// table on boot if missing — introspection via PRAGMA table_info handles
// both fresh DBs and legacy DBs that predate any given column. CREATE TABLE
// IF NOT EXISTS in schema.sql is a no-op on existing tables, so it can't
// backfill columns; this can.
const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
{
name: 'container_checked_at',
ddl: 'ALTER TABLE servers ADD COLUMN container_checked_at TEXT'
},
{ name: 'container_state', ddl: 'ALTER TABLE servers ADD COLUMN container_state TEXT' },
{
name: 'desired_state',
ddl: "ALTER TABLE servers ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'running' CHECK (desired_state IN ('running','stopped'))"
},
// Base columns that shipped in the very first schema stay here for parity
// with fresh installs — no ALTER is issued if they're already present.
{ name: 'game_port', ddl: 'ALTER TABLE servers ADD COLUMN game_port INTEGER' },
{ name: 'rcon_host', ddl: 'ALTER TABLE servers ADD COLUMN rcon_host TEXT' },
{ name: 'last_players', ddl: 'ALTER TABLE servers ADD COLUMN last_players INTEGER' },
{ name: 'last_checked_at', ddl: 'ALTER TABLE servers ADD COLUMN last_checked_at TEXT' },
// Post-v0.1 additions from the perk-a-cola/diagnostics work
{ name: 'last_map', ddl: 'ALTER TABLE servers ADD COLUMN last_map TEXT' },
{ name: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' },
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' },
{
name: 'rcon_failures',
ddl: 'ALTER TABLE servers ADD COLUMN rcon_failures INTEGER NOT NULL DEFAULT 0'
},
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
// Script-scope columns — static per server, populated via server-edit UI.
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' },
// Provisioning columns — populated when EC creates the deployment.
{ name: 'game_mode', ddl: 'ALTER TABLE servers ADD COLUMN game_mode TEXT' },
{ name: 'map_slug', ddl: 'ALTER TABLE servers ADD COLUMN map_slug TEXT' },
{ name: 'host_dir', ddl: 'ALTER TABLE servers ADD COLUMN host_dir TEXT' },
{ name: 'config_file', ddl: 'ALTER TABLE servers ADD COLUMN config_file TEXT' },
{ name: 'game_id', ddl: 'ALTER TABLE servers ADD COLUMN game_id TEXT' },
{ name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' },
{ name: 'created_by', ddl: 'ALTER TABLE servers ADD COLUMN created_by TEXT' },
{ name: 'destroyed_at', ddl: 'ALTER TABLE servers ADD COLUMN destroyed_at TEXT' },
// Occupancy + match timers — see schema.sql for semantics.
{ name: 'active_since', ddl: 'ALTER TABLE servers ADD COLUMN active_since TEXT' },
{ name: 'inactive_since', ddl: 'ALTER TABLE servers ADD COLUMN inactive_since TEXT' },
{ name: 'game_started_at', ddl: 'ALTER TABLE servers ADD COLUMN game_started_at TEXT' },
{ name: 'game_ended_at', ddl: 'ALTER TABLE servers ADD COLUMN game_ended_at TEXT' },
{ name: 'game_cycle_key', ddl: 'ALTER TABLE servers ADD COLUMN game_cycle_key TEXT' }
];
function migrateServers(handle: Database.Database): void {
const rows = handle.prepare('PRAGMA table_info(servers)').all() as Array<{ name: string }>;
const existing = new Set(rows.map((r) => r.name));
for (const col of SERVERS_COLUMNS) {
if (!existing.has(col.name)) {
handle.exec(col.ddl);
}
}
// Retire external-backend registry fields, including required fields that
// would otherwise prevent native inserts into an upgraded database.
const retired = new Set(['komodo_type', 'komodo_name', 'komodo_server']);
handle.transaction(() => {
for (const column of rows) {
if (retired.has(column.name))
handle.exec(
`ALTER TABLE servers DROP COLUMN "${column.name.replaceAll('"', '""')}"`
);
}
})();
}
// Additive columns on tables the script model owns. Same pattern as
// migrateServers — CREATE TABLE IF NOT EXISTS won't backfill columns onto
// pre-existing tables, so we introspect and ALTER as needed.
const SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'kind', ddl: "ALTER TABLE scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" }
];
const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'release_version', ddl: 'ALTER TABLE script_versions ADD COLUMN release_version TEXT' },
// state was added post-launch to make untested/tested/test-failed visible
// in the UI. Existing rows default to 'untested' — this is safe because
// any real promoted version's script has current_version_id set (which is
// the authoritative "promoted" signal), and any version already gated
// through a ready-promote beta will be re-marked next time that state
// machine fires.
{
name: 'state',
ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'"
},
{
name: 'kind',
ddl: "ALTER TABLE script_versions ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'"
}
];
const BETA_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'kind', ddl: "ALTER TABLE beta_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" }
];
const DEPLOYMENT_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{
name: 'kind',
ddl: "ALTER TABLE deployment_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'"
}
];
const DEPLOYMENTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{
name: 'script_id',
ddl: 'ALTER TABLE deployments ADD COLUMN script_id INTEGER REFERENCES scripts(id)'
},
{
name: 'script_version_id',
ddl: 'ALTER TABLE deployments ADD COLUMN script_version_id INTEGER REFERENCES script_versions(id)'
},
{
name: 'prior_version_id',
ddl: 'ALTER TABLE deployments ADD COLUMN prior_version_id INTEGER REFERENCES script_versions(id)'
},
{
name: 'beta_id',
ddl: 'ALTER TABLE deployments ADD COLUMN beta_id INTEGER REFERENCES betas(id)'
},
{
name: 'rolled_back_by',
ddl: 'ALTER TABLE deployments ADD COLUMN rolled_back_by TEXT REFERENCES deployments(id)'
}
];
function migrateTable(
handle: Database.Database,
table: string,
cols: Array<{ name: string; ddl: string }>
): void {
const rows = handle.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
const existing = new Set(rows.map((r) => r.name));
for (const col of cols) {
if (!existing.has(col.name)) handle.exec(col.ddl);
}
}
// Backfill the multi-script join tables from the legacy single-script columns.
// Existing bundle membership is authoritative. Only single-script betas can
// inherit server membership from the deprecated primary-script columns.
function backfillMultiScriptJoins(handle: Database.Database): void {
handle.exec(
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id)
SELECT b.id, b.script_id, b.script_version_id
FROM betas b
WHERE b.script_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM beta_scripts bs WHERE bs.beta_id = b.id)`
);
handle.exec(
`INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id)
SELECT bs.beta_id, bs.server, b.script_id, bs.had_original, bs.prior_version_id
FROM beta_servers bs
JOIN betas b ON b.id = bs.beta_id
WHERE b.script_id IS NOT NULL
AND bs.server_deleted_at IS NULL
AND EXISTS (SELECT 1 FROM servers WHERE name = bs.server)
AND (SELECT COUNT(*) FROM beta_scripts x WHERE x.beta_id = b.id) = 1
AND EXISTS (SELECT 1 FROM beta_scripts x WHERE x.beta_id = b.id AND x.script_id = b.script_id)
AND NOT EXISTS (
SELECT 1 FROM beta_server_scripts x
WHERE x.beta_id = bs.beta_id AND x.server = bs.server
)`
);
handle.exec(
`INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id)
SELECT d.id, d.script_id, d.script_version_id, d.prior_version_id
FROM deployments d
WHERE d.script_id IS NOT NULL
AND d.script_version_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM deployment_scripts ds WHERE ds.deployment_id = d.id)`
);
}
// The pre-script-model beta tables had entirely different shapes (slug PK,
// beta_scripts, beta_deploys). schema.sql defines the new shape; CREATE TABLE
// IF NOT EXISTS is a no-op if the old tables already exist, so we have to
// detect and drop them first. Any outstanding rows abort the boot so the
// operator can promote/rollback in the old app before upgrading.
function dropLegacyBetaTables(handle: Database.Database): void {
const legacy = handle
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')"
)
.all() as Array<{ name: string }>;
if (legacy.length === 0) return;
const legacyNames = legacy.map((r) => r.name);
// The old `betas` table had a `slug` column; the new one has `id` + `script_id`.
// If we see `slug`, we know it's the old shape.
const betasCols = handle.prepare('PRAGMA table_info(betas)').all() as Array<{ name: string }>;
const isLegacyShape = betasCols.some((c) => c.name === 'slug');
if (!isLegacyShape) return;
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
if (!legacyNames.includes(table)) continue;
const row = handle.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number };
if (row.n > 0) {
throw new Error(
`legacy ${table} still has ${row.n} rows — promote or rollback outstanding betas in the previous version before upgrading`
);
}
}
// Drop in FK-safe order.
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
if (legacyNames.includes(table)) handle.exec(`DROP TABLE ${table}`);
}
}
// One-shot guard for the -beta/.OLD → DB-snapshot cutover. Legacy running/
// ready-promote betas from the old code have -beta.<ext> and .OLD files on
// disk with no corresponding beta_file_snapshots rows — restoring them under
// the new rollback path would delete-instead-of-restore. If a legacy active
// beta is detected on the first boot after upgrade, refuse to start so the
// operator can rollback/promote under the old code. A settings sentinel marks
// the cutover complete so subsequent boots with (legitimately new-scheme)
// active betas aren't blocked.
function assertSnapshotSchemeSafe(handle: Database.Database): void {
const done = handle.prepare("SELECT value FROM settings WHERE key = 'snapshots_v1'").get() as
{ value: string } | undefined;
if (done) return;
const active = handle
.prepare("SELECT id FROM betas WHERE state IN ('running','ready-promote') LIMIT 1")
.get() as { id: number } | undefined;
if (active) {
throw new Error(
`close active beta ${active.id} before upgrading — snapshot scheme is incompatible with legacy -beta/.OLD state`
);
}
handle.prepare("INSERT INTO settings (key, value) VALUES ('snapshots_v1', '1')").run();
}
// Preserve old plans as reviewable checklists instead of silently passing checks
// that the three-event protocol can no longer perform. Existing evidence stays
// in history; active progress starts fresh under the new gameplay contract.
function migrateGameTelemetry(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'game_telemetry_v2'").get()) return;
handle.transaction(() => {
const convert = (raw: string): string => {
const plan = JSON.parse(raw) as BetaPlan;
plan.requirements = plan.requirements.map((r) => {
const { event: _event, ...rest } = r as BetaRequirement & { event?: string };
return ['feature', 'restart-budget', 'clean-logs'].includes(r.kind)
? { ...requirement('manual', r.id), label: `Review: ${r.label}`.slice(0, 200) }
: rest;
});
return JSON.stringify(plan);
};
const plans = handle.prepare('SELECT beta_id, plan FROM beta_test_plans').all() as Array<{
beta_id: number;
plan: string;
}>;
for (const row of plans)
handle
.prepare('UPDATE beta_test_plans SET plan = ? WHERE beta_id = ?')
.run(convert(row.plan), row.beta_id);
const presets = handle.prepare('SELECT name, plan FROM beta_test_presets').all() as Array<{
name: string;
plan: string;
}>;
for (const row of presets)
handle
.prepare('UPDATE beta_test_presets SET plan = ? WHERE name = ?')
.run(convert(row.plan), row.name);
handle
.prepare(
`UPDATE beta_test_runs SET metrics = ?, cursors = '{}' WHERE retired_at IS NULL
AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'))`
)
.run(JSON.stringify(emptyMetrics()));
handle.exec(`UPDATE beta_test_plans SET outcome = 'active', reason = 'Gameplay telemetry updated; collecting fresh script events'
WHERE outcome IN ('active','passed') AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'));
UPDATE script_versions SET state = 'untested' WHERE state = 'tested'
AND id IN (SELECT bs.script_version_id FROM beta_scripts bs JOIN beta_test_plans p ON p.beta_id = bs.beta_id WHERE p.outcome = 'active')
AND id NOT IN (SELECT current_version_id FROM scripts WHERE current_version_id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM beta_scripts bs JOIN betas b ON b.id = bs.beta_id
WHERE bs.script_version_id = script_versions.id AND b.state = 'promoted');
UPDATE betas SET state = 'running' WHERE state = 'ready-promote' AND id IN (SELECT beta_id FROM beta_test_plans WHERE outcome = 'active');
UPDATE beta_servers SET stage = 'observing' WHERE stage = 'ready' AND beta_id IN (SELECT beta_id FROM beta_test_plans WHERE outcome = 'active');
INSERT INTO settings (key, value) VALUES ('game_telemetry_v2', '1');`);
})();
}
function migrateProvisioningActions(handle: Database.Database): void {
const table = handle
.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'server_deployments'"
)
.get() as { sql: string };
if (table.sql.includes("'rebuild'")) return;
// Rebuild the CHECK constraint without cascading into events, recipes, or
// collection members. Preserve rowids: they order same-server operations.
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(
table.sql
.replace('server_deployments', 'server_deployments_new')
.replace(
"'create', 'destroy'",
"'create', 'start', 'restart', 'rebuild', 'stop', 'destroy'"
)
);
const columns = (
handle.prepare('PRAGMA table_info(server_deployments)').all() as { name: string }[]
)
.map((column) => `"${column.name.replaceAll('"', '""')}"`)
.join(', ');
handle.exec(`INSERT INTO server_deployments_new (rowid, ${columns})
SELECT rowid, ${columns} FROM server_deployments;
DROP TABLE server_deployments;
ALTER TABLE server_deployments_new RENAME TO server_deployments;`);
if ((handle.pragma('foreign_key_check') as unknown[]).length)
throw new Error('Provisioning migration found broken references');
})();
} finally {
handle.pragma('foreign_keys = ON');
}
}
/** Rebuild constrained tables while preserving links, indexes, triggers, and row order. */
function migrateWatchdogConstraints(handle: Database.Database): void {
for (const name of ['provisioning_sources', 'deployment_servers']) {
const { sql } = handle
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name) as { sql: string };
const replacement =
name === 'provisioning_sources'
? sql.includes("'autoscaling'")
? sql
: sql.replace(
/'campaign','beta','maintenance'(?:,'watchdog')?/,
"'campaign','beta','maintenance','watchdog','autoscaling'"
)
: sql.replace(
/server\s+TEXT NOT NULL REFERENCES servers\(name\)/,
'server TEXT NOT NULL'
);
if (sql === replacement) continue;
const dependent = handle
.prepare(
"SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL"
)
.all(name) as { sql: string }[];
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(replacement.replace(name, name + '_watchdog_new'));
handle.exec(
`INSERT INTO ${name}_watchdog_new (rowid, ${(
handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]
)
.map((col) => col.name)
.join(', ')}) SELECT rowid, ${(
handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]
)
.map((col) => col.name)
.join(
', '
)} FROM ${name}; DROP TABLE ${name}; ALTER TABLE ${name}_watchdog_new RENAME TO ${name};`
);
for (const object of dependent) handle.exec(object.sql);
if ((handle.pragma('foreign_key_check') as unknown[]).length)
throw new Error('Watchdog migration found broken references');
})();
} finally {
handle.pragma('foreign_keys = ON');
}
}
}
function backfillDesiredState(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'watchdog_desired_state_v1'").get())
return;
handle.transaction(() => {
// A failed stop may have reached the host agent. Preserve that explicit intent.
handle.exec(`UPDATE servers SET desired_state = 'stopped' WHERE 'stop' = (
SELECT action FROM server_deployments d WHERE d.server = servers.name AND d.state != 'cancelled'
AND d.action IN ('create','start','restart','rebuild','stop')
ORDER BY d.rowid DESC LIMIT 1);
INSERT INTO settings (key, value) VALUES ('watchdog_desired_state_v1', '1');`);
})();
}
export function db(): Database.Database { export function db(): Database.Database {
if (_db) return _db; if (_db) return _db;
@@ -426,215 +24,7 @@ export function db(): Database.Database {
handle.pragma('auto_vacuum = INCREMENTAL'); handle.pragma('auto_vacuum = INCREMENTAL');
handle.pragma('foreign_keys = ON'); handle.pragma('foreign_keys = ON');
tuneMigrationConnection(handle); tuneMigrationConnection(handle);
dropLegacyBetaTables(handle); runMigrations(handle, migrations);
handle.exec(schemaSql);
migrateProvisioningActions(handle);
migrateWatchdogConstraints(handle);
migrateTable(handle, 'deployment_files', [
{
name: 'snapshot_pending',
ddl: 'ALTER TABLE deployment_files ADD COLUMN snapshot_pending INTEGER NOT NULL DEFAULT 0'
}
]);
migrateTable(handle, 'restart_rollout', [
{
name: 'operation_id',
ddl: 'ALTER TABLE restart_rollout ADD COLUMN operation_id TEXT'
}
]);
migrateTable(handle, 'server_deployments', [
{
name: 'requested_by',
ddl: 'ALTER TABLE server_deployments ADD COLUMN requested_by TEXT'
},
{
name: 'submitted_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN submitted_at TEXT'
},
{
name: 'checked_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN checked_at TEXT'
},
{ name: 'players', ddl: 'ALTER TABLE server_deployments ADD COLUMN players INTEGER' },
{ name: 'phase_key', ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_key TEXT' },
{
name: 'phase_started_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_started_at TEXT'
},
{
name: 'observed_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN observed_at TEXT'
},
{
name: 'observation_json',
ddl: 'ALTER TABLE server_deployments ADD COLUMN observation_json TEXT'
}
]);
migrateTable(handle, 'server_deployment_events', [
{ name: 'kind', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN kind TEXT' },
{
name: 'phase_key',
ddl: 'ALTER TABLE server_deployment_events ADD COLUMN phase_key TEXT'
},
{
name: 'details_json',
ddl: 'ALTER TABLE server_deployment_events ADD COLUMN details_json TEXT'
}
]);
for (const table of ['deployment_servers', 'beta_servers', 'maintenance_jobs']) {
migrateTable(handle, table, [
{
name: 'provisioning_id',
ddl: `ALTER TABLE ${table} ADD COLUMN provisioning_id TEXT`
}
]);
}
migrateTable(handle, 'maintenance_jobs', [
{
name: 'kind',
ddl: "ALTER TABLE maintenance_jobs ADD COLUMN kind TEXT NOT NULL DEFAULT 'legacy'"
},
{
name: 'origin',
ddl: "ALTER TABLE maintenance_jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'"
}
]);
migrateServers(handle);
// Old snippets may contain player addresses, including hex-encoded bytes.
// Discard them once; newly retained diagnostics are sanitized failure replies only.
if (
!handle.prepare("SELECT 1 FROM settings WHERE key='rcon_private_diagnostics_v1'").get()
) {
handle.transaction(() => {
handle.exec(`UPDATE servers SET last_reply_snippet=NULL;
UPDATE servers SET last_error='bad-reply:unparseable print reply'
WHERE last_error LIKE 'bad-reply:print reply not recognized:%';
INSERT INTO settings(key,value) VALUES('rcon_private_diagnostics_v1','1');`);
})();
}
backfillDesiredState(handle);
// Column additions must run BEFORE any statement that references the
// new columns. schema.sql cannot include those references — see the
// comment there.
migrateTable(handle, 'scripts', SCRIPTS_COLUMNS);
migrateTable(handle, 'script_versions', SCRIPT_VERSIONS_COLUMNS);
migrateTable(handle, 'beta_scripts', BETA_SCRIPTS_COLUMNS);
migrateTable(handle, 'beta_test_runs', [
{
name: 'revision',
ddl: 'ALTER TABLE beta_test_runs ADD COLUMN revision INTEGER NOT NULL DEFAULT 1'
}
]);
migrateTable(handle, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS);
migrateTable(handle, 'deployments', DEPLOYMENTS_COLUMNS);
// Older databases gain rolled_back_by above, after schema.sql runs.
handle.exec(
'CREATE INDEX IF NOT EXISTS idx_deployments_rolled_back_by ON deployments(rolled_back_by)'
);
migrateTable(handle, 'server_launch_collections', [
{
name: 'cancelled_at',
ddl: 'ALTER TABLE server_launch_collections ADD COLUMN cancelled_at TEXT'
},
{
name: 'mode',
ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'"
},
{
name: 'retained_servers',
ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'"
}
]);
migrateTable(handle, 'server_launch_recipes', [
{
name: 'recipe_summary',
ddl: 'ALTER TABLE server_launch_recipes ADD COLUMN recipe_summary TEXT'
}
]);
migrateServerHistory(handle);
migrateTable(handle, 'server_collection_previews', [
{
name: 'mode',
ddl: "ALTER TABLE server_collection_previews ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'"
},
{
name: 'fleet_snapshot',
ddl: 'ALTER TABLE server_collection_previews ADD COLUMN fleet_snapshot TEXT'
}
]);
migrateTable(handle, 'server_telemetry', [
{ name: 'payload', ddl: 'ALTER TABLE server_telemetry ADD COLUMN payload TEXT' }
]);
// Kind-referencing indexes: safe here because migrateTable above just
// ALTER'd the column into existence if it was missing.
handle.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_scripts_one_baseline_per_scope
ON scripts(scope_type, scope_value) WHERE kind = 'baseline'`
);
handle.exec(`CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind)`);
backfillMultiScriptJoins(handle);
assertSnapshotSchemeSafe(handle);
migrateGameTelemetry(handle);
migrateTable(handle, 'leaderboard_matches', [
{
name: 'start_players',
ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER'
}
]);
migrateTable(handle, 'leaderboard_match_players', [
{
name: 'first_observed_round',
ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER'
},
{
name: 'present_at_start',
ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER'
}
]);
backfillLeaderboard(handle);
initializeFleetSettings(handle);
migrationStep('native instance schema and file plans', () =>
migrateNativeInstances(handle)
);
migrationStep('per-game server key pools', () => migrateServerKeys(handle));
migrationStep('autoscaling schema', () => ensureAutoscalingSchema(handle));
migrationStep('rebalancing schema', () => ensureRebalancingSchema(handle));
// File-blob migration removes the legacy content column altogether.
// Reordering it first would unnecessarily copy every artifact twice.
migrateFileBlobs(handle);
// Lifecycle history is retained indefinitely. Admission and status reads
// must narrow by server or active state instead of scanning that history.
// Install after migrations that rebuild this table or add server_deleted_at.
handle.exec(`CREATE INDEX IF NOT EXISTS idx_server_deployments_server
ON server_deployments(server, server_deleted_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_state
ON server_deployments(state, action, server_deleted_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_server_state
ON server_deployments(server, server_deleted_at, state, action);
CREATE INDEX IF NOT EXISTS idx_server_deployments_started_at
ON server_deployments(started_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_submission
ON server_deployments(server, server_deleted_at, action, submitted_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_finished_at
ON server_deployments(finished_at) WHERE server_deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
installPerformanceIndexes(handle);
installReportRevisions(handle);
migrationStep('retention clocks and indexes', () =>
retentionClocks(handle, {
deployments: ['started_at', 'finished_at'],
server_deployments: ['started_at', 'finished_at'],
maintenance_jobs: ['requested_at', 'finished_at'],
watchdog_incidents: ['first_seen_at', 'finished_at'],
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
betas: ['opened_at', 'closed_at'],
script_versions: ['uploaded_at'],
retention_controls: ['resolved_at']
})
);
// Existing databases require one rebuild to gain the incremental-vacuum page map.
enableIncrementalVacuum(handle);
handle.pragma('optimize = 0x10002'); handle.pragma('optimize = 0x10002');
handle.pragma('shrink_memory'); handle.pragma('shrink_memory');
tuneConnection(handle); tuneConnection(handle);
+29
View File
@@ -0,0 +1,29 @@
import type Database from 'better-sqlite3';
/** Reporter identities and received sequences survive controller restarts with match history. */
export function migrateLeaderboardReports(handle: Database.Database): void {
const columns = new Set(
(handle.prepare('PRAGMA table_info(leaderboard_matches)').all() as { name: string }[]).map(
(column) => column.name
)
);
for (const [name, definition] of [
['reporter_match_key', 'TEXT'],
['last_seq', 'INTEGER NOT NULL DEFAULT 0'],
['report_count', 'INTEGER NOT NULL DEFAULT 0']
]) {
if (!columns.has(name))
handle.exec(`ALTER TABLE leaderboard_matches ADD COLUMN ${name} ${definition}`);
}
handle.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_reporter_match
ON leaderboard_matches(server, reporter_match_key)
WHERE reporter_match_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS leaderboard_match_reports (
match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE,
seq INTEGER NOT NULL CHECK (typeof(seq) = 'integer' AND seq > 0),
applied INTEGER NOT NULL CHECK (applied IN (0,1)),
PRIMARY KEY (match_id, seq)
);
`);
}
+586
View File
@@ -0,0 +1,586 @@
import type Database from 'better-sqlite3';
import schemaSql from './schema.sql?raw';
import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-plan.js';
import { emptyMetrics } from '../fleet/beta-evaluation.js';
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
import { initializeFleetSettings } from './fleet-settings.js';
import { migrateServerHistory } from './server-history.js';
// Compatibility bridge for unversioned databases. Historical column checks and
// sentinels distinguish releases that already applied destructive backfills.
// Freeze this bridge; add future upgrades to the ordered list in migrations.ts.
// Desired shape of the `servers` table. Every column here is added to the
// legacy table if missing — introspection via PRAGMA table_info handles
// both fresh DBs and legacy DBs that predate any given column. CREATE TABLE
// IF NOT EXISTS in schema.sql is a no-op on existing tables, so it can't
// backfill columns; this can.
const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
{
name: 'container_checked_at',
ddl: 'ALTER TABLE servers ADD COLUMN container_checked_at TEXT'
},
{ name: 'container_state', ddl: 'ALTER TABLE servers ADD COLUMN container_state TEXT' },
{
name: 'desired_state',
ddl: "ALTER TABLE servers ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'running' CHECK (desired_state IN ('running','stopped'))"
},
// Base columns that shipped in the very first schema stay here for parity
// with fresh installs — no ALTER is issued if they're already present.
{ name: 'game_port', ddl: 'ALTER TABLE servers ADD COLUMN game_port INTEGER' },
{ name: 'rcon_host', ddl: 'ALTER TABLE servers ADD COLUMN rcon_host TEXT' },
{ name: 'last_players', ddl: 'ALTER TABLE servers ADD COLUMN last_players INTEGER' },
{ name: 'last_checked_at', ddl: 'ALTER TABLE servers ADD COLUMN last_checked_at TEXT' },
// Post-v0.1 additions from the perk-a-cola/diagnostics work
{ name: 'last_map', ddl: 'ALTER TABLE servers ADD COLUMN last_map TEXT' },
{ name: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' },
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' },
{
name: 'rcon_failures',
ddl: 'ALTER TABLE servers ADD COLUMN rcon_failures INTEGER NOT NULL DEFAULT 0'
},
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
// Script-scope columns — static per server, populated via server-edit UI.
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' },
// Provisioning columns — populated when EC creates the deployment.
{ name: 'game_mode', ddl: 'ALTER TABLE servers ADD COLUMN game_mode TEXT' },
{ name: 'map_slug', ddl: 'ALTER TABLE servers ADD COLUMN map_slug TEXT' },
{ name: 'host_dir', ddl: 'ALTER TABLE servers ADD COLUMN host_dir TEXT' },
{ name: 'config_file', ddl: 'ALTER TABLE servers ADD COLUMN config_file TEXT' },
{ name: 'game_id', ddl: 'ALTER TABLE servers ADD COLUMN game_id TEXT' },
{ name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' },
{ name: 'created_by', ddl: 'ALTER TABLE servers ADD COLUMN created_by TEXT' },
{ name: 'destroyed_at', ddl: 'ALTER TABLE servers ADD COLUMN destroyed_at TEXT' },
// Occupancy + match timers — see schema.sql for semantics.
{ name: 'active_since', ddl: 'ALTER TABLE servers ADD COLUMN active_since TEXT' },
{ name: 'inactive_since', ddl: 'ALTER TABLE servers ADD COLUMN inactive_since TEXT' },
{ name: 'game_started_at', ddl: 'ALTER TABLE servers ADD COLUMN game_started_at TEXT' },
{ name: 'game_ended_at', ddl: 'ALTER TABLE servers ADD COLUMN game_ended_at TEXT' },
{ name: 'game_cycle_key', ddl: 'ALTER TABLE servers ADD COLUMN game_cycle_key TEXT' }
];
function migrateServers(handle: Database.Database): void {
const rows = handle.prepare('PRAGMA table_info(servers)').all() as Array<{ name: string }>;
const existing = new Set(rows.map((r) => r.name));
for (const col of SERVERS_COLUMNS) {
if (!existing.has(col.name)) {
handle.exec(col.ddl);
}
}
// Retire external-backend registry fields, including required fields that
// would otherwise prevent native inserts into an upgraded database.
const retired = new Set(['komodo_type', 'komodo_name', 'komodo_server']);
handle.transaction(() => {
for (const column of rows) {
if (retired.has(column.name))
handle.exec(
`ALTER TABLE servers DROP COLUMN "${column.name.replaceAll('"', '""')}"`
);
}
})();
}
// Additive columns on tables the script model owns. Same pattern as
// migrateServers — CREATE TABLE IF NOT EXISTS won't backfill columns onto
// pre-existing tables, so we introspect and ALTER as needed.
const SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'kind', ddl: "ALTER TABLE scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" }
];
const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'release_version', ddl: 'ALTER TABLE script_versions ADD COLUMN release_version TEXT' },
// state was added post-launch to make untested/tested/test-failed visible
// in the UI. Existing rows default to 'untested' — this is safe because
// any real promoted version's script has current_version_id set (which is
// the authoritative "promoted" signal), and any version already gated
// through a ready-promote beta will be re-marked next time that state
// machine fires.
{
name: 'state',
ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'"
},
{
name: 'kind',
ddl: "ALTER TABLE script_versions ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'"
}
];
const BETA_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'kind', ddl: "ALTER TABLE beta_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" }
];
const DEPLOYMENT_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{
name: 'kind',
ddl: "ALTER TABLE deployment_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'"
}
];
const DEPLOYMENTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{
name: 'script_id',
ddl: 'ALTER TABLE deployments ADD COLUMN script_id INTEGER REFERENCES scripts(id)'
},
{
name: 'script_version_id',
ddl: 'ALTER TABLE deployments ADD COLUMN script_version_id INTEGER REFERENCES script_versions(id)'
},
{
name: 'prior_version_id',
ddl: 'ALTER TABLE deployments ADD COLUMN prior_version_id INTEGER REFERENCES script_versions(id)'
},
{
name: 'beta_id',
ddl: 'ALTER TABLE deployments ADD COLUMN beta_id INTEGER REFERENCES betas(id)'
},
{
name: 'rolled_back_by',
ddl: 'ALTER TABLE deployments ADD COLUMN rolled_back_by TEXT REFERENCES deployments(id)'
}
];
function migrateTable(
handle: Database.Database,
table: string,
cols: Array<{ name: string; ddl: string }>
): void {
const rows = handle.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
const existing = new Set(rows.map((r) => r.name));
for (const col of cols) {
if (!existing.has(col.name)) handle.exec(col.ddl);
}
}
// Backfill the multi-script join tables from the legacy single-script columns.
// Existing bundle membership is authoritative. Only single-script betas can
// inherit server membership from the deprecated primary-script columns.
function backfillMultiScriptJoins(handle: Database.Database): void {
handle.exec(
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id)
SELECT b.id, b.script_id, b.script_version_id
FROM betas b
WHERE b.script_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM beta_scripts bs WHERE bs.beta_id = b.id)`
);
handle.exec(
`INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id)
SELECT bs.beta_id, bs.server, b.script_id, bs.had_original, bs.prior_version_id
FROM beta_servers bs
JOIN betas b ON b.id = bs.beta_id
WHERE b.script_id IS NOT NULL
AND bs.server_deleted_at IS NULL
AND EXISTS (SELECT 1 FROM servers WHERE name = bs.server)
AND (SELECT COUNT(*) FROM beta_scripts x WHERE x.beta_id = b.id) = 1
AND EXISTS (SELECT 1 FROM beta_scripts x WHERE x.beta_id = b.id AND x.script_id = b.script_id)
AND NOT EXISTS (
SELECT 1 FROM beta_server_scripts x
WHERE x.beta_id = bs.beta_id AND x.server = bs.server
)`
);
handle.exec(
`INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id)
SELECT d.id, d.script_id, d.script_version_id, d.prior_version_id
FROM deployments d
WHERE d.script_id IS NOT NULL
AND d.script_version_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM deployment_scripts ds WHERE ds.deployment_id = d.id)`
);
}
// The pre-script-model beta tables had entirely different shapes (slug PK,
// beta_scripts, beta_deploys). schema.sql defines the new shape; CREATE TABLE
// IF NOT EXISTS is a no-op if the old tables already exist, so we have to
// detect and drop them first. Any outstanding rows abort the boot so the
// operator can promote/rollback in the old app before upgrading.
function dropLegacyBetaTables(handle: Database.Database): void {
const legacy = handle
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')"
)
.all() as Array<{ name: string }>;
if (legacy.length === 0) return;
const legacyNames = legacy.map((r) => r.name);
// The old `betas` table had a `slug` column; the new one has `id` + `script_id`.
// If we see `slug`, we know it's the old shape.
const betasCols = handle.prepare('PRAGMA table_info(betas)').all() as Array<{ name: string }>;
const isLegacyShape = betasCols.some((c) => c.name === 'slug');
if (!isLegacyShape) return;
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
if (!legacyNames.includes(table)) continue;
const row = handle.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number };
if (row.n > 0) {
throw new Error(
`legacy ${table} still has ${row.n} rows — promote or rollback outstanding betas in the previous version before upgrading`
);
}
}
// Drop in FK-safe order.
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
if (legacyNames.includes(table)) handle.exec(`DROP TABLE ${table}`);
}
}
// One-shot guard for the -beta/.OLD → DB-snapshot cutover. Legacy running/
// ready-promote betas from the old code have -beta.<ext> and .OLD files on
// disk with no corresponding beta_file_snapshots rows — restoring them under
// the new rollback path would delete-instead-of-restore. If a legacy active
// beta is detected on the first boot after upgrade, refuse to start so the
// operator can rollback/promote under the old code. A settings sentinel marks
// the cutover complete so subsequent boots with (legitimately new-scheme)
// active betas aren't blocked.
function assertSnapshotSchemeSafe(handle: Database.Database): void {
const done = handle.prepare("SELECT value FROM settings WHERE key = 'snapshots_v1'").get() as
{ value: string } | undefined;
if (done) return;
const active = handle
.prepare("SELECT id FROM betas WHERE state IN ('running','ready-promote') LIMIT 1")
.get() as { id: number } | undefined;
if (active) {
throw new Error(
`close active beta ${active.id} before upgrading — snapshot scheme is incompatible with legacy -beta/.OLD state`
);
}
handle.prepare("INSERT INTO settings (key, value) VALUES ('snapshots_v1', '1')").run();
}
// Preserve old plans as reviewable checklists instead of silently passing checks
// that the three-event protocol can no longer perform. Existing evidence stays
// in history; active progress starts fresh under the new gameplay contract.
function migrateGameTelemetry(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'game_telemetry_v2'").get()) return;
handle.transaction(() => {
const convert = (raw: string): string => {
const plan = JSON.parse(raw) as BetaPlan;
plan.requirements = plan.requirements.map((r) => {
const { event: _event, ...rest } = r as BetaRequirement & { event?: string };
return ['feature', 'restart-budget', 'clean-logs'].includes(r.kind)
? { ...requirement('manual', r.id), label: `Review: ${r.label}`.slice(0, 200) }
: rest;
});
return JSON.stringify(plan);
};
const plans = handle.prepare('SELECT beta_id, plan FROM beta_test_plans').all() as Array<{
beta_id: number;
plan: string;
}>;
for (const row of plans)
handle
.prepare('UPDATE beta_test_plans SET plan = ? WHERE beta_id = ?')
.run(convert(row.plan), row.beta_id);
const presets = handle.prepare('SELECT name, plan FROM beta_test_presets').all() as Array<{
name: string;
plan: string;
}>;
for (const row of presets)
handle
.prepare('UPDATE beta_test_presets SET plan = ? WHERE name = ?')
.run(convert(row.plan), row.name);
handle
.prepare(
`UPDATE beta_test_runs SET metrics = ?, cursors = '{}' WHERE retired_at IS NULL
AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'))`
)
.run(JSON.stringify(emptyMetrics()));
handle.exec(`UPDATE beta_test_plans SET outcome = 'active', reason = 'Gameplay telemetry updated; collecting fresh script events'
WHERE outcome IN ('active','passed') AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'));
UPDATE script_versions SET state = 'untested' WHERE state = 'tested'
AND id IN (SELECT bs.script_version_id FROM beta_scripts bs JOIN beta_test_plans p ON p.beta_id = bs.beta_id WHERE p.outcome = 'active')
AND id NOT IN (SELECT current_version_id FROM scripts WHERE current_version_id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM beta_scripts bs JOIN betas b ON b.id = bs.beta_id
WHERE bs.script_version_id = script_versions.id AND b.state = 'promoted');
UPDATE betas SET state = 'running' WHERE state = 'ready-promote' AND id IN (SELECT beta_id FROM beta_test_plans WHERE outcome = 'active');
UPDATE beta_servers SET stage = 'observing' WHERE stage = 'ready' AND beta_id IN (SELECT beta_id FROM beta_test_plans WHERE outcome = 'active');
INSERT INTO settings (key, value) VALUES ('game_telemetry_v2', '1');`);
})();
}
function migrateProvisioningActions(handle: Database.Database): void {
const table = handle
.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'server_deployments'"
)
.get() as { sql: string };
if (table.sql.includes("'rebuild'")) return;
// Rebuild the CHECK constraint without cascading into events, recipes, or
// collection members. Preserve rowids: they order same-server operations.
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(
table.sql
.replace('server_deployments', 'server_deployments_new')
.replace(
"'create', 'destroy'",
"'create', 'start', 'restart', 'rebuild', 'stop', 'destroy'"
)
);
const columns = (
handle.prepare('PRAGMA table_info(server_deployments)').all() as { name: string }[]
)
.map((column) => `"${column.name.replaceAll('"', '""')}"`)
.join(', ');
handle.exec(`INSERT INTO server_deployments_new (rowid, ${columns})
SELECT rowid, ${columns} FROM server_deployments;
DROP TABLE server_deployments;
ALTER TABLE server_deployments_new RENAME TO server_deployments;`);
if ((handle.pragma('foreign_key_check') as unknown[]).length)
throw new Error('Provisioning migration found broken references');
})();
} finally {
handle.pragma('foreign_keys = ON');
}
}
/** Rebuild constrained tables while preserving links, indexes, triggers, and row order. */
function migrateWatchdogConstraints(handle: Database.Database): void {
for (const name of ['provisioning_sources', 'deployment_servers']) {
const { sql } = handle
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name) as { sql: string };
const replacement =
name === 'provisioning_sources'
? sql.includes("'autoscaling'")
? sql
: sql.replace(
/'campaign','beta','maintenance'(?:,'watchdog')?/,
"'campaign','beta','maintenance','watchdog','autoscaling'"
)
: sql.replace(
/server\s+TEXT NOT NULL REFERENCES servers\(name\)/,
'server TEXT NOT NULL'
);
if (sql === replacement) continue;
const dependent = handle
.prepare(
"SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL"
)
.all(name) as { sql: string }[];
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(replacement.replace(name, name + '_watchdog_new'));
handle.exec(
`INSERT INTO ${name}_watchdog_new (rowid, ${(
handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]
)
.map((col) => col.name)
.join(', ')}) SELECT rowid, ${(
handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]
)
.map((col) => col.name)
.join(
', '
)} FROM ${name}; DROP TABLE ${name}; ALTER TABLE ${name}_watchdog_new RENAME TO ${name};`
);
for (const object of dependent) handle.exec(object.sql);
if ((handle.pragma('foreign_key_check') as unknown[]).length)
throw new Error('Watchdog migration found broken references');
})();
} finally {
handle.pragma('foreign_keys = ON');
}
}
}
function backfillDesiredState(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'watchdog_desired_state_v1'").get())
return;
handle.transaction(() => {
// A failed stop may have reached the host agent. Preserve that explicit intent.
handle.exec(`UPDATE servers SET desired_state = 'stopped' WHERE 'stop' = (
SELECT action FROM server_deployments d WHERE d.server = servers.name AND d.state != 'cancelled'
AND d.action IN ('create','start','restart','rebuild','stop')
ORDER BY d.rowid DESC LIMIT 1);
INSERT INTO settings (key, value) VALUES ('watchdog_desired_state_v1', '1');`);
})();
}
export function migrateLegacySchema(handle: Database.Database): void {
dropLegacyBetaTables(handle);
handle.exec(schemaSql);
migrateProvisioningActions(handle);
migrateWatchdogConstraints(handle);
migrateTable(handle, 'deployment_files', [
{
name: 'snapshot_pending',
ddl: 'ALTER TABLE deployment_files ADD COLUMN snapshot_pending INTEGER NOT NULL DEFAULT 0'
}
]);
migrateTable(handle, 'restart_rollout', [
{
name: 'operation_id',
ddl: 'ALTER TABLE restart_rollout ADD COLUMN operation_id TEXT'
}
]);
migrateTable(handle, 'server_deployments', [
{
name: 'requested_by',
ddl: 'ALTER TABLE server_deployments ADD COLUMN requested_by TEXT'
},
{
name: 'submitted_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN submitted_at TEXT'
},
{
name: 'checked_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN checked_at TEXT'
},
{ name: 'players', ddl: 'ALTER TABLE server_deployments ADD COLUMN players INTEGER' },
{ name: 'phase_key', ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_key TEXT' },
{
name: 'phase_started_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_started_at TEXT'
},
{
name: 'observed_at',
ddl: 'ALTER TABLE server_deployments ADD COLUMN observed_at TEXT'
},
{
name: 'observation_json',
ddl: 'ALTER TABLE server_deployments ADD COLUMN observation_json TEXT'
}
]);
migrateTable(handle, 'server_deployment_events', [
{ name: 'kind', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN kind TEXT' },
{
name: 'phase_key',
ddl: 'ALTER TABLE server_deployment_events ADD COLUMN phase_key TEXT'
},
{
name: 'details_json',
ddl: 'ALTER TABLE server_deployment_events ADD COLUMN details_json TEXT'
}
]);
for (const table of ['deployment_servers', 'beta_servers', 'maintenance_jobs']) {
migrateTable(handle, table, [
{
name: 'provisioning_id',
ddl: `ALTER TABLE ${table} ADD COLUMN provisioning_id TEXT`
}
]);
}
migrateTable(handle, 'maintenance_jobs', [
{
name: 'kind',
ddl: "ALTER TABLE maintenance_jobs ADD COLUMN kind TEXT NOT NULL DEFAULT 'legacy'"
},
{
name: 'origin',
ddl: "ALTER TABLE maintenance_jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'"
}
]);
migrateServers(handle);
// Old snippets may contain player addresses, including hex-encoded bytes.
// Discard them once; newly retained diagnostics are sanitized failure replies only.
if (!handle.prepare("SELECT 1 FROM settings WHERE key='rcon_private_diagnostics_v1'").get()) {
handle.transaction(() => {
handle.exec(`UPDATE servers SET last_reply_snippet=NULL;
UPDATE servers SET last_error='bad-reply:unparseable print reply'
WHERE last_error LIKE 'bad-reply:print reply not recognized:%';
INSERT INTO settings(key,value) VALUES('rcon_private_diagnostics_v1','1');`);
})();
}
backfillDesiredState(handle);
migrateTable(handle, 'scripts', SCRIPTS_COLUMNS);
migrateTable(handle, 'script_versions', SCRIPT_VERSIONS_COLUMNS);
migrateTable(handle, 'beta_scripts', BETA_SCRIPTS_COLUMNS);
migrateTable(handle, 'beta_test_runs', [
{
name: 'revision',
ddl: 'ALTER TABLE beta_test_runs ADD COLUMN revision INTEGER NOT NULL DEFAULT 1'
}
]);
migrateTable(handle, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS);
migrateTable(handle, 'deployments', DEPLOYMENTS_COLUMNS);
// Older databases gain rolled_back_by above, after schema.sql runs.
handle.exec(
'CREATE INDEX IF NOT EXISTS idx_deployments_rolled_back_by ON deployments(rolled_back_by)'
);
migrateTable(handle, 'server_launch_collections', [
{
name: 'cancelled_at',
ddl: 'ALTER TABLE server_launch_collections ADD COLUMN cancelled_at TEXT'
},
{
name: 'mode',
ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'"
},
{
name: 'retained_servers',
ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'"
}
]);
migrateTable(handle, 'server_launch_recipes', [
{
name: 'recipe_summary',
ddl: 'ALTER TABLE server_launch_recipes ADD COLUMN recipe_summary TEXT'
}
]);
migrateServerHistory(handle);
migrateTable(handle, 'server_collection_previews', [
{
name: 'mode',
ddl: "ALTER TABLE server_collection_previews ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'"
},
{
name: 'fleet_snapshot',
ddl: 'ALTER TABLE server_collection_previews ADD COLUMN fleet_snapshot TEXT'
}
]);
migrateTable(handle, 'server_telemetry', [
{ name: 'payload', ddl: 'ALTER TABLE server_telemetry ADD COLUMN payload TEXT' }
]);
// Kind-referencing indexes: safe here because migrateTable above just
// ALTER'd the column into existence if it was missing.
handle.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_scripts_one_baseline_per_scope
ON scripts(scope_type, scope_value) WHERE kind = 'baseline'`
);
handle.exec(`CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind)`);
backfillMultiScriptJoins(handle);
assertSnapshotSchemeSafe(handle);
migrateGameTelemetry(handle);
migrateTable(handle, 'leaderboard_matches', [
{
name: 'start_players',
ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER'
}
]);
migrateTable(handle, 'leaderboard_match_players', [
{
name: 'first_observed_round',
ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER'
},
{
name: 'present_at_start',
ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER'
}
]);
backfillLeaderboard(handle);
initializeFleetSettings(handle);
}
export function installLifecycleIndexes(handle: Database.Database): void {
// Lifecycle history is retained indefinitely. Admission and status reads
// must narrow by server or active state instead of scanning that history.
// Install after migrations that rebuild this table or add server_deleted_at.
handle.exec(`CREATE INDEX IF NOT EXISTS idx_server_deployments_server
ON server_deployments(server, server_deleted_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_state
ON server_deployments(state, action, server_deleted_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_server_state
ON server_deployments(server, server_deleted_at, state, action);
CREATE INDEX IF NOT EXISTS idx_server_deployments_started_at
ON server_deployments(started_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_submission
ON server_deployments(server, server_deleted_at, action, submitted_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_finished_at
ON server_deployments(finished_at) WHERE server_deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
}
+3 -5
View File
@@ -1,9 +1,7 @@
import { db } from './index.js'; import { db } from './index.js';
// The heavy lifting is idempotent DDL inside schema.sql, which db() runs on // Called from hooks.server.ts so startup fails before workers or requests run.
// first handle. This module exists as an explicit hook for future migrations // db() applies the ordered migrations.ts list using PRAGMA user_version.
// (numbered .sql files applied via a schema_version row) and to be called
// from hooks.server.ts so boot-time failures surface early.
export function migrate(): void { export function migrate(): void {
db().prepare('SELECT 1').get(); db();
} }
+60
View File
@@ -0,0 +1,60 @@
import type Database from 'better-sqlite3';
import { migrationStep } from './connection.js';
export interface Migration {
version: number;
name: string;
run: (handle: Database.Database) => void;
// Table rebuilds must disable foreign keys before opening the transaction.
// VACUUM cannot run in one; autocommit steps must be safe to resume.
mode?: 'foreign-keys-off' | 'autocommit';
}
export function runMigrations(handle: Database.Database, migrations: readonly Migration[]): void {
for (const [index, migration] of migrations.entries()) {
if (migration.version !== index + 1)
throw new Error('Database migrations must have consecutive versions starting at 1');
}
const version = () => handle.pragma('user_version', { simple: true }) as number;
const assertSupported = (current: number) => {
if (current < 0 || current > migrations.length)
throw new Error(
`Database version ${current} is not supported by this application (latest: ${migrations.length})`
);
};
const initialVersion = version();
assertSupported(initialVersion);
if (handle.inTransaction) throw new Error('Database migrations require their own transaction');
for (const migration of migrations) {
if (initialVersion >= migration.version) continue;
migrationStep(`${migration.version}: ${migration.name}`, () => {
const foreignKeys = handle.pragma('foreign_keys', { simple: true });
if (migration.mode === 'foreign-keys-off') handle.pragma('foreign_keys = OFF');
try {
const apply = () => {
// Recheck after acquiring the write lock in case another opener migrated.
const current = version();
assertSupported(current);
if (current >= migration.version) return;
if (current !== migration.version - 1)
throw new Error(`Missing database migration before ${migration.version}`);
migration.run(handle);
if (
migration.mode === 'foreign-keys-off' &&
(handle.pragma('foreign_key_check') as unknown[]).length
)
throw new Error(
`Database migration ${migration.version} left broken references`
);
handle.pragma(`user_version = ${migration.version}`);
};
if (migration.mode === 'autocommit') apply();
else handle.transaction(apply).immediate();
} finally {
if (migration.mode === 'foreign-keys-off')
handle.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
}
});
}
}
+224
View File
@@ -0,0 +1,224 @@
import { migrateLegacySchema, installLifecycleIndexes } from './legacy.js';
import { migrateNativeInstances } from '../backends/schema.js';
import { migrateServerKeys } from './server-keys.js';
import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import {
installMetricsMatchRevisions,
installMetricsPlayerRevisions,
installMetricsPolicyRevisions,
installMetricsProvisioningRevisions,
installReportRevisions
} from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.js';
import {
migrateAuthSchema,
migrateLoginDevices,
migrateRememberedSessions,
migrateSuperViewerRole,
migrateModeratorRole
} from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js';
import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js';
import { migrateAfk } from './afk.js';
import { migrateDiscord } from '../discord/schema.js';
import { migrateDiscordPublicationDefaults } from '../discord/publication-defaults.js';
import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js';
import {
migrateModeration,
migrateModerationCases,
migrateModerationCaseActions,
migrateModerationCaseAssessments,
migrateModerationCaseRelationships,
migrateModerationCaseFollowups,
migrateModerationCaseReferenceIndexes,
migrateModerationCaseStates
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an
// upgrade. These initial steps bridge every historical, unversioned database;
// their old sentinels are compatibility inputs, not the migration ledger.
export const migrations: readonly Migration[] = [
{
version: 1,
name: 'legacy controller schema and backfills',
mode: 'foreign-keys-off',
run: migrateLegacySchema
},
{
version: 2,
name: 'native instances and file plans',
mode: 'foreign-keys-off',
run: migrateNativeInstances
},
{ version: 3, name: 'per-game server key pools', run: migrateServerKeys },
{ version: 4, name: 'autoscaling', run: ensureAutoscalingSchema },
{ version: 5, name: 'rebalancing', run: ensureRebalancingSchema },
// Native file plans are stripped before artifact bodies are moved to blobs.
{ version: 6, name: 'shared file blobs', run: migrateFileBlobs },
{
version: 7,
name: 'query indexes and report revisions',
run: (handle) => {
installLifecycleIndexes(handle);
installPerformanceIndexes(handle);
installReportRevisions(handle);
}
},
{
version: 8,
name: 'retention clocks and indexes',
run: (handle) =>
retentionClocks(handle, {
deployments: ['started_at', 'finished_at'],
server_deployments: ['started_at', 'finished_at'],
maintenance_jobs: ['requested_at', 'finished_at'],
watchdog_incidents: ['first_seen_at', 'finished_at'],
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
betas: ['opened_at', 'closed_at'],
script_versions: ['uploaded_at'],
retention_controls: ['resolved_at']
})
},
{ version: 9, name: 'hosts and saved command files', run: migrateHostSchema },
{ version: 10, name: 'authentication', run: migrateAuthSchema },
{ version: 11, name: 'game-file libraries', run: migrateGameFileSchema },
{
version: 12,
name: 'incremental vacuum page map',
mode: 'autocommit',
run: enableIncrementalVacuum
},
{
version: 13,
name: 'metrics policy report revisions',
run: installMetricsPolicyRevisions
},
{
version: 14,
name: 'metrics match report revisions',
run: installMetricsMatchRevisions
},
{
version: 15,
name: 'metrics provisioning report revisions',
run: installMetricsProvisioningRevisions
},
{
version: 16,
name: 'metrics player history and report revisions',
run: installMetricsPlayerRevisions
},
{
version: 17,
name: 'super viewer authentication role',
mode: 'foreign-keys-off',
run: migrateSuperViewerRole
},
{
version: 18,
name: 'durable leaderboard report identity and sequences',
run: migrateLeaderboardReports
},
{
version: 19,
name: 'cumulative player score per match',
run: migratePlayerScoreTotal
},
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank },
{ version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials },
{ version: 22, name: 'remembered browser sessions', run: migrateRememberedSessions },
{ version: 23, name: 'host temperature history', run: migrateTemperatures },
{
version: 24,
name: 'rebuild generation evidence boundary',
run: (handle) => {
if (
!(handle.pragma('table_info(native_instances)') as { name: string }[]).some(
(column) => column.name === 'generation_reconciled_at'
)
)
handle.exec(
'ALTER TABLE native_instances ADD COLUMN generation_reconciled_at TEXT'
);
}
},
{
version: 25,
name: 'moderator authentication role',
mode: 'foreign-keys-off',
run: migrateModeratorRole
},
{ 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'
);
}
},
{ version: 28, name: 'known login devices', run: migrateLoginDevices },
{
version: 29,
name: 'moderation case workflow and linked restrictions',
mode: 'foreign-keys-off',
run: migrateModerationCases
},
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk },
{
version: 31,
name: 'moderation case links to existing actions',
run: migrateModerationCaseActions
},
{
version: 32,
name: 'moderation case assessment revision history',
run: migrateModerationCaseAssessments
},
{
version: 33,
name: 'moderation case relationships and duplicate review',
run: migrateModerationCaseRelationships
},
{
version: 34,
name: 'moderation follow-up dates and personal updates',
run: migrateModerationCaseFollowups
},
{
version: 35,
name: 'moderation case foreign key reference indexes',
run: migrateModerationCaseReferenceIndexes
},
{
version: 36,
name: 'moderation case lifecycle states',
mode: 'foreign-keys-off',
run: migrateModerationCaseStates
},
{ version: 37, name: 'Discord boards and availability subscriptions', run: migrateDiscord },
{
version: 38,
name: 'Discord default server publication',
run: migrateDiscordPublicationDefaults
}
];
export const databaseVersion = migrations.length;
+11
View File
@@ -0,0 +1,11 @@
import type Database from 'better-sqlite3';
/** Old score balances cannot be used to reconstruct cumulative points. */
export function migratePlayerScoreTotal(handle: Database.Database): void {
const columns = handle.prepare('PRAGMA table_info(leaderboard_match_players)').all() as {
name: string;
}[];
if (!columns.some((column) => column.name === 'score_total'))
handle.exec(`ALTER TABLE leaderboard_match_players ADD COLUMN score_total INTEGER
CHECK (score_total IS NULL OR (typeof(score_total) = 'integer' AND score_total >= 0))`);
}
+4 -4
View File
@@ -8,12 +8,12 @@ CREATE TABLE IF NOT EXISTS file_blob_references (
); );
CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256); CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256);
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts. -- Base schema for the version-0 compatibility bridge in migrations.ts.
-- Future schema changes belong in new numbered migrations.
-- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this -- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this
-- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model -- runs (see dropLegacyBetaTables in db/legacy.ts); the new script-centric model
-- lives here. -- lives here.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
-- Historical gameplay deliberately has no FK to servers: destroying a server -- Historical gameplay deliberately has no FK to servers: destroying a server
@@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS scripts (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
); );
CREATE INDEX IF NOT EXISTS idx_scripts_scope ON scripts(scope_type, scope_value); CREATE INDEX IF NOT EXISTS idx_scripts_scope ON scripts(scope_type, scope_value);
-- kind-referencing indexes are created in db/index.ts AFTER migrateTable adds -- kind-referencing indexes are created in db/legacy.ts after migrateTable adds
-- the column to legacy DBs. Putting them here would fail with "no such column: -- the column to legacy DBs. Putting them here would fail with "no such column:
-- kind" on an existing pre-kind DB, breaking boot. -- kind" on an existing pre-kind DB, breaking boot.
+27
View File
@@ -0,0 +1,27 @@
import type { DiscordAdminState } from '../../discord.js';
import { fleetIdentity } from '../../fleet-overview.js';
import { listServers } from '../fleet/servers.js';
import { discordCredentialStatus } from './config.js';
import { publicFleet } from './projection.js';
import { discordStatus, getDiscordSettings } from './store.js';
export function discordAdminState(): DiscordAdminState {
const settings = getDiscordSettings();
const published = publicFleet(settings);
return {
settings,
credentials: discordCredentialStatus(),
status: discordStatus(),
servers: listServers().flatMap((server) => {
const identity = fleetIdentity(server);
if (identity.game !== 't5' && identity.game !== 't6') return [];
return [
{ name: server.name, game: identity.game, label: identity.title, map: identity.map }
];
}),
preview: {
t5: published.filter((server) => server.game === 't5'),
t6: published.filter((server) => server.game === 't6')
}
};
}
+131
View File
@@ -0,0 +1,131 @@
import { leaderboardMetrics } from '../../leaderboard.js';
import { zombieMaps } from '../../map-aliases.js';
const games = [
{ name: 'Black Ops I', value: 't5' },
{ name: 'Black Ops II', value: 't6' }
];
export const discordMaps = (['t5', 't6'] as const).flatMap((game) =>
zombieMaps(game).map((map) => ({
name: `${game === 't5' ? 'BO1' : 'BO2'} · ${map.friendly}`,
value: [game, map.canonical, ...(game === 't6' ? [map.slug] : [])].join(':')
}))
);
const gameOption = { type: 3, name: 'game', description: 'Game', choices: games };
const mapOption = { type: 3, name: 'map', description: 'Map and ruleset', choices: discordMaps };
const pageOption = {
type: 4,
name: 'page',
description: 'Page number',
min_value: 1,
max_value: 100001
};
/** Guild commands only: no Gateway connection or privileged member/content intents. */
export const discordCommands = [
{
name: 'servers',
description: 'Browse Electric Cherry servers privately',
type: 1,
options: [
gameOption,
mapOption,
{
type: 3,
name: 'status',
description: 'Observed occupancy',
choices: [
{ name: 'All', value: 'all' },
{ name: 'Empty', value: 'empty' },
{ name: 'Space available', value: 'space' },
{ name: 'Full', value: 'full' },
{ name: 'In progress', value: 'in-progress' }
]
},
pageOption
]
},
{
name: 'server',
description: 'Show details of a published server',
type: 1,
options: [
{
type: 3,
name: 'name',
description: 'Published server name (or use a Details button)',
required: true,
max_length: 128
}
]
},
{
name: 'leaderboard',
description: 'View Electric Cherry completed-game rankings',
type: 1,
options: [
gameOption,
mapOption,
{
type: 3,
name: 'metric',
description: 'Ranking metric',
choices: leaderboardMetrics.map((metric) => ({
name: metric.label,
value: metric.key
}))
},
{
type: 4,
name: 'squad',
description: 'Number of players at match start',
min_value: 1,
max_value: 4
},
pageOption
]
},
{
name: 'watch',
description: 'Receive one private map availability notification',
type: 1,
options: [
{
type: 1,
name: 'add',
description: 'Watch a map for up to 24 hours',
options: [
{ ...gameOption, required: true },
{ ...mapOption, required: true },
{
type: 3,
name: 'condition',
description: 'Observed occupancy to watch for',
required: true,
choices: [
{ name: 'An empty lobby', value: 'empty' },
{ name: 'A lobby with space available', value: 'space' }
]
}
]
},
{ type: 1, name: 'list', description: 'List your active watches' },
{
type: 1,
name: 'cancel',
description: 'Cancel one of your watches',
options: [
{
type: 3,
name: 'id',
description: 'Watch ID shown by /watch list',
required: true,
max_length: 100
}
]
}
]
},
{ name: 'help', description: 'How to use the Electric Cherry companion', type: 1 }
];
+30
View File
@@ -0,0 +1,30 @@
import { discordSnowflake } from '../../discord.js';
/** Credentials belong to the deployment environment and never to browser settings. */
export function discordCredentials() {
return {
applicationId: (process.env.EC_DISCORD_APPLICATION_ID ?? '').trim(),
publicKey: (process.env.EC_DISCORD_PUBLIC_KEY ?? '').trim(),
botToken: (process.env.EC_DISCORD_BOT_TOKEN ?? '').trim()
};
}
export function discordCredentialStatus() {
const credentials = discordCredentials();
return {
applicationId: credentials.applicationId,
publicKeyConfigured: /^[a-fA-F0-9]{64}$/.test(credentials.publicKey),
botTokenConfigured: !!credentials.botToken
};
}
export function requireDiscordCredentials(): ReturnType<typeof discordCredentials> {
const credentials = discordCredentials();
if (
!discordSnowflake(credentials.applicationId) ||
!/^[a-fA-F0-9]{64}$/.test(credentials.publicKey) ||
!credentials.botToken
)
throw new Error('Configure the Discord application ID, public key and bot token first.');
return credentials;
}
+322
View File
@@ -0,0 +1,322 @@
import {
discordSnowflake,
type DiscordGame,
type DiscordMessage,
type DiscordPublicServer
} from '../../discord.js';
import {
leaderboardMetrics,
type LeaderboardMetric,
type LeaderboardOptions,
type LeaderboardPlayerCount
} from '../../leaderboard.js';
import { leaderboardReport } from '../reports/index.js';
import { discordCredentials } from './config.js';
import { discordMaps } from './commands.js';
import { discordServerKey, discordText, publicFleet } from './projection.js';
import {
cancelSubscription,
getDiscordSettings,
listSubscriptions,
subscribeToMap
} from './store.js';
import {
LEADERBOARD_PAGE_SIZE,
privateMessage,
renderBrowser,
renderLeaderboard,
renderServer,
renderWatchChoice,
renderWatchMaps,
type BrowserFilter,
type BrowserStatus
} from './render.js';
interface DiscordOption {
type?: number;
name: string;
value?: string | number;
options?: DiscordOption[];
}
export interface DiscordInteraction {
type: number;
application_id?: string;
guild_id?: string;
member?: { user?: { id?: string } };
data?: { name?: string; options?: DiscordOption[]; custom_id?: string; values?: string[] };
}
export interface DiscordInteractionResponse {
type: 4;
data: DiscordMessage;
}
class InputError extends Error {}
const response = (data: DiscordMessage): DiscordInteractionResponse => ({ type: 4, data });
export const privateDiscordError = (message: string) =>
response(privateMessage(discordText(message, 400)));
const statuses: BrowserStatus[] = ['all', 'empty', 'space', 'full', 'in-progress'];
function optionValues(options: DiscordOption[] | undefined): Map<string, string | number> {
if (options !== undefined && (!Array.isArray(options) || options.length > 25))
throw new InputError('Invalid command options.');
const values = new Map<string, string | number>();
for (const option of options ?? []) {
if (
!option ||
typeof option.name !== 'string' ||
values.has(option.name) ||
!['string', 'number'].includes(typeof option.value)
)
throw new InputError('Invalid command options.');
values.set(option.name, option.value!);
}
return values;
}
function stringOption(
values: Map<string, string | number>,
key: string,
limit = 128
): string | undefined {
const value = values.get(key);
if (value === undefined) return undefined;
if (typeof value !== 'string' || !value.trim() || value.length > limit)
throw new InputError(`Invalid ${key} option.`);
return value.trim();
}
function scope(values: Map<string, string | number>): { game?: DiscordGame; map?: string } {
const game = stringOption(values, 'game');
const map = stringOption(values, 'map');
if (game && game !== 't5' && game !== 't6')
throw new InputError('Choose Black Ops I or Black Ops II.');
if (map && !discordMaps.some((entry) => entry.value === map))
throw new InputError('Choose a map from the command options.');
if (game && map && !map.startsWith(`${game}:`))
throw new InputError('Choose a map from the selected game.');
return { game: (game ?? map?.slice(0, 2)) as DiscordGame | undefined, map };
}
function page(values: Map<string, string | number>): number {
const value = values.get('page') ?? 1;
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100001)
throw new InputError('Choose a page between 1 and 100001.');
return Number(value) - 1;
}
function watchMap(
servers: DiscordPublicServer[],
key: string,
game?: DiscordGame
): DiscordPublicServer {
const found = servers.find(
(server) =>
(server.mapKey === key || discordServerKey(server.mapKey) === key) &&
(!game || server.game === game)
);
if (!found)
throw new InputError('That map is no longer published. Browse the current server list.');
if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(found.mapKey))
throw new InputError('A map watch is unavailable for this reported map.');
return found;
}
async function leaderboard(values: Map<string, string | number>): Promise<DiscordMessage> {
const settings = getDiscordSettings();
if (!settings.leaderboardEnabled)
return privateMessage('Leaderboard publication is not enabled for this Discord server.');
const metric = stringOption(values, 'metric') ?? 'highestRound';
if (!leaderboardMetrics.some((entry) => entry.key === metric))
throw new InputError('Choose a ranking metric from the command options.');
const squad = values.get('squad');
if (
squad !== undefined &&
(!Number.isSafeInteger(squad) || Number(squad) < 1 || Number(squad) > 4)
)
throw new InputError('Squad size must be between 1 and 4.');
const query: LeaderboardOptions = {
...scope(values),
metric: metric as LeaderboardMetric,
players: squad as LeaderboardPlayerCount | undefined,
limit: LEADERBOARD_PAGE_SIZE,
offset: page(values) * LEADERBOARD_PAGE_SIZE
};
const report = await leaderboardReport(query);
// Publishing may have been disabled while the report worker was running.
const current = getDiscordSettings();
if (!current.enabled || current.guildId !== settings.guildId || !current.leaderboardEnabled)
return privateMessage('Leaderboard publication is no longer enabled.');
return renderLeaderboard(report, query);
}
function addWatch(
servers: DiscordPublicServer[],
key: string,
mode: string | undefined,
guildId: string,
userId: string,
game?: DiscordGame
): DiscordMessage {
if (mode !== 'empty' && mode !== 'space')
throw new InputError('Choose an empty-lobby or space-available watch.');
const server = watchMap(servers, key, game);
let watch: { id: string; expiresAt: number };
try {
watch = subscribeToMap({ guildId, userId, game: server.game, mapKey: server.mapKey, mode });
} catch (error) {
if (
error instanceof Error &&
(error.message.startsWith('You already have 10') ||
error.message.startsWith('Map watches are currently full'))
)
throw new InputError(error.message);
throw error;
}
return privateMessage(
`Watching **${discordText(server.map)}** for ${mode === 'empty' ? 'an empty lobby' : 'space available'}.\nYou will receive one DM after stable observations. Enable DMs from this server to receive it. This does not guarantee match admission.\nExpires <t:${Math.floor(watch.expiresAt / 1000)}:R> · Cancel with /watch cancel\nWatch ID: ${discordText(watch.id)}`
);
}
function parseBrowserId(id: string): BrowserFilter {
const match =
/^ec:b:(all|t5|t6):(all|empty|space|full|in-progress):(\d{1,6}):([A-Za-z0-9_-]{0,70})(?::(?:prev|next))?$/.exec(
id
);
if (!match) throw new InputError('This browser control is invalid. Run /servers again.');
const map = Buffer.from(match[4], 'base64url').toString();
if (map && !discordMaps.some((entry) => entry.value === map))
throw new InputError('This map filter is unavailable. Run /servers again.');
return {
game: match[1] === 'all' ? undefined : (match[1] as DiscordGame),
status: match[2] as BrowserStatus,
page: Number(match[3]),
map: map || undefined
};
}
/** All responses are private new messages; components never mutate the shared board. */
export async function handleDiscordInteraction(
interaction: DiscordInteraction
): Promise<DiscordInteractionResponse> {
const userId = interaction.member?.user?.id;
if (
!discordSnowflake(userId) ||
!discordSnowflake(interaction.guild_id) ||
interaction.application_id !== discordCredentials().applicationId
)
return privateDiscordError('This interaction is not available here.');
const settings = getDiscordSettings();
if (!settings.enabled || settings.guildId !== interaction.guild_id)
return privateDiscordError('Electric Cherry is not enabled for this Discord server.');
if (interaction.type !== 2 && interaction.type !== 3)
return privateDiscordError('This interaction is not supported.');
const data = interaction.data;
if (!data || typeof data !== 'object')
return privateDiscordError('This interaction is invalid.');
try {
if (interaction.type === 2 && data.name === 'leaderboard')
return response(await leaderboard(optionValues(data.options)));
if (interaction.type === 2 && data.name === 'help')
return response(
privateMessage(
'## 🍒 Electric Cherry\n/servers — browse live servers privately\n/server — inspect a published server\n/leaderboard — completed-game rankings, when enabled\n/watch add — one DM when a map has an empty lobby or space\n/watch list · /watch cancel — manage your watches\n\nWatches expire after 24 hours. Observed spaces do not guarantee that a match accepts new players.'
)
);
const servers = publicFleet(settings);
if (interaction.type === 3) {
const id = data.custom_id;
if (typeof id !== 'string' || id.length > 100)
throw new InputError('This control is invalid.');
if (id.startsWith('ec:b:')) return response(renderBrowser(servers, parseBrowserId(id)));
if (/^ec:d:[a-f0-9]{20}$/.test(id)) {
const server = servers.find(
(entry) => discordServerKey(entry.name) === id.slice(5)
);
if (!server) throw new InputError('This server is no longer published.');
return response(renderServer(server));
}
if (/^ec:maps:(t5|t6)$/.test(id))
return response(renderWatchMaps(servers, id.slice(8) as DiscordGame));
if (
id === 'ec:map' &&
Array.isArray(data.values) &&
data.values.length === 1 &&
typeof data.values[0] === 'string'
)
return response(renderWatchChoice(watchMap(servers, data.values[0])));
const watch = /^ec:w:(empty|space):([a-f0-9]{20})$/.exec(id);
if (watch)
return response(addWatch(servers, watch[2], watch[1], settings.guildId, userId));
if (/^ec:leader:(t5|t6)$/.test(id))
return response(await leaderboard(new Map([['game', id.slice(10)]])));
throw new InputError(
'This control is no longer available. Run /help for current commands.'
);
}
if (data.name === 'watch') {
if (!Array.isArray(data.options) || data.options.length !== 1)
throw new InputError('Choose watch add, list, or cancel.');
const command = data.options[0];
const values = optionValues(command.options);
if (command.name === 'add') {
const selected = scope(values);
if (!selected.game || !selected.map) throw new InputError('Choose a game and map.');
return response(
addWatch(
servers,
selected.map,
stringOption(values, 'condition'),
settings.guildId,
userId,
selected.game
)
);
}
if (command.name === 'cancel') {
const id = stringOption(values, 'id', 100);
if (!id) throw new InputError('Provide the watch ID shown by /watch list.');
return response(
privateMessage(
cancelSubscription(id, settings.guildId, userId)
? 'Watch cancelled.'
: 'No active watch with that ID belongs to you.'
)
);
}
if (command.name === 'list') {
const watches = listSubscriptions(userId, settings.guildId);
return response(
privateMessage(
`## Your map watches\n${watches.length ? watches.map((watch) => `**${discordText(servers.find((server) => server.mapKey === watch.mapKey)?.map ?? discordMaps.find((map) => map.value === watch.mapKey)?.name ?? 'Unpublished map', 60)}** · ${watch.mode === 'empty' ? 'Empty lobby' : 'Space available'} · Expires <t:${Math.floor(watch.expiresAt / 1000)}:R>\nID: ${discordText(watch.id)}`).join('\n\n') : 'No active watches. Use /watch add to create one.'}`
)
);
}
throw new InputError('Choose watch add, list, or cancel.');
}
const values = optionValues(data.options);
if (data.name === 'servers') {
const status = stringOption(values, 'status') ?? 'all';
if (!statuses.includes(status as BrowserStatus))
throw new InputError('Choose an occupancy filter from the command options.');
return response(
renderBrowser(servers, {
...scope(values),
status: status as BrowserStatus,
page: page(values)
})
);
}
if (data.name === 'server') {
const name = stringOption(values, 'name');
const server = servers.find((entry) => entry.name === name || entry.label === name);
if (!server)
throw new InputError(
'That server is not published. Use /servers and click Details.'
);
return response(renderServer(server));
}
throw new InputError('This command is not supported. Run /help for available commands.');
} catch (error) {
if (error instanceof InputError) return privateDiscordError(error.message);
throw error;
}
}
+139
View File
@@ -0,0 +1,139 @@
import { createHash } from 'node:crypto';
import type { DiscordPublicServer, DiscordSettings } from '../../discord.js';
import {
currentRound,
fleetIdentity,
freshServer,
type FleetServer
} from '../../fleet-overview.js';
import { currentCapacity } from '../../server-capacity.js';
import { parseServerName, selectedZombieMap, zombieMaps } from '../../map-aliases.js';
import { fleetSnapshot } from '../fleet/snapshot.js';
type SourceServer = FleetServer & {
network?: { publicAddress: string | null; publicPort: number | null } | null;
};
export const DEFAULT_DISCORD_LOBBY_CAPACITY = 4;
/** IDs stay short enough for Discord components, even for long registry names. */
export function discordServerKey(name: string): string {
return createHash('sha256').update(name).digest('hex').slice(0, 20);
}
/** Rendering never interprets a game nickname as Markdown, a link, or a mention. */
export function discordText(value: string, limit = 160): string {
return (
value
.replace(/[\r\n\t]/g, ' ')
// eslint-disable-next-line no-control-regex -- Strip untrusted game-name control characters.
.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')
.slice(0, limit)
.replace(/@/g, '@\u200b')
.replace(/</g, '‹')
.replace(/>/g, '›')
.replace(/([\\`*_{}[\]()~|])/g, '\\$1')
);
}
function mapIdentity(server: SourceServer, game: 't5' | 't6') {
const parsed = parseServerName(server.name);
const map = server.last_map ?? server.map ?? parsed?.mapCanonical ?? 'unknown';
const slug = server.map === map ? (server.map_slug ?? parsed?.mapSlug) : undefined;
const candidates = zombieMaps(game).filter((entry) => entry.canonical === map);
const selected =
game === 't6' && candidates.length > 1 && !slug
? undefined
: selectedZombieMap(game, map, slug);
return {
map: selected?.friendly ?? fleetIdentity(server).map ?? 'Unknown map',
mapKey: [
game,
map,
...(game === 't6' ? [selected?.slug ?? slug ?? 'unspecified'] : [])
].join(':')
};
}
function publicConnection(server: SourceServer): string | null {
const address = server.network?.publicAddress?.trim();
const port = server.network?.publicPort;
// Never fall back to agent, RCON, bind, or inferred host addresses.
if (!address || !/^[A-Za-z0-9.:[\]-]{1,253}$/.test(address)) return null;
if (!Number.isSafeInteger(port) || port! < 1 || port! > 65535) return null;
const host = address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
return `${host}:${port}`;
}
/** Explicit field projection is the boundary between private fleet data and Discord. */
export function projectFleet(
servers: readonly SourceServer[],
settings: DiscordSettings,
now = Date.now()
): DiscordPublicServer[] {
const excluded = new Set(settings.excludedServers);
return servers
.flatMap((server): DiscordPublicServer[] => {
if (excluded.has(server.name)) return [];
const identity = fleetIdentity(server);
if (identity.game !== 't5' && identity.game !== 't6') return [];
const game = identity.game;
const fresh =
freshServer(server, now) &&
(!server.telemetry_after ||
Date.parse(server.last_seen_at ?? '') > Date.parse(server.telemetry_after));
const players =
fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0
? server.last_players
: null;
// These lobbies have four slots when the live status omits the limit.
// Occupancy still needs fresh, current-process evidence below.
const capacity = currentCapacity(server, now) ?? DEFAULT_DISCORD_LOBBY_CAPACITY;
const state: DiscordPublicServer['state'] =
server.health === 'starting' || server.health === 'provisioning'
? 'starting'
: server.health === 'offline'
? 'offline'
: server.health === 'stale' || (server.health === 'online' && !fresh)
? 'stale'
: players === null
? 'unknown'
: players === 0
? 'empty'
: capacity !== null && players >= capacity
? 'full'
: 'in-progress';
const observed = Date.parse(server.last_seen_at ?? '');
return [
{
name: server.name,
game,
label: identity.title,
...mapIdentity(server, game),
state,
players,
capacity,
round: players === null ? null : currentRound(server, server.telemetry, now),
observedAt:
Number.isFinite(observed) && observed <= now + 1000
? new Date(observed).toISOString()
: null,
playerNames:
settings.showPlayers && fresh && players !== null
? (server.roster ?? []).slice(0, players).map((player) => player.name)
: [],
connection: settings.showAddresses ? publicConnection(server) : null
}
];
})
.sort(
(a, b) =>
a.game.localeCompare(b.game) ||
a.label.localeCompare(b.label) ||
a.name.localeCompare(b.name)
);
}
export function publicFleet(settings: DiscordSettings, now = Date.now()): DiscordPublicServer[] {
return projectFleet(fleetSnapshot().servers, settings, now);
}
@@ -0,0 +1,24 @@
import type Database from 'better-sqlite3';
/** Replace the old opt-in default while preserving an existing nonempty selection. */
export function migrateDiscordPublicationDefaults(handle: Database.Database): void {
const row = handle.prepare('SELECT value FROM discord_configuration WHERE id=1').get() as
{ value: string } | undefined;
if (!row) return;
const settings = JSON.parse(row.value) as Record<string, unknown>;
if ('excludedServers' in settings) return;
const published = new Set(settings.publishedServers as string[]);
settings.excludedServers = published.size
? (
handle
.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL ORDER BY name')
.all() as { name: string }[]
)
.map((server) => server.name)
.filter((name) => !published.has(name))
: [];
delete settings.publishedServers;
handle
.prepare('UPDATE discord_configuration SET value=? WHERE id=1')
.run(JSON.stringify(settings));
}
+285
View File
@@ -0,0 +1,285 @@
import type { DiscordGame, DiscordMessage, DiscordPublicServer } from '../../discord.js';
import type { LeaderboardOptions, LeaderboardResponse } from '../../leaderboard.js';
import { leaderboardMetrics } from '../../leaderboard.js';
import {
efficiencyRequirement,
formatEfficiencyValue,
isEfficiencyMetric
} from '../../leaderboard-efficiency.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { gameTitle } from '../../ui/server-labels.js';
import { DEFAULT_DISCORD_LOBBY_CAPACITY, discordServerKey, discordText } from './projection.js';
export const BOARD_PAGE_SIZE = 8;
export const BROWSER_PAGE_SIZE = 5;
export const LEADERBOARD_PAGE_SIZE = 10;
const COMPONENTS_V2 = 1 << 15;
export const EPHEMERAL = 1 << 6;
type Component = Record<string, unknown>;
export type BrowserStatus = 'all' | 'empty' | 'space' | 'full' | 'in-progress';
export interface BrowserFilter {
game?: DiscordGame;
map?: string;
status?: BrowserStatus;
page?: number;
}
const text = (content: string): Component => ({ type: 10, content });
const button = (label: string, custom_id: string, disabled = false): Component => ({
type: 2,
style: 2,
label,
custom_id,
disabled
});
const row = (...components: Component[]): Component => ({ type: 1, components });
const container = (components: Component[]): Component => ({
type: 17,
accent_color: 0xd94868,
components
});
export function discordMessage(components: Component[], privateReply = true): DiscordMessage {
return {
flags: COMPONENTS_V2 | (privateReply ? EPHEMERAL : 0),
components,
allowed_mentions: { parse: [] }
};
}
export function privateMessage(message: string): DiscordMessage {
return discordMessage([text(message)]);
}
const states: Record<DiscordPublicServer['state'], string> = {
empty: '🟢 Empty lobby',
'in-progress': '🔵 In progress',
full: '🟠 Full',
starting: '🟡 Starting',
offline: '⚫ Offline',
stale: '🟡 Stale observation',
unknown: '⚪ Status unknown'
};
function summary(server: DiscordPublicServer): string {
const count =
server.players === null
? 'Players unknown'
: `${server.players}/${server.capacity ?? DEFAULT_DISCORD_LOBBY_CAPACITY} players`;
return `${count}${server.round === null ? '' : ` · Round ${server.round}`}\n${states[server.state]}`;
}
function observed(server: DiscordPublicServer): string {
return server.observedAt
? `Observed <t:${Math.floor(Date.parse(server.observedAt) / 1000)}:R>`
: 'No observation available';
}
function serverSection(server: DiscordPublicServer): Component {
const map = server.label.startsWith(server.map) ? '' : `Map: ${discordText(server.map, 50)}\n`;
return {
type: 9,
components: [
text(
`**${discordText(server.label, 90)}**\n${map}${summary(server)}\n-# ${observed(server)}`
)
],
accessory: button('Details', `ec:d:${discordServerKey(server.name)}`)
};
}
/** Each page has <= 32 total components and comfortably fits Discord's 4,000 text limit. */
export function renderBoard(
game: DiscordGame,
servers: readonly DiscordPublicServer[]
): DiscordMessage[] {
const selected = servers.filter((server) => server.game === game);
const pages = Math.max(1, Math.ceil(selected.length / BOARD_PAGE_SIZE));
return Array.from({ length: pages }, (_, page) => {
const players = selected.reduce((sum, server) => sum + (server.players ?? 0), 0);
const sections = selected
.slice(page * BOARD_PAGE_SIZE, (page + 1) * BOARD_PAGE_SIZE)
.map(serverSection);
return discordMessage(
[
container([
text(
`## 🍒 Electric Cherry · ${gameTitle(game)}\n${selected.length} servers · ${players} observed players${pages > 1 ? ` · Page ${page + 1}/${pages}` : ''}`
),
...(sections.length
? sections
: [text('No servers are published for this game yet.')]),
text(
'-# Available spaces do not establish whether an in-progress match accepts players.'
),
row(
button('Browse servers', browserId({ game })),
button('Watch a map', `ec:maps:${game}`),
button('Leaderboards', `ec:leader:${game}`)
)
])
],
false
);
});
}
export function browserId(filter: BrowserFilter): string {
return `ec:b:${filter.game ?? 'all'}:${filter.status ?? 'all'}:${filter.page ?? 0}:${Buffer.from(filter.map ?? '').toString('base64url')}`;
}
export function filterServers(
servers: readonly DiscordPublicServer[],
filter: BrowserFilter
): DiscordPublicServer[] {
return servers.filter(
(server) =>
(!filter.game || server.game === filter.game) &&
(!filter.map || server.mapKey === filter.map) &&
(!filter.status ||
filter.status === 'all' ||
(filter.status === 'space'
? server.players !== null &&
server.capacity !== null &&
server.players < server.capacity
: server.state === filter.status))
);
}
export function renderBrowser(
servers: readonly DiscordPublicServer[],
filter: BrowserFilter = {}
): DiscordMessage {
const selected = filterServers(servers, filter);
const pages = Math.max(1, Math.ceil(selected.length / BROWSER_PAGE_SIZE));
const page = Math.max(0, Math.min(filter.page ?? 0, pages - 1));
const current = { ...filter, page };
return discordMessage([
container([
text(
`## Browse servers\n${filter.game ? gameTitle(filter.game) : 'All games'} · ${selected.length} matching servers · Page ${page + 1}/${pages}`
),
...selected
.slice(page * BROWSER_PAGE_SIZE, (page + 1) * BROWSER_PAGE_SIZE)
.map(serverSection),
...(selected.length ? [] : [text('No servers match these filters.')]),
row(
button('All', browserId({ ...current, status: 'all', page: 0 })),
button('Empty', browserId({ ...current, status: 'empty', page: 0 })),
button('Space available', browserId({ ...current, status: 'space', page: 0 })),
button('Full', browserId({ ...current, status: 'full', page: 0 }))
),
row(
button(
'Previous',
`${browserId({ ...current, page: Math.max(0, page - 1) })}:prev`,
page === 0
),
button(
'Next',
`${browserId({ ...current, page: page + 1 })}:next`,
page + 1 >= pages
)
),
text(
'-# Your filters are private. Use /servers for game and map filters. Space available describes occupancy only.'
)
])
]);
}
export function renderServer(server: DiscordPublicServer): DiscordMessage {
return discordMessage([
container([
text(
`## ${discordText(server.label, 100)}\n${gameTitle(server.game)} · ${discordText(server.map, 100)}\n${summary(server)}\n${observed(server)}`
),
...(server.playerNames.length
? [
text(
`**Players**\n${server.playerNames
.slice(0, 16)
.map((name) => discordText(name, 60))
.join(' · ')}`
)
]
: []),
text(
`**Connection**\n${server.connection ? `Public endpoint: ${discordText(server.connection, 270)}\n` : ''}${server.game === 't5' ? 'Open the Black Ops I Plutonium server browser and find this lobby.' : server.connection ? 'Open the Black Ops II Plutonium console and enter connect followed by the public endpoint above.' : 'Open the Black Ops II Plutonium server browser and find this lobby.'}\nMatch admission is not reported by Electric Cherry.`
),
row(
button('Watch for empty', `ec:w:empty:${discordServerKey(server.mapKey)}`),
button('Watch for space', `ec:w:space:${discordServerKey(server.mapKey)}`),
button('Browse servers', browserId({ game: server.game }))
)
])
]);
}
export function renderWatchMaps(
servers: readonly DiscordPublicServer[],
game?: DiscordGame
): DiscordMessage {
const maps = [
...new Map(
servers
.filter((server) => !game || server.game === game)
.map((server) => [server.mapKey, server])
).values()
];
if (!maps.length) return privateMessage('No published maps are available to watch.');
return discordMessage([
text(
'## Watch a map\nChoose a map, then choose empty or space available. Watches send one DM, expire after 24 hours, and can be cancelled with /watch.'
),
row({
type: 3,
custom_id: 'ec:map',
placeholder: 'Choose a published map',
options: maps.slice(0, 25).map((server) => ({
label: `${gameTitle(server.game, true)} · ${discordText(server.map, 40)}`.slice(
0,
100
),
value: discordServerKey(server.mapKey)
}))
})
]);
}
export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage {
return discordMessage([
text(
`## Watch ${discordText(server.map)}\nEmpty means zero observed players. Space means fewer players than the lobby capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours`
),
row(
button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`),
button('Space available', `ec:w:space:${discordServerKey(server.mapKey)}`)
)
]);
}
export function renderLeaderboard(
report: LeaderboardResponse,
query: LeaderboardOptions
): DiscordMessage {
const metric = report.metric;
const label = leaderboardMetrics.find((item) => item.key === metric)!.label;
const map = report.maps.find((item) => item.key === query.map)?.name;
const items = report.entries.map((entry) => {
const value = isEfficiencyMetric(metric)
? formatEfficiencyValue(entry[metric], metric)
: entry[metric].toLocaleString('en-US');
return `**${entry.rank}.** ${discordText(entry.name, 60)} — **${value}**`;
});
return discordMessage([
container([
text(
`## ${label}\n${query.game ? gameTitle(query.game) : 'All games'}${map ? ` · ${discordText(map)}` : ''}${query.players ? ` · ${query.players}-player starts` : ''}\n${items.length ? items.join('\n') : 'No qualifying records in this scope.'}`
),
text(
`-# Completed matches with player records reaching round ${MIN_RECORDED_ROUND} or later.${isEfficiencyMetric(metric) ? ` ${efficiencyRequirement(metric)}.` : ''}\n-# Page ${Math.floor(report.offset / LEADERBOARD_PAGE_SIZE) + 1} · ${report.total} ranked players. Use /leaderboard to change filters or page.`
)
])
]);
}
+43
View File
@@ -0,0 +1,43 @@
import type Database from 'better-sqlite3';
export function migrateDiscord(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS discord_configuration (
id INTEGER PRIMARY KEY CHECK (id=1), value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS discord_health (
id INTEGER PRIMARY KEY CHECK (id=1), last_attempt_at INTEGER,
last_success_at INTEGER, last_error TEXT
);
INSERT OR IGNORE INTO discord_health(id) VALUES(1);
CREATE TABLE IF NOT EXISTS discord_boards (
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')),
guild_id TEXT NOT NULL, channel_id TEXT NOT NULL, page INTEGER NOT NULL, message_id TEXT,
content_hash TEXT, verified_at INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0,
UNIQUE(game,guild_id,channel_id,page)
);
CREATE TABLE IF NOT EXISTS discord_subscriptions (
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
game TEXT NOT NULL CHECK(game IN ('t5','t6')), map_key TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('empty','space')),
state TEXT NOT NULL CHECK(state IN ('active','queued','delivered','failed','cancelled','expired')),
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
candidate TEXT, candidate_at INTEGER, error TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS discord_subscription_active
ON discord_subscriptions(guild_id,user_id,game,map_key,mode)
WHERE state IN ('active','queued');
CREATE INDEX IF NOT EXISTS discord_subscription_expiry ON discord_subscriptions(expires_at);
CREATE TABLE IF NOT EXISTS discord_outbox (
id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('board','delete','alert')),
target_id TEXT NOT NULL UNIQUE, payload TEXT NOT NULL, content_hash TEXT,
channel_id TEXT, attempts INTEGER NOT NULL DEFAULT 0,
next_at INTEGER, created_at INTEGER NOT NULL, error TEXT
);
CREATE INDEX IF NOT EXISTS discord_outbox_due ON discord_outbox(next_at);
CREATE TABLE IF NOT EXISTS discord_interactions (
id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS discord_interactions_expiry ON discord_interactions(expires_at);
`);
}
+503
View File
@@ -0,0 +1,503 @@
import { createHash, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import {
defaultDiscordSettings,
discordSnowflake,
validateDiscordSettings,
type DiscordDeliveryStatus,
type DiscordGame,
type DiscordMessage,
type DiscordSettings
} from '../../discord.js';
import { db } from '../db/index.js';
export interface DiscordSubscription {
id: string;
guildId: string;
userId: string;
game: DiscordGame;
mapKey: string;
mode: 'empty' | 'space';
state: 'active' | 'queued' | 'delivered' | 'failed' | 'cancelled' | 'expired';
expiresAt: number;
candidate: string | null;
candidateAt: number | null;
error: string | null;
}
export interface DiscordBoard {
id: string;
game: DiscordGame;
guildId: string;
channelId: string;
page: number;
messageId: string | null;
contentHash: string | null;
verifiedAt: number;
generation: number;
}
export interface DiscordJob {
id: string;
kind: 'board' | 'delete' | 'alert';
targetId: string;
payload: DiscordMessage;
contentHash: string | null;
channelId: string | null;
attempts: number;
createdAt: number;
}
export interface MapSubscriptionInput {
guildId: string;
userId: string;
game: DiscordGame;
mapKey: string;
mode: 'empty' | 'space';
}
const subscriptionColumns = `id,guild_id AS guildId,user_id AS userId,game,map_key AS mapKey,
mode,state,expires_at AS expiresAt,candidate,candidate_at AS candidateAt,error`;
const boardColumns = `id,game,guild_id AS guildId,channel_id AS channelId,page,message_id AS messageId,
content_hash AS contentHash,verified_at AS verifiedAt,generation`;
/** Synchronous, bounded transactions; delivery never holds a database transaction open. */
export class DiscordStore {
constructor(
readonly handle: Database.Database,
readonly now: () => number = Date.now
) {}
settings(): DiscordSettings {
const row = this.handle
.prepare('SELECT value FROM discord_configuration WHERE id=1')
.get() as { value: string } | undefined;
return row
? validateDiscordSettings(JSON.parse(row.value))
: structuredClone(defaultDiscordSettings);
}
saveSettings(input: unknown): DiscordSettings {
const settings = validateDiscordSettings(input);
this.handle.transaction(() => {
this.handle
.prepare(
'INSERT INTO discord_configuration(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value'
)
.run(JSON.stringify(settings));
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='cancelled' WHERE state IN ('active','queued') AND (?=0 OR guild_id!=?)"
)
.run(Number(settings.enabled), settings.guildId);
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state!='queued')"
)
.run();
this.retryFailed();
})();
return settings;
}
status(): DiscordDeliveryStatus {
const row = this.handle.prepare('SELECT * FROM discord_health WHERE id=1').get() as {
last_attempt_at: number | null;
last_success_at: number | null;
last_error: string | null;
};
const iso = (value: number | null) =>
value === null ? null : new Date(value).toISOString();
return {
lastAttemptAt: iso(row.last_attempt_at),
lastSuccessAt: iso(row.last_success_at),
lastError:
row.last_error ??
(
this.handle
.prepare(
"SELECT error FROM discord_subscriptions WHERE state='failed' AND expires_at>? ORDER BY created_at DESC LIMIT 1"
)
.get(this.now()) as { error: string } | undefined
)?.error ??
null,
boards: this.handle
.prepare(
'SELECT game,channel_id AS channelId,COUNT(message_id) AS messageCount FROM discord_boards GROUP BY game,channel_id'
)
.all() as DiscordDeliveryStatus['boards'],
activeSubscriptions: this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued') AND expires_at>?",
this.now()
),
pendingNotifications: this.count(
"SELECT COUNT(*) AS n FROM discord_outbox WHERE kind='alert' AND next_at IS NOT NULL"
)
};
}
subscribe(input: MapSubscriptionInput): { id: string; expiresAt: number } {
if (
!discordSnowflake(input.userId) ||
!discordSnowflake(input.guildId) ||
!['t5', 't6'].includes(input.game) ||
!['empty', 'space'].includes(input.mode) ||
typeof input.mapKey !== 'string' ||
!/^[a-zA-Z0-9_.:-]{1,100}$/.test(input.mapKey)
)
throw new Error('Invalid map watch.');
const settings = this.settings();
if (!settings.enabled || settings.guildId !== input.guildId)
throw new Error('Discord map watches are not enabled for this server.');
return this.handle.transaction(() => {
this.expireSubscriptions();
const existing = this.handle
.prepare(
"SELECT id,expires_at AS expiresAt FROM discord_subscriptions WHERE guild_id=? AND user_id=? AND game=? AND map_key=? AND mode=? AND state IN ('active','queued')"
)
.get(input.guildId, input.userId, input.game, input.mapKey, input.mode) as
{ id: string; expiresAt: number } | undefined;
if (existing) return existing;
if (
this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE user_id=? AND state IN ('active','queued')",
input.userId
) >= 10
)
throw new Error(
'You already have 10 active map watches. Cancel one before adding another.'
);
if (
this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued')"
) >= 1000
)
throw new Error('Map watches are currently full. Please try later.');
const id = randomUUID();
const expiresAt = this.now() + 24 * 60 * 60_000;
this.handle
.prepare(
"INSERT INTO discord_subscriptions(id,guild_id,user_id,game,map_key,mode,state,created_at,expires_at) VALUES(?,?,?,?,?,?,'active',?,?)"
)
.run(
id,
input.guildId,
input.userId,
input.game,
input.mapKey,
input.mode,
this.now(),
expiresAt
);
return { id, expiresAt };
})();
}
subscriptions(userId?: string, guildId?: string): DiscordSubscription[] {
this.expireSubscriptions();
return this.handle
.prepare(
`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE state IN ('active','queued') ${userId ? 'AND user_id=? AND guild_id=?' : ''} ORDER BY created_at LIMIT 1000`
)
.all(...(userId ? [userId, guildId ?? ''] : [])) as DiscordSubscription[];
}
subscription(id: string): DiscordSubscription | undefined {
return this.handle
.prepare(`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE id=?`)
.get(id) as DiscordSubscription | undefined;
}
cancel(id: string, guildId: string, userId: string): boolean {
return this.handle.transaction(() => {
const updated = this.handle
.prepare(
"UPDATE discord_subscriptions SET state='cancelled' WHERE id=? AND guild_id=? AND user_id=? AND state IN ('active','queued')"
)
.run(id, guildId, userId).changes;
if (updated)
this.handle
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
.run(id);
return updated > 0;
})();
}
expireSubscriptions(): void {
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='expired' WHERE state IN ('active','queued') AND expires_at<=?"
)
.run(this.now());
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state NOT IN ('active','queued'))"
)
.run();
this.handle
.prepare(
"DELETE FROM discord_subscriptions WHERE state NOT IN ('active','queued') AND (expires_at<? OR id IN (SELECT id FROM discord_subscriptions ORDER BY created_at DESC LIMIT -1 OFFSET 10000))"
)
.run(this.now() - 7 * 86400_000);
}
candidate(id: string, candidate: string | null): void {
this.handle
.prepare(
"UPDATE discord_subscriptions SET candidate=?,candidate_at=CASE WHEN candidate IS ? THEN candidate_at ELSE ? END WHERE id=? AND state='active'"
)
.run(candidate, candidate, candidate ? this.now() : null, id);
}
queueAlert(subscription: DiscordSubscription, message: DiscordMessage): void {
this.handle.transaction(() => {
const current = this.subscription(subscription.id);
if (!current || current.state !== 'active' || current.expiresAt <= this.now()) return;
this.enqueue('alert', subscription.id, message, null);
this.handle
.prepare("UPDATE discord_subscriptions SET state='queued' WHERE id=?")
.run(subscription.id);
})();
}
boards(): DiscordBoard[] {
return this.handle
.prepare(`SELECT ${boardColumns} FROM discord_boards ORDER BY game,channel_id,page`)
.all() as DiscordBoard[];
}
board(id: string): DiscordBoard | undefined {
return this.handle
.prepare(`SELECT ${boardColumns} FROM discord_boards WHERE id=?`)
.get(id) as DiscordBoard | undefined;
}
reconcileBoards(
desired: {
game: DiscordGame;
guildId: string;
channelId: string;
messages: DiscordMessage[];
}[]
): void {
this.handle.transaction(() => {
const retained = new Set<string>();
for (const group of desired) {
for (const [page, message] of group.messages.entries()) {
let board = this.boards().find(
(item) =>
item.game === group.game &&
item.guildId === group.guildId &&
item.channelId === group.channelId &&
item.page === page
);
if (!board) {
const id = randomUUID();
this.handle
.prepare(
'INSERT INTO discord_boards(id,game,guild_id,channel_id,page) VALUES(?,?,?,?,?)'
)
.run(id, group.game, group.guildId, group.channelId, page);
board = this.board(id)!;
}
retained.add(board.id);
this.handle
.prepare("DELETE FROM discord_outbox WHERE target_id=? AND kind='delete'")
.run(board.id);
const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex');
if (board.contentHash !== hash || !board.messageId)
this.enqueue('board', board.id, message, hash);
else
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE target_id=? AND kind='board'"
)
.run(board.id);
}
}
for (const board of this.boards()) {
if (!retained.has(board.id)) {
if (board.messageId)
this.enqueue('delete', board.id, { allowed_mentions: { parse: [] } }, null);
else this.removeBoard(board.id);
}
}
})();
}
private enqueue(
kind: DiscordJob['kind'],
targetId: string,
payload: DiscordMessage,
hash: string | null
): void {
const existing = this.handle
.prepare('SELECT kind FROM discord_outbox WHERE target_id=?')
.get(targetId) as { kind: string } | undefined;
if (!existing && this.count('SELECT COUNT(*) AS n FROM discord_outbox') >= 500)
throw new Error('Discord delivery queue is full.');
this.handle
.prepare(
`INSERT INTO discord_outbox(id,kind,target_id,payload,content_hash,next_at,created_at) VALUES(?,?,?,?,?,?,?)
ON CONFLICT(target_id) DO UPDATE SET kind=excluded.kind,payload=excluded.payload,content_hash=excluded.content_hash,
attempts=CASE WHEN kind!=excluded.kind THEN 0 ELSE attempts END,
next_at=CASE WHEN kind!=excluded.kind THEN excluded.next_at ELSE next_at END`
)
.run(
randomUUID(),
kind,
targetId,
JSON.stringify(payload),
hash,
this.now(),
this.now()
);
}
jobs(limit = 10): DiscordJob[] {
return (
this.handle
.prepare(
`SELECT id,kind,target_id AS targetId,payload,content_hash AS contentHash,
channel_id AS channelId,attempts,created_at AS createdAt FROM discord_outbox WHERE next_at<=? ORDER BY CASE kind WHEN 'delete' THEN 0 WHEN 'alert' THEN 1 ELSE 2 END,created_at LIMIT ?`
)
.all(this.now(), limit) as (Omit<DiscordJob, 'payload'> & { payload: string })[]
).map((row) => ({ ...row, payload: JSON.parse(row.payload) }));
}
cacheDm(jobId: string, channelId: string): void {
this.handle
.prepare('UPDATE discord_outbox SET channel_id=? WHERE id=?')
.run(channelId, jobId);
}
dropJob(id: string): void {
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(id);
}
resumeAlert(id: string): void {
this.handle.transaction(() => {
this.handle
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
.run(id);
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='active',candidate=NULL,candidate_at=NULL WHERE id=? AND state='queued'"
)
.run(id);
})();
}
delivered(job: DiscordJob, messageId?: string): void {
this.handle.transaction(() => {
if (job.kind === 'board')
this.handle
.prepare(
'UPDATE discord_boards SET message_id=?,content_hash=?,verified_at=? WHERE id=?'
)
.run(messageId, job.contentHash, this.now(), job.targetId);
if (job.kind === 'delete')
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(job.targetId);
if (job.kind === 'alert')
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='delivered',error=NULL WHERE id=? AND state='queued'"
)
.run(job.targetId);
// A newer desired board can replace a payload while an HTTP call is in flight.
this.handle
.prepare('DELETE FROM discord_outbox WHERE id=? AND payload=?')
.run(job.id, JSON.stringify(job.payload));
this.handle
.prepare(
'UPDATE discord_health SET last_success_at=?,last_error=CASE WHEN EXISTS(SELECT 1 FROM discord_outbox WHERE error IS NOT NULL) THEN last_error ELSE NULL END WHERE id=1'
)
.run(this.now());
})();
}
removeBoard(id: string): void {
this.handle.prepare('DELETE FROM discord_outbox WHERE target_id=?').run(id);
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(id);
}
missingMessage(id: string): void {
this.handle
.prepare(
'UPDATE discord_boards SET message_id=NULL,content_hash=NULL,verified_at=0,generation=generation+1 WHERE id=?'
)
.run(id);
}
verified(id: string): void {
this.handle
.prepare('UPDATE discord_boards SET verified_at=? WHERE id=?')
.run(this.now(), id);
}
attempt(): void {
this.handle
.prepare('UPDATE discord_health SET last_attempt_at=? WHERE id=1')
.run(this.now());
}
error(message: string): void {
this.handle
.prepare('UPDATE discord_health SET last_error=? WHERE id=1')
.run(message.slice(0, 300));
}
failed(job: DiscordJob, error: string, retryAt: number | null): void {
this.handle.transaction(() => {
this.handle
.prepare(
'UPDATE discord_outbox SET attempts=attempts+1,next_at=?,error=? WHERE id=?'
)
.run(retryAt, error.slice(0, 300), job.id);
if (retryAt === null && job.kind === 'alert') {
this.handle
.prepare("UPDATE discord_subscriptions SET state='failed',error=? WHERE id=?")
.run(error.slice(0, 300), job.targetId);
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(job.id);
}
this.error(error);
})();
}
retryFailed(): void {
this.handle
.prepare(
'UPDATE discord_outbox SET next_at=?,attempts=0,error=NULL WHERE next_at IS NULL'
)
.run(this.now());
this.handle.prepare('UPDATE discord_health SET last_error=NULL WHERE id=1').run();
}
claimInteraction(id: string): boolean {
if (!discordSnowflake(id)) throw new Error('Invalid Discord interaction ID.');
return this.handle.transaction(() => {
this.handle
.prepare('DELETE FROM discord_interactions WHERE expires_at<=?')
.run(this.now());
if (this.handle.prepare('SELECT 1 FROM discord_interactions WHERE id=?').get(id))
return false;
if (this.count('SELECT COUNT(*) AS n FROM discord_interactions') >= 10000)
throw new Error('Discord is processing too many interactions. Please try later.');
this.handle
.prepare('INSERT INTO discord_interactions(id,expires_at) VALUES(?,?)')
.run(id, this.now() + 15 * 60_000);
return true;
})();
}
private count(sql: string, ...args: (string | number)[]): number {
return (this.handle.prepare(sql).get(...args) as { n: number }).n;
}
}
export const discordStore = () => new DiscordStore(db());
export const getDiscordSettings = () => discordStore().settings();
export const saveDiscordSettings = (input: unknown) => discordStore().saveSettings(input);
export const discordStatus = () => discordStore().status();
export const subscribeToMap = (input: MapSubscriptionInput) => discordStore().subscribe(input);
export const listSubscriptions = (userId: string, guildId: string) =>
discordStore().subscriptions(userId, guildId);
export const cancelSubscription = (id: string, guildId: string, userId: string) =>
discordStore().cancel(id, guildId, userId);
export const claimDiscordInteraction = (id: string) => discordStore().claimInteraction(id);

Some files were not shown because too many files have changed in this diff Show More