Add native host management and role-based operator authentication

Introduce an outbound Python host agent, Debian installer, durable remote
operations, immutable game-file libraries, host-aware deployments and ports,
and occupancy-safe campaigns and draining. Native hosts do not require Komodo.

Add administrator, operator and viewer accounts, protected management routes,
secure sessions, password recovery, audit history and separate telemetry tokens.
Document first-admin setup, reporter migration and live-host acceptance checks.

Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type
checking, the production build, compiled SSR/API smoke tests and administrator
recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
This commit is contained in:
2026-10-03 11:42:36 -04:00
parent a2e2311aa9
commit e5c0fda1ed
127 changed files with 8485 additions and 657 deletions
+29 -39
View File
@@ -1,43 +1,33 @@
# Filesystem paths — inside the Komodo container, these mirror the host layout
SERVERS_ROOT=/root/zombies/t5
# T6 is separate; defaults to a sibling t6 directory beside SERVERS_ROOT.
T6_SERVERS_ROOT=/root/zombies/t6
REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp
# On-disk archive of active beta files (cleared per-beta on promote/rollback)
BETA_ARCHIVE_DIR=/root/zombies/beta-archive
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
EC_PUBLIC_URL=https://electric-cherry.example.com
# Generate separate secrets with: openssl rand -hex 32
# Setup token authorizes creation of the first administrator only. Remove it
# after setup. Never reuse the telemetry token as a setup or host credential.
EC_SETUP_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.
EC_TELEMETRY_TOKEN=
# Persistent SQLite metadata, game-file storage, and beta archives.
# For an existing deployment, retain its original data directory.
EC_DATA_DIR=/root/zombies/.electric-cherry
DB_PATH=./data/cherry.db
GAME_FILES_ROOT=./data/game-files
BETA_ARCHIVE_DIR=./data/beta-archive
BODY_SIZE_LIMIT=Infinity
# SQLite location
DB_PATH=/data/cherry.db
# Prepare this runtime from Hosts before deploying. Image and game-file
# library versions are selected independently in the launch preview.
PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
# Configure campaign retention and restart/startup concurrency in Settings → Fleet settings.
# Optional shared RCON password. Native instances use independently generated
# secrets when omitted; RCON packets are sent locally by each host agent.
# RCON_PASSWORD=
# Komodo
KOMODO_ADDRESS=https://komodo.example.internal
KOMODO_API_KEY=
KOMODO_API_SECRET=
# Komodo server (host) that new deployments land on. Must match the exact
# name shown on the Servers page in the Komodo UI. Required to deploy new
# servers from the Fleet page.
KOMODO_TARGET_SERVER=
# Optional overrides for the plutainer defaults used when EC creates a new
# server. Change these if you fork the image or use a different docker network.
# PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
# PLUTAINER_NETWORK=komodo_default
# PLUTAINER_GAMEFILES_VOLUME=t5_game_files
# PLUTAINER_T6_GAMEFILES_VOLUME=t6_game_files
# T6_REL_DEST_SCRIPT=runtime/plutonium/storage/t6/scripts/zm
# T6_CONFIG_TARGET_FILE=dedicated_zm.cfg
# Port range EC picks from when auto-assigning a game_port to a new server.
# Must correspond to what's actually forwarded on the Komodo host firewall.
# SERVER_PORT_MIN=28960
# SERVER_PORT_MAX=28980
# Development tests only. Production host transport requires HTTPS.
# EC_AGENT_ALLOW_HTTP=true
# Browser login on localhost only (never for an exposed controller):
# EC_AUTH_ALLOW_HTTP=true
# RCON — shared across the fleet (every server uses the same rcon_password).
# Host defaults to host.docker.internal so the container can reach game
# servers on the same Docker host; override if RCON lives elsewhere.
RCON_PASSWORD=
RCON_HOST=host.docker.internal
RCON_TIMEOUT_MS=3000
# HTTP (Komodo API)
HTTP_TIMEOUT_MS=10000
# Existing legacy registrations can still use their previous KOMODO_*,
# SERVERS_ROOT, T6_SERVERS_ROOT, and RCON_HOST settings. Native hosts need none.
# Fleet concurrency and campaign retention are saved in Settings, not env vars.
+2
View File
@@ -12,3 +12,5 @@ build
data/
test-fixtures/
.gstack/
__pycache__/
*.pyc
+3
View File
@@ -23,12 +23,15 @@ COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/src/lib/server/db/schema.sql ./build/schema.sql
COPY scripts/reset-admin.mjs ./scripts/reset-admin.mjs
COPY src/lib/server/auth/passwords.js ./src/lib/server/auth/passwords.js
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
ENV BODY_SIZE_LIMIT=Infinity
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
+105 -465
View File
@@ -1,497 +1,137 @@
# Electric Cherry
Web app that manages a fleet of T5 and T6 Plutonium Zombies servers running under Komodo.
Electric Cherry manages T5 and T6 Plutonium Zombies servers through its own host
agents. Docker and Plutainer run the games; EC handles hosts, game libraries,
collections, configuration, campaigns, maintenance and operation history.
Komodo is not required for native hosts.
Fleet membership lives in SQLite. EC registers servers during creation and removes
them during destruction; startup does not discover or import server folders.
Existing database registrations are preserved, and missing mounts do not remove
servers from the fleet. The legacy `seedServerRegistry` helper is an explicit
import utility only.
## Getting started
Status refreshes check Komodo container readiness, including nested Docker health
status. A running container with `health: starting` is still **Starting**. Starting
or restarting containers hide old game data and RCON errors; stopped, unhealthy,
or unverifiable containers do not trigger routine RCON polling. Containers without
a Docker health check use their running state as the readiness signal.
Run the controller with Docker Compose behind HTTPS:
Status polling runs every 30 seconds with at most four server checks in flight.
Each completed check publishes a current fleet snapshot; a slow check does not
hold back updates or the next poll for servers that have finished. RCON status
queries retry timeouts and send errors twice, with 250ms then 500ms backoff.
Overlapping status and occupancy probes share the in-flight query for the same
server process and endpoint. Configuration and invalid-reply errors are reported
immediately; commands that change server state are never retried by this logic.
```sh
cp .env.example .env
# Set EC_PUBLIC_URL, generate EC_SETUP_TOKEN, and retain EC_DATA_DIR.
docker compose up -d --build
```
With fresh container health, validated telemetry received in the last 45 seconds
supplies the current player count, roster, map, and game status. RCON is the fallback
when telemetry is unavailable or stale. Fleet, server details, metrics, collection
coverage, setup readiness, and beta health checks share this policy. Reports from
before the latest start, restart, or rebuild cannot verify the new process.
Historical scoreboards remain visible and are labeled separately from live counts;
RCON diagnostics retain their own results. Telemetry does not report ping or player
capacity, so those fields remain unknown in telemetry-only observations.
Open EC and create the first administrator using `EC_SETUP_TOKEN` (at least 32
characters; generate it with `openssl rand -hex 32`). After setup, remove that
token from the environment. Administrators can add administrators, operators and
viewers from **Settings → Users**. See [Users and access](docs/authentication.md)
for permissions, sessions, password recovery and upgrade instructions.
**Active** measures time since the lobby was first observed with players;
**Match** measures time since the script's `game_start` was received. Telemetry
records occupancy changes immediately, so a later heartbeat or status poll does
not move the active timer forward. A new match keeps the active timer if the
lobby stays occupied. An empty report clears the match timer and begins the
inactive period; a later join begins a new active period. These are observed
times, not exact player connection timestamps.
Operator pages and management APIs require login. Enrolled agents authenticate
independently and connect outbound over HTTPS; the
controller needs no Docker socket, game-server mounts or inbound RCON access.
The default Compose data mount preserves the previous
`/root/zombies/.electric-cherry` database location. For a new installation, set
`EC_DATA_DIR` to your preferred persistent directory. Back up that directory,
including SQLite metadata and published game-file storage.
Server chat shows real time in your selected timezone, elapsed match time using
the same observed start as Fleet, and the round last reported when each message
was sent. Real time is approximate: the latest log write anchors the engine clock.
Earlier clock segments, missing match starts, and unavailable or stale round
reports show `—` instead of borrowing context from another match. Round history
uses retained accepted telemetry (up to 200 requests per server or seven days).
1. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its
generated enrollment command.
2. Prepare a Plutainer runtime from the host page.
3. **Library → Game Files**: import existing T5/T6 files once, then cache a
published version on the destination host.
4. Add the exact Plutonium server keys in **Keys**, or supply missing keys when
launching.
5. Launch a server or collection with an explicit host, game-file version and
runtime image. EC reserves host-specific ports and verifies startup.
## Fleet overview
See [Native host management](docs/native-hosts.md) for installation, credentials,
storage, failure recovery, API contracts and the live-host acceptance checklist.
Fleet searches server names, map names, and the full player roster. Filter occupied,
empty, unhealthy, testing, or pinned lobbies; switch between cards and a compact
table; and group by game or map. Pins and display preferences are saved in this
browser. Map groups preserve T6 variants and can preselect the exact map when
launching another lobby.
## Fleet and operations
Health summaries exclude stale player counts. The match spotlight uses fresh round
telemetry from an occupied, ongoing match. Persisted campaign, beta, creation,
removal, and restart-recovery state appears alongside affected servers. Fleet
membership and operation progress refresh every ten seconds, with live server
status between refreshes. Failed refreshes keep the last snapshot visible.
The fleet registry lives in SQLite, independently of host filesystems. Status
combines agent container health, fresh game telemetry and agent-local RCON.
Unreachable hosts report unknown instance state; they do not trigger duplicate
replacements. Running containers continue through controller or agent outages.
**Metrics** explores recorded server use by time range, game, and map. It includes
occupied/idle/unknown timelines, sortable rankings, previous-period comparisons,
map capacity pressure, and an activity heatmap in the selected timezone. Hosting
experiments suggest changes from explicit rules, including tentative advice from
sparse observations; they never change the fleet automatically. Background refresh
keeps the last report visible if it fails. See [Metrics](docs/metrics.md) for the
definitions, evidence limits, and experimental rules.
Provisioning owns Create, Start, Stop, Restart, Rebuild and Destroy. Commands and
results have durable operation IDs and immutable instance generations. Queues,
capacity reservations and history survive controller restarts. **Rebuild removes
per-instance writable files** and creates a new generation. Shared immutable game
libraries survive rebuilds and destruction.
## Stack
Disruptive actions wait for a fresh empty lobby or verified stopped container.
The agent rechecks occupancy locally immediately before activation and disruption.
Long player sessions can continue indefinitely. **Drain** prevents new launches
and safely retires existing servers as they become eligible.
- SvelteKit (adapter-node) + TypeScript
- SQLite via better-sqlite3 (replaces `script_metadata.json` as the source of
truth)
- Server-sent events for live pipeline / server-status streams
- Game telemetry for current player counts and rosters, with direct UDP RCON as fallback
- Komodo API for restarts and per-server game-port discovery
- Runs as a single Komodo-managed container on the same host as the game
servers, with `/root/zombies` bind-mounted in
Collections save target maps and lobby counts. Fill missing servers adds only the
shortfall; Match collection exactly also queues safe removal of excess servers.
Launch previews select a host, pinned base-game versions and an independent
runtime image. Every new launch uses the latest promoted configuration, scripts
and plugins when execution starts. Saved Plutonium keys are reused by exact
server ID and retained after removal.
## Layout
## Libraries and campaigns
- `src/lib/server/db/` — schema, migrations, better-sqlite3 handle
- `src/lib/server/fleet/` — file walking, push/beta engines
- `src/lib/server/integrations/` — Komodo, RCON
- `src/lib/server/pipeline/` — sweep loop, event bus, worker singleton
- `src/lib/server/provisioning/` — lifecycle requests, queues, execution, recovery, and history
- `src/routes/api/` — REST + SSE endpoints
- `src/routes/` — UI pages (dashboard, campaigns, server deployments, betas, per-server)
- `Dockerfile`, `docker-compose.yml`, `entrypoint.sh` — container build
- `komodo/` — Komodo stack definition
- `scripts/` — legacy JSON importer, fixture seeder
- `tests/` — vitest unit tests
Game-file versions include game, version, DLC metadata, manifests, checksums and
size. Actual game files use dedicated filesystem storage, never SQLite blobs.
Downloads resume, report progress and verify checksums before becoming usable.
Multiple instances on one host share read-only base files.
## Plugin releases
Scripts, plugins, configuration baselines and overlays retain their versioning,
beta, promotion and rollback workflows. Campaigns stage bytes remotely without
stopping games; activation uses the same occupancy and concurrency gates as
lifecycle operations. Agent before images preserve exact rollback bytes and
operation history.
Plugins support named release versions, GitHub DLL imports and update discovery.
See [Plugin releases](docs/plugin-releases.md) for the import and beta workflow.
T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and
`dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded
after the main config. T6 variants retain their distinct catalog selections.
Game-file libraries and Plutonium runtime revisions are tracked independently.
## Maintenance
Game telemetry uses its own `EC_TELEMETRY_TOKEN`; configure reporters to send it
in an authorization header. Anonymous reports are rejected before they can
affect occupancy or game history. Existing JSON payloads are unchanged.
Open **Operations → Maintenance** for health recovery, configuration drift,
Plutonium updates, and scheduled restarts. Overview shows active work, attention,
version coverage, and upcoming restarts. Policies & schedules controls each
capability independently; Activity links findings and results to Provisioning.
Fleet and server pages show runtime revision indicators and maintenance links.
## Documentation
Maintenance decides when work is needed and verifies the result. Provisioning
executes every lifecycle operation using the shared safety checks and queues.
Health recovery uses one restart followed by one rebuild if needed; drift repair
requests a rebuild with a linked Campaign. **Rebuild deletes all instance files.**
Maintenance skips observations and new automatic work on servers with active
Provisioning operations or retained recovery capacity. Other servers continue
normally, and existing maintenance still tracks its own operation's outcome.
- [Native hosts, storage and operation recovery](docs/native-hosts.md)
- [Users, roles and authentication](docs/authentication.md)
- [Provisioning and safety queues](docs/provisioning.md)
- [Collections and promoted launch recipes](docs/deployment-blueprints.md)
- [Maintenance](docs/maintenance.md)
- [Beta test plans and game telemetry](docs/beta-test-plans.md)
- [Plugin releases](docs/plugin-releases.md)
- [Metrics](docs/metrics.md)
- [Leaderboard and match history](docs/leaderboard.md)
Plutonium updates are applied by Plutainer's startup updater
(`PLUTAINER_AUTO_UPDATE` must be enabled). Check versions and choose **Update on
restart**, or opt into automatic updates for verified outdated servers. Revision
checks run hourly, and Create and Rebuild verify and record the running revision
before completing. Unknown or stale revisions remain unverified. A healthy restart
can still fail update verification; Maintenance reports that outcome without
rebuilding or repeatedly restarting the server. Electric Cherry does not download
binaries or pin a runtime revision.
Older workflow documents may describe the legacy Komodo backend. Native hosts
use the agent boundary described in the native-host document.
Optional daily restarts are independent of updates. Schedules use the saved
timezone, follow daylight saving changes, and coalesce missed days into one run.
Intentionally stopped servers are excluded. Existing schedules, pending jobs,
operation IDs, and history survive the move from Settings and Watchdog.
Servers owned by Provisioning are skipped for that scheduled occurrence.
## Development
**Pause all maintenance** holds new and queued maintenance while checks and
submitted recovery continue. Each capability retains its own policy. Automatic
updates remain disabled until explicitly enabled. See [Maintenance](docs/maintenance.md)
for verification, retry, scheduling, and compatibility details.
The controller is SvelteKit/TypeScript with SQLite via better-sqlite3, the Node
adapter and server-sent events. The host agent is Python 3 standard-library code
installed as a systemd service. Hosts do not install Node.js or build EC.
## Fleet settings
**Settings → Fleet settings** controls campaign history retention (default 7 days),
concurrent restarts (default 1), concurrent server startups (default 1), and
concurrent server destructions (default 1).
Values are positive whole numbers; retention supports up to 36500 days. Settings
are saved in SQLite for all users and apply at the next queue or cleanup check
without restarting the app. Lowering a limit leaves active operations running
and holds queued work until capacity is available. Collection previews and
progress pages display the current startup limit.
Retention removes eligible old campaign records and file diffs. Pending, failed,
held, and health-recovery-linked campaigns remain protected; Provisioning history
is retained. Shortening retention can remove older history on the next sweep.
On the first startup after upgrading, existing `DEPLOY_KEEP_DAYS`,
`DEPLOYMENT_RESTART_CONCURRENCY`, and `DEPLOYMENT_CREATE_CONCURRENCY` environment
values initialize the saved settings. Keep custom values for that first startup;
afterward, the database values take precedence and those variables can be removed.
The supplied Compose and Komodo examples use the built-in defaults.
Existing saved settings retain their values when upgraded; the destruction
limit starts at 1 and can be changed in Settings.
`GET /api/settings` reads the four values (`deployKeepDays`, `restartConcurrency`,
`createConcurrency`, `destroyConcurrency`); `PUT /api/settings` saves all four together as JSON.
Invalid or incomplete requests return HTTP 400 without changing saved settings.
## How restarts stay safe
Betas support configurable gameplay, participation, stability and manual-review
requirements, saved presets, coverage rules and staged waves. See
[Beta test plans](docs/beta-test-plans.md) for usage and the four-event game telemetry contract.
File changes are staged sequentially. Their activation uses rolling restarts:
by default only **one server across the fleet** can restart or recover at a
time. Promotions, rollbacks, drift reconciliation, and beta restarts share this
limit. Adjust **Concurrent restarts** in **Settings → Fleet settings** to raise it.
The Provisioning worker runs every 15s. Campaigns, betas, and maintenance submit
durable restart requests and read their results. Provisioning checks recovering
servers first, then admits queued operations when there is capacity. Requests
normally run in submission order; on a shared server, beta activation precedes
campaign activation, and maintenance waits for both. Busy
or unreachable servers stay queued without taking a slot, so other empty
servers can proceed. A successful Komodo restart request retains its slot
until the container is healthy and RCON or fresh telemetry verifies game status. Health checks start no sooner
than 15 seconds after the request returns, avoiding an immediate check of the
old process. An unhealthy server keeps its slot rather than letting failures
spread across the fleet. A timed-out restart also keeps its slot because Komodo
may have accepted the request.
Slots persist in SQLite across app restarts, and overlapping timer/API triggers
share the active run. Beta options to skip health or match gates still work;
skipping a beta gate does not release the fleet's recovery slot early. New-server
creation uses a separate shared startup queue, holding its slot through container,
game-status, selected-map, and Plutonium revision verification.
No token, no session, no auth freeze. If Komodo or RCON aren't configured,
the sweep marks pending rows `manual` so nothing hangs forever.
## Collections, provisioning, and launch previews
Provisioning also offers **House Party Protocol**: review the entire fleet and queue
destruction in one action. This includes queued creations and reuses pending
destruction requests. Each server waits for a fresh zero-player reading; occupied
or unreachable servers remain queued. Containers, server files, and registrations
are removed, while saved keys and collections are retained. The preview must be
reviewed again if fleet membership changes before confirmation. Servers created
after confirmation are not included. Progress appears in deployment history.
If any target belongs to an active beta, no removals are queued. Promote or roll
back that beta before retrying fleet removal or an exact collection launch.
If deletion fails after Komodo removal, the operation detail page offers
**Resume cleanup** to retry local file and database removal without RCON or
repeating Komodo deletion. Requesting destruction again also resumes that cleanup.
Recovery is blocked if a newer operation exists for the same server name.
**Operations → Collections** includes a builder for whole server lineups: choose
multiple T5/T6 maps and set the desired total lobbies for each. Collections also
has **Active launches** and **Launch history** tabs, with progress at
`/collections/launches/:id`.
Each server operation remains in Provisioning and links back to its collection
launch. Existing collection launch bookmarks redirect to the new location.
**Conform fleet**
counts existing and queued servers and creates only the shortfall. The preview
shows target/existing/missing counts and IDs, ports, and key requirements for new
servers only. Already-satisfied targets create nothing; excess servers are kept.
Choose **Match collection exactly** in the preview to also remove excess lobbies
and maps outside the collection. Review the keep/create/remove lists and confirm
removals. Matching servers stay untouched; unstarted excess creations are cancelled,
and running servers wait for a fresh zero-player check before removal. New lobbies
can reserve ports from these removals and wait until they are released. The collection
page tracks both actions. **Fill missing servers** remains the default.
All jobs and capacity reservations are committed together before provisioning;
one collection page tracks each server's independent progress and failures.
New servers start one at a time by default across all collections and individual
launches. A startup slot stays occupied until Komodo is healthy, RCON or fresh
telemetry reports the selected map, and RCON verifies the current Plutonium
production revision or newer. Unknown release or runtime revisions hold the queue
while verification retries; a confirmed older runtime fails provisioning. The
queue survives application restarts. The collection preview
shows the startup limit, and its progress page shows active startups and each
server's place in the shared queue. Adjust **Concurrent server startups** in
**Settings → Fleet settings** to increase the limit. Each worker pass starts at most the
available number of slots; immediate failures do not drain the remaining queue.
Existing single-server collections can be expanded with **Edit**. Single-server
launches and launching again from recorded recipes remain available.
Collections save maps and target lobby counts. Every new server uses the latest
promoted scripts, plugins, and configuration when provisioning begins, including
promotions made after preview or while queued. Existing collections automatically
follow this policy. Deployment history records the files selected for that launch;
**Launch again** uses the current promoted setup for the same game and map.
The progress page explains waiting conditions, shows elapsed time, and can resume
health verification of an interrupted creation when its original server remains.
See [Deployment collections](docs/deployment-blueprints.md) for the workflow,
promotion policy, and API details.
## Saved server keys
Use **Keys** to add, replace, or remove a Plutonium key for an exact
server ID such as `t5_zombie_theater_2`. IDs use a supported map slug and a
lobby number from 1 to 20; the server does not need to be deployed first.
During deployment, choose the map. The lowest available lobby is assigned
automatically, with the exact server ID shown before deployment. Its saved key is reused automatically,
and any newly entered key is saved for that ID, including if deployment fails.
Destroying a server preserves its saved key. Updating or removing a saved key
does not change an existing server; changes apply when that server ID is next
created or rebuilt. Electric Cherry does not check whether Plutonium accepts a
saved key. **Last saved** shows when the key was saved, not a validation result.
Keys are stored in the application's SQLite database at `DB_PATH`, independently
of server records. Preserve that database across container replacements. Key
values are not returned by the management API, but are stored unencrypted in
SQLite, so database files and backups contain credentials. Existing deployment
keys are not automatically imported from Komodo; add them once to retain them.
## Local dev
Windows or macOS — no `/root/zombies` on disk, so seed a fixture tree first.
```bash
```sh
npm install
npm run seed:fixture
npm run agent:package
# Export EC_SETUP_TOKEN in your shell using the secret saved for /setup.
EC_AUTH_ALLOW_HTTP=true npm run dev
# Point env at the fixture (PowerShell)
$env:SHARED_SCRIPTS = "$PWD/test-fixtures/zombies/shared/scripts"
$env:SERVERS_ROOT = "$PWD/test-fixtures/zombies/t5"
$env:DB_PATH = "$PWD/data/cherry.db"
npm run dev
# open http://localhost:3000
```
Without RCON_PASSWORD / KOMODO_* set, the pipeline marks pending rows
`manual` after a sweep — the UI still works, you just don't get real
restarts. Enough to develop the push/beta flow.
## Map selection and configs
The map selected when deploying a T5 zombies server controls its startup
rotation automatically. No map-choice config overlay is required. New servers
use the promoted game baseline and matching overlays when available; otherwise
Plutainer seeds its usual defaults and Electric Cherry applies the selected map
through a generated `configs/electric_cherry_map.cfg`, loaded after the main
config and before map rotation. This file is tracked and restored by drift
reconciliation; operators do not need to create or promote it. The launch order
uses Plutainer's [extra arguments and map-rotation controls](https://github.com/Ayymoss/Plutainer/blob/main/docs/configuration.md).
Plutainer's
default main config is preserved until a game baseline is promoted.
Beta previews, promotion, and config drift repair all apply the server's stored
catalog map after composing the baseline and overlays. An old `sv_maprotation`
overlay cannot override that selection, but its other settings still apply.
Existing servers receive this behavior on their next config deployment or drift
reconciliation; this change does not immediately rewrite or restart them.
Servers with an unknown map retain their existing config behavior. Rollback
restores the exact prior config bytes.
## T6 Zombies (Black Ops II)
Choose **Black Ops II (T6)** in Deploy new server. T6 uses `t6_zombie_<slug>_<lobby>`
IDs, its own saved Plutonium keys, `PLUTAINER_GAME=t6zm`, and
`configs/dedicated_zm.cfg`. Existing T5 deployments and environment settings
keep their current paths. API clients can send `game: "t6"`; omitting it selects T5.
Before the first deployment, populate the Docker volume `t6_game_files` on the
Komodo host with your Black Ops II game files, including the DLC maps you intend
to host. Set `PLUTAINER_T6_GAMEFILES_VOLUME` to use another volume or an absolute
host directory. It is mounted read-only at `/home/plutainer/gamefiles`.
Plutainer supplies its runtime and config seeds, not the base game files.
Use a Plutonium server key created for **T6 Zombies**.
Server state defaults to `/root/zombies/t6`, or the sibling `t6` directory beside
`SERVERS_ROOT`; override with `T6_SERVERS_ROOT`. Electric Cherry and the Komodo
host must see this directory at the same absolute path. The supplied Compose
mount already covers both games. GSC files go to
`runtime/plutonium/storage/t6/scripts/zm` (no `raw/`), and game logs use
`logs/games_zm.log`. Optional overrides are `T6_REL_DEST_SCRIPT` and
`T6_CONFIG_TARGET_FILE`; a custom config filename needs a promoted baseline or
a file supplied at that name, since Plutainer seeds `dedicated_zm.cfg`.
The catalog includes the classic maps, Survival, Grief, and Turned selections.
Rotations use `execgts <ruleset>.cfg map <engine-map>`; Plutainer seeds the required
`storage/t6/gamesettings` files. Keep seeding enabled, including with a promoted
baseline. Town, Farm, and Bus Depot share `zm_transit`, so EC keeps their selection
in `map_slug` through creation, beta, promotion, and drift repair. Map-scoped
artifacts target the engine map and therefore apply to all its variants.
Creation health checks verify the engine map reported by RCON or fresh telemetry; this does not
independently verify the start location within that map.
Both games share the fleet's port allocator (`SERVER_PORT_MIN`/`SERVER_PORT_MAX`)
to prevent collisions. EC explicitly sets `PLUTAINER_PORT`, so T6 need not use
its upstream default of 4976. Forward the allocated UDP ports. Use a current
Plutainer v2 image: EC relies on `PLUTAINER_RCON_PASSWORD`,
`PLUTAINER_MAP_ROTATE=false`, and extra arguments loaded after the main config.
Current Plutainer automatically whitelists the Docker gateway for T5/T6 RCON;
an admin client on another host needs its address allowed in the server config.
Implementation references (checked September 2026):
- [Plutainer game requirements](https://github.com/Ayymoss/Plutainer/blob/main/docs/games.md)
- [Plutainer launch, seeding, and RCON implementation](https://github.com/Ayymoss/Plutainer/blob/main/scripts/lib/cod.sh)
- [Plutainer configuration](https://github.com/Ayymoss/Plutainer/blob/main/docs/configuration.md)
- [T6 map/ruleset combinations](https://github.com/xerxes-at/T6ServerConfigs/blob/master/localappdata/Plutonium/storage/t6/dedicated_zm.cfg)
- [Plutonium GSC paths](https://plutonium.pw/docs/modding/loading-mods/)
## Game telemetry
The server script sends JSON to `POST /api/telemetry`:
- `game_start`: server, map, and all players (name and GUID).
- `round_change`: server, map, round, and all players with kills, headshots, downs, revives, and score.
- `heartbeat`: the same fields as round change, sent periodically during a round to refresh player stats.
- `game_end`: the same fields as round change, with final stats.
These reports work with or without an active beta. Electric Cherry records
the full player data, updates Fleet, and applies gameplay progress to active
betas automatically. No discovery request or beta-specific fields are needed.
See [Game reporter protocol](docs/beta-test-plans.md#game-reporter-protocol) for
complete payload examples. Fresh telemetry can verify player count and game
activity alongside RCON during provisioning and Watchdog recovery. Occupied
reports prevent interruptions; stale reports cannot authorize them. Komodo still
verifies container readiness.
For a missing scoreboard, open the server's **Diagnostics → Telemetry API requests**.
The log shows received requests for that exact server name, including accepted
reports, rejected payloads, HTTP status, errors, and expandable submitted JSON.
It refreshes every five seconds while visible and survives app restarts. Logging
starts with this version; older requests cannot be reconstructed. See
[Telemetry diagnostics](docs/beta-test-plans.md#telemetry-diagnostics) for retention
and how to interpret missing or rejected reports.
The **Leaderboard** records persistent player totals and map records from these
same reports, including highest round, cumulative kills, rounds survived,
headshots, revives, downs, and games played. See [Leaderboard](docs/leaderboard.md)
for counting rules, history import, and the read API. Only completed games count;
in-progress and interrupted games remain in Match History without contributing
to player rankings or profile totals.
In **Match History**, select **View match** on a completed match to open its
shareable results page. It shows the map, server, final round, timing, and every
recorded player's stats, including players absent from the final report. Completed
matches also link from server history and player profiles. Results survive server
deletion; missing starts and unrecorded stats remain explicitly unknown.
## Running tests
```bash
npm run check
npm test
npm run test:agent
npm run build
```
Covers push (add/update/skip), beta lifecycle (open → rollback, open →
promote, conflict blocks), sweep (integrations-off manual branch), and
RCON parsing (fake UDP socket).
Builds regenerate the downloadable agent and checksum manifest. Local controller
metadata defaults to `./data/cherry.db`; use a separate test database when
exercising enrollment. Production agent transport requires HTTPS.
## Deploy to the Komodo host
`src/lib/server/hosts` owns enrollment, readiness and command transport;
`src/lib/server/backends` provides instance identity and remote file access;
`src/lib/server/provisioning/native.ts` connects existing lifecycle and campaign
queues to agents; `src/lib/server/game-files` owns immutable libraries and
streaming distribution; `agent/` contains the service, installer and agent tests.
1. Build the image once locally or on the host:
```bash
docker compose build
```
2. Import `komodo/stack.toml` into Komodo (or point Komodo at the
`docker-compose.yml` at repo root).
3. Set the env vars in Komodo:
```
KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET
RCON_PASSWORD # single shared password across every T5/T6 server
RCON_HOST # optional; defaults to host.docker.internal
```
4. First boot triggers the legacy import: if
`/root/zombies/script_metadata.json` exists and `/data/cherry.db` is
empty, `entrypoint.sh` runs `scripts/import-legacy.ts` before starting
the server. The source JSON gets renamed to `.migrated`.
5. Load the app on the LAN or over tailscale (no web-app auth by design).
## Campaigns and provisioning
Campaigns (`/campaigns`) tracks script/config updates, drift reconciliation, restart progress, file diffs, and rollbacks. Existing campaign records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy detail API endpoints remain available for compatibility.
Beta deployments, promotions, drift repairs, and rollbacks stage their file changes
in SQLite. Live server files and inventory remain unchanged while waiting for an
empty or stopped server, other operations, and rolling restart capacity. Provisioning
captures rollback snapshots and applies the staged changes immediately before
requesting the restart. Failed file activation blocks that restart. Queued changes
survive application restarts; cancelling an unapplied beta discards its pending
changes. Manual beta rollback holds both file restoration and restart until
**Resume when empty** is selected on its rollback campaign.
Finish pending file campaigns before opening a beta on their servers; this keeps
an older queued change from overwriting the version tested by the new beta.
Deleting a server preserves its name, recorded beta progress and test evidence,
campaign results and file diffs, provisioning and maintenance history, and match
records. Historical entries show **Deleted** and omit live server links and
actions. Reusing the same server name starts a new instance; old records remain
marked deleted, and old work cannot target the replacement. Servers linked to an
active beta cannot be destroyed or rebuilt until that beta is promoted or rolled
back. A participant removed outside provisioning still pauses its open beta; roll
it back and open a new beta to test the replacement.
Normal history retention still applies. Records erased by earlier versions cannot
be restored by this upgrade. Existing history is marked deleted where the server
is already absent; instances that reused a name before upgrading cannot always be
distinguished.
Provisioning (`/provisioning`, `/api/provisioning`) owns Create, Start, Restart, Rebuild, Stop, and Destroy: request persistence, eligibility and occupancy checks, execution, recovery, and history. Campaigns, betas, and scheduled maintenance request lifecycle work through Provisioning and link to the resulting operation. They retain their file, test-evidence, and version-verification workflows. Pending automated restarts appear in Provisioning before execution. History survives server removal. New creation requests return HTTP 202 with an operation ID and open the provisioning detail page. Create and Rebuild complete only when Komodo reports healthy, RCON or fresh telemetry verifies the selected engine map and game status, and RCON reports a Plutonium runtime revision at least as new as the current production release. Provisioning records the release and runtime evidence so new servers have verified version status immediately. Unknown release or runtime revisions are retried; a confirmed older runtime fails with a clear error and no automatic restart. Queued requests reserve their server name and port; queued creation and pending verification survive application restarts. The detail page records startup progress and explains any health, map, or revision issue blocking completion.
Each server deployment opens a detail page with its outcome, blocker, permitted
recovery actions, grouped execution phases, and timestamped verification evidence.
New jobs record command certainty and the source of game-health checks; older
records retain their known outcomes without invented evidence. A failed restart's
ongoing recovery and held capacity appear separately from its recorded failure.
Recent activity is visible immediately, with searchable history loaded in pages.
Launch files distinguish the setup captured while queued from the files selected
when creation began. The page retains its last snapshot if a refresh fails.
Destroy uses an in-app confirmation modal and immediately returns a queued deployment (`DELETE /api/servers/:name` returns HTTP 202 with `deploymentId`). The worker waits for a fresh zero-player RCON or telemetry report, or a verified stopped container, before teardown. Occupied reports from either source block teardown; servers without current verification remain queued and are checked each worker tick. Waiting requests survive application restarts, while interrupted teardown is marked for inspection. Completed destruction stays on the provisioning detail page.
**Settings → Concurrent server destructions** limits simultaneous individual and
fleet removals (default 1). Each destruction holds its slot through teardown and
local cleanup. Lowering the limit lets active removals finish before more begin.
Use **Provisioning → Manage server**, or a server’s **Server actions**, to start,
restart, stop, or rebuild an existing instance. **Rebuild destroys and creates in
place**: the old container, Komodo deployment, server registration, and all server
files are deleted. A fresh instance uses the same server ID, map, and port with
the latest promoted setup. No old instance files are carried forward. The key
stored separately in **Keys** must be available before teardown. Stop retains the
instance so Start can bring it back. Disruptive operations wait for a fresh empty
reading or a confirmed stopped container, and completion requires verification.
See [Provisioning](docs/provisioning.md) for lifecycle behavior and recovery.
Existing `/deployments` page links redirect to `/provisioning`, preserving filters
and operation IDs. Legacy `/api/deployments` endpoints and stored history remain
compatible.
Automated tests use simulated Docker/game hosts. Real Debian installer and
T5/T6 game-server validation remain deployment acceptance work; see the checklist
in the native-host guide.
+1231
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# Electric Cherry host installer. Downloaded release integrity is checked before
# execution. Existing Docker data and unrelated containers are never changed.
set -euo pipefail
umask 077
controller=''
enrollment_token=''
data_dir='/var/lib/electric-cherry'
reenroll=''
while [ "$#" -gt 0 ]; do
case "$1" in
--controller) controller="${2:?Missing controller URL}"; shift 2 ;;
--token) enrollment_token="${2:?Missing enrollment token}"; shift 2 ;;
--data-dir) data_dir="${2:?Missing data directory}"; shift 2 ;;
--reenroll) reenroll='--force-enroll'; shift ;;
*) echo "Unknown installer option" >&2; exit 2 ;;
esac
done
[ "$(id -u)" = 0 ] || { echo 'Run the installer as root.' >&2; exit 1; }
case "$controller" in https://*) ;; *) echo '--controller must be an HTTPS URL.' >&2; exit 2 ;; esac
[ -n "$enrollment_token" ] || { echo '--token is required.' >&2; exit 2; }
case "$data_dir" in /*) ;; *) echo '--data-dir must be absolute.' >&2; exit 2 ;; esac
case "$data_dir" in *[!a-zA-Z0-9/_.-]*|/|/etc|/usr|/var|/var/lib) echo 'Choose a dedicated data directory.' >&2; exit 2 ;; esac
[ ! -L "$data_dir" ] || { echo 'Data directory must not be a symlink.' >&2; exit 2; }
. /etc/os-release
[ "${ID:-}" = debian ] || { echo 'Supported platform: Debian 12 or 13.' >&2; exit 1; }
case "${VERSION_ID:-}" in 12|13) ;; *) echo 'Supported platform: Debian 12 or 13.' >&2; exit 1 ;; esac
[ "$(dpkg --print-architecture)" = amd64 ] || { echo 'Supported architecture: amd64.' >&2; exit 1; }
command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl python3
python3 - "$controller" "$data_dir" <<'PY'
import json, pathlib, sys
config = pathlib.Path('/etc/electric-cherry/agent.json')
if config.exists():
saved = json.loads(config.read_text())
if saved.get('controller', '').rstrip('/') != sys.argv[1].rstrip('/') or pathlib.Path(saved.get('root', '')).resolve() != pathlib.Path(sys.argv[2]).resolve():
raise SystemExit('Existing enrollment uses a different controller or data directory. Preserve the existing installation and migrate it explicitly.')
PY
if ! command -v docker >/dev/null; then
if [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then
echo 'Existing Docker data/configuration detected without a Docker CLI. Repair Docker, then rerun.' >&2
exit 1
fi
apt-get install -y --no-install-recommends docker.io
systemctl enable --now docker
fi
docker info >/dev/null 2>&1 || { echo 'Docker is installed but unavailable. Start or repair it, then rerun.' >&2; exit 1; }
install -d -m 0755 "$data_dir" "$data_dir/libraries" "$data_dir/instances"
install -d -m 0700 "$data_dir/agent" /etc/electric-cherry
available_kb=$(df -Pk "$data_dir" | awk 'NR==2 {print $4}')
[ "$available_kb" -ge 1048576 ] || { echo 'At least 1 GiB of available game storage is required.' >&2; exit 1; }
temporary_dir=$(mktemp -d)
trap 'rm -rf "$temporary_dir"' EXIT
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/ec-agent.py" -o "$temporary_dir/ec-agent.py"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" <<'PY'
import hashlib, json, pathlib, sys
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
actual = hashlib.sha256(pathlib.Path(sys.argv[2]).read_bytes()).hexdigest()
if actual != manifest.get('sha256'):
raise SystemExit('Agent release checksum verification failed.')
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1:
raise SystemExit('Unsupported agent release manifest.')
PY
# Stop only the agent while replacing its own executable/journal owner. Existing
# game containers keep running under Docker throughout installation and updates.
if systemctl cat electric-cherry-agent.service >/dev/null 2>&1; then
systemctl stop electric-cherry-agent.service
fi
install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-agent.py 2>/dev/null || {
install -d -m 0755 /usr/local/libexec
install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-agent.py
}
printf '%s' "$enrollment_token" > "$temporary_dir/enrollment-token"
unset enrollment_token
python3 /usr/local/libexec/electric-cherry-agent.py --enroll --controller "$controller" --root "$data_dir" --token-file "$temporary_dir/enrollment-token" ${reenroll:+"$reenroll"}
cat > /etc/systemd/system/electric-cherry-agent.service <<EOF
[Unit]
Description=Electric Cherry Host Agent
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/libexec/electric-cherry-agent.py --config /etc/electric-cherry/agent.json
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=$data_dir /etc/electric-cherry
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable electric-cherry-agent
systemctl restart electric-cherry-agent
systemctl is-active --quiet electric-cherry-agent || { echo 'Agent service did not start. Inspect journalctl -u electric-cherry-agent.' >&2; exit 1; }
echo 'Electric Cherry host agent installed and enrolled. Host readiness is shown in Hosts.'
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""Publish the reviewed local agent source into the controller static release."""
import hashlib
import json
from pathlib import Path
import shutil
source = Path(__file__).resolve().parent
target = source.parent / "static" / "agent"
target.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source / "ec_agent.py", target / "ec-agent.py")
shutil.copyfile(source / "install.sh", target / "install.sh")
(target / "manifest.json").write_text(json.dumps({"version": "1.0.0", "protocolVersion": 1, "platform": "debian-amd64", "sha256": hashlib.sha256((target / "ec-agent.py").read_bytes()).hexdigest()}, indent=2) + "\n")
+547
View File
@@ -0,0 +1,547 @@
import base64
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("ec_agent", Path(__file__).resolve().parents[1] / "ec_agent.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
Agent, AgentError, Held, Journal = module.Agent, module.AgentError, module.Held, module.Journal
INSTANCE = "a53d99da-a1eb-4f01-9b10-a6268b556542"
class FakeDocker:
def __init__(self):
self.containers = {}
self.calls = []
self.fail_restart = False
self.lose_reply_after = None
self.started = 0
def inspect(self, name):
return self.containers.get(name)
def call(self, *args, **kwargs):
self.calls.append(args)
if args[0] == "info":
return json.dumps({"ServerVersion": "27.5.1"})
if args[:2] == ("image", "ls"):
return "ghcr.io/ayymoss/plutainer:latest\n"
if args[:2] == ("container", "create"):
labels = {args[index + 1].split("=", 1)[0]: args[index + 1].split("=", 1)[1] for index, arg in enumerate(args) if arg == "--label"}
name = args[args.index("--name") + 1]
self.containers[name] = {"Id": "container-123", "Config": {"Labels": labels}, "State": {"Status": "created", "Running": False}}
if args[:2] in (("container", "start"), ("container", "restart")):
if self.fail_restart and args[1] == "restart":
raise AgentError("Docker command unavailable or timed out")
self.started += 1
self.containers[args[-1]]["State"] = {"Status": "running", "Running": True, "StartedAt": f"started-{self.started}"}
if args[:2] == ("container", "stop"):
self.containers[args[-1]]["State"] = {"Status": "exited", "Running": False}
if args[:2] == ("container", "rm"):
self.containers.pop(args[-1])
if args[0] == "container" and args[1] == self.lose_reply_after:
raise AgentError("Docker command unavailable or timed out")
return ""
class Response(io.BytesIO):
def __init__(self, content, status=200, headers=None):
super().__init__(content)
self.status = status
self.headers = headers or {}
class AgentTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.config = {"root": self.temp.name, "controller": "https://controller.example", "credential": "secret", "portRange": {"min": 28960, "max": 28980}}
self.docker = FakeDocker()
self.agent = Agent(self.config, self.docker)
self.library_id = "library-one"
self.library = self.agent.library_dir("t5", self.library_id)
(self.library / "content").mkdir(parents=True)
(self.library / "content" / "game.ff").write_bytes(b"game-data")
files = [{"path": "game.ff", "size": 9, "sha256": module.digest(b"game-data")}]
module.atomic_json(self.library / "manifest.json", {"libraryId": self.library_id, "game": "t5", "size": 9, "files": files, "manifestSha256": module.digest(json.dumps(files, separators=(",", ":")).encode())})
def tearDown(self):
self.agent.journal.close()
self.temp.cleanup()
def operation(self, kind, payload=None, op_id="operation-1", generation=1):
return {"id": op_id, "kind": kind, "instanceId": INSTANCE, "generation": generation, "payload": payload or {}}
def create(self):
payload = {"name": "t5_zombie_test_1", "game": "t5", "libraryId": self.library_id, "image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "env": {"PLUTAINER_PORT": "28960"}, "rconPassword": "rcon-secret", "files": [{"path": "configs/server.cfg", "contentBase64": base64.b64encode(b"original").decode()}]}
result = self.agent.execute(self.operation("instance.create", payload, "create"))
self.assertEqual(result["state"], "completed", result)
return payload
def test_same_id_reports_saved_result_without_executing_again(self):
self.create()
operation = self.operation("instance.restart")
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
first = self.agent.execute(operation)
self.assertEqual(first["state"], "completed")
second = self.agent.execute({**operation, "state": "running", "updatedAt": "later", "result": None})
self.assertEqual(second["result"], first["result"])
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
def test_reusing_id_with_different_payload_is_rejected(self):
self.agent.execute(self.operation("host.inspect"))
with self.assertRaisesRegex(AgentError, "different content"):
self.agent.execute(self.operation("host.inspect", {"changed": True}))
def test_agent_restart_marks_running_unknown_and_never_replays(self):
operation = self.operation("instance.restart")
self.agent.journal.accept(operation)
self.agent.journal.update(operation["id"], "running")
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
result = self.agent.execute(operation)
self.assertEqual(result["state"], "unknown")
self.assertEqual(self.docker.calls, [])
def test_occupied_and_unknown_wait_then_revalidate_same_operation(self):
self.create()
operation = self.operation("instance.restart")
for players in (2, None, 7):
self.agent.rcon = lambda *_, count=players: {"ok": True, "players": count}
result = self.agent.execute(operation)
self.assertEqual(result["state"], "accepted")
self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls))
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.assertEqual(self.agent.execute(operation)["state"], "completed")
def test_unknown_container_health_cannot_disrupt(self):
self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"] = {"Status": "unknown", "Running": False}
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.assertEqual(self.agent.execute(self.operation("instance.stop"))["state"], "accepted")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_files_stage_is_nondisruptive_activation_snapshots_and_rollback(self):
self.create()
target = self.agent.instance_dir(INSTANCE, 1) / "app/configs/server.cfg"
staged = {"stageId": "campaign", "files": [{"id": 9, "path": "configs/server.cfg", "contentBase64": base64.b64encode(b"new").decode(), "sha256": module.digest(b"new"), "checkBefore": True, "expectedBeforeSha": module.digest(b"original")}]}
self.agent.execute(self.operation("files.stage", staged, "stage"))
self.assertEqual(target.read_bytes(), b"original")
self.agent.rcon = lambda *_: {"ok": True, "players": 3}
operation = self.operation("instance.restart", {"stageId": "campaign"})
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
self.assertEqual(target.read_bytes(), b"original")
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
result = json.loads(self.agent.execute(operation)["result"])
self.assertEqual(base64.b64decode(result["before"][0]["contentBase64"]), b"original")
self.assertEqual(target.read_bytes(), b"new")
rollback = self.agent.execute(self.operation("files.rollback", {"stageId": "campaign", "restart": False}, "rollback"))
self.assertEqual(rollback["state"], "completed")
self.assertEqual(target.read_bytes(), b"original")
def test_changed_target_fails_before_activation_or_restart(self):
self.create()
target = self.agent.instance_dir(INSTANCE, 1) / "app/configs/server.cfg"
self.agent.execute(self.operation("files.stage", {"stageId": "campaign", "files": [{"path": "configs/server.cfg", "contentBase64": base64.b64encode(b"new").decode(), "checkBefore": True, "expectedBeforeSha": "0" * 64}]}, "stage"))
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
result = self.agent.execute(self.operation("instance.restart", {"stageId": "campaign"}))
self.assertEqual(result["state"], "failed")
self.assertEqual(target.read_bytes(), b"original")
self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls))
def test_ambiguous_restart_keeps_before_images_on_disk_and_is_not_replayed(self):
self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.agent.execute(self.operation("files.stage", {"stageId": "campaign", "files": [{"path": "configs/server.cfg", "contentBase64": base64.b64encode(b"new").decode()}]}, "stage"))
self.docker.fail_restart = True
operation = self.operation("instance.restart", {"stageId": "campaign"})
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.agent.execute(operation)
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
saved = json.loads((self.agent.instance_dir(INSTANCE, 1) / "stages/campaign/manifest.json").read_text())
self.assertEqual(saved["state"], "activated")
self.assertEqual(base64.b64decode(saved["before"][0]["contentBase64"]), b"original")
def test_remove_only_own_instance_and_preserves_shared_library(self):
self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
result = self.agent.execute(self.operation("instance.remove"))
self.assertEqual(result["state"], "completed")
self.assertTrue((self.library / "content/game.ff").exists())
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
def test_unowned_container_never_mutated(self):
self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["Config"]["Labels"] = {}
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_rebuild_moves_generation_and_rejects_old_commands(self):
payload = self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
result = self.agent.execute(self.operation("instance.rebuild", {**payload, "nextGeneration": 2}))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(json.loads(result["result"])["generation"], 2)
self.assertEqual(self.agent.execute(self.operation("instance.stop", op_id="old-stop"))["state"], "failed")
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
def test_traversal_and_symlinks_are_rejected(self):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
(app / "escape").symlink_to(self.temp.name)
for path in ("../spec.json", "/etc/passwd", "escape/secret", "configs/../../spec.json"):
with self.assertRaises(AgentError):
module.scoped(app, path)
def manifest(self, content=b"complete-game-file"):
files = [{"path": "zone/test.ff", "size": len(content), "sha256": module.digest(content)}]
return {"id": "downloaded", "game": "t6", "files": files, "sizeBytes": len(content), "manifestSha256": module.digest(json.dumps(files, separators=(",", ":")).encode())}
def test_resume_verifies_and_atomically_publishes_library(self):
content = b"complete-game-file"
manifest = self.manifest(content)
staging = self.agent.root / "libraries/t6/.downloaded.partial/content/zone"
staging.mkdir(parents=True)
(staging / "test.ff").write_bytes(content[:8])
calls = []
def fetch(method, path, data=None, extra_headers=None):
calls.append((path, extra_headers))
if path == "/manifest":
return Response(json.dumps(manifest).encode())
return Response(content[8:], 206, {"Content-Range": f"bytes 8-{len(content)-1}/{len(content)}"})
self.agent.artifact_open = fetch
result = self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
self.assertEqual(result["state"], "ready")
self.assertEqual(calls[1][1], {"Range": "bytes=8-"})
self.assertEqual((self.agent.library_dir("t6", "downloaded") / "content/zone/test.ff").read_bytes(), content)
self.assertEqual(self.agent.inventory()["libraryCaches"][-1]["status"], "ready")
def test_corrupt_or_incomplete_library_never_promoted(self):
manifest = self.manifest()
def fetch(method, path, data=None, extra_headers=None):
return Response(json.dumps(manifest).encode() if path == "/manifest" else b"corrupt")
self.agent.artifact_open = fetch
with self.assertRaisesRegex(AgentError, "integrity"):
self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
self.assertFalse(self.agent.library_dir("t6", "downloaded").exists())
self.assertEqual(self.agent.library_progress["downloaded"]["status"], "failed")
def test_manifest_tampering_rejected_before_download(self):
manifest = self.manifest()
manifest["files"][0]["size"] = 999
self.agent.artifact_open = lambda *_: Response(json.dumps(manifest).encode())
with self.assertRaisesRegex(AgentError, "manifest checksum"):
self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
def test_artifact_urls_never_leak_credentials_to_other_origins(self):
for value in ("https://attacker.example/files", "//attacker.example/files", "http://controller.example/files", "https://user:pass@controller.example/files"):
with self.assertRaises(AgentError):
self.agent.artifact_url(value)
def test_unsupported_operation_does_not_spawn_shell(self):
result = self.agent.execute(self.operation("shell.execute", {"command": "touch /tmp/unsafe"}))
self.assertEqual(result["state"], "failed")
self.assertEqual(self.docker.calls, [])
def test_restart_lost_acknowledgement_recovers_from_changed_start_timestamp(self):
self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.docker.lose_reply_after = "restart"
operation = self.operation("instance.restart")
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
recovered = self.agent.execute(operation)
self.assertEqual(recovered["state"], "completed")
self.assertTrue(json.loads(recovered["result"])["reconciled"])
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
def test_created_container_lost_start_reply_reconciles_immutable_operation_label(self):
payload = {"name": "t5_zombie_test_1", "game": "t5", "libraryId": self.library_id, "image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "env": {}, "rconPassword": "secret", "files": []}
self.docker.lose_reply_after = "start"
operation = self.operation("instance.create", payload)
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed")
self.assertEqual(sum(call[:2] == ("container", "create") for call in self.docker.calls), 1)
def test_remove_lost_reply_reconciles_absence_without_duplicate_delete(self):
self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.docker.lose_reply_after = "rm"
operation = self.operation("instance.remove")
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
self.assertEqual(sum(call[:2] == ("container", "rm") for call in self.docker.calls), 1)
def test_rotation_recovers_lost_reply_using_durably_saved_pending_credential(self):
self.agent.config_path = str(Path(self.temp.name) / "agent.json")
accepted = {}
def request(method, path, payload):
if not accepted:
accepted["credential"] = payload["credential"]
persisted = json.loads(Path(self.agent.config_path).read_text())
self.assertEqual(persisted["pendingCredential"], payload["credential"])
raise AgentError("Controller request failed")
self.assertEqual(self.agent.config["credential"], accepted["credential"])
return {"ok": True}
self.agent.request = request
result = self.agent.execute(self.operation("credentials.rotate"))
self.assertEqual(result["state"], "completed")
saved = json.loads(Path(self.agent.config_path).read_text())
self.assertNotIn("pendingCredential", saved)
self.assertEqual(saved["credential"], accepted["credential"])
def test_status_parser_never_treats_partial_or_unfamiliar_rows_as_empty(self):
header = "map: zombie_cosmodrome\nnum score ping guid name\n"
self.assertIsNone(module.status_players(header))
self.assertEqual(module.status_players(header + "--- ----- ---- ---- ----\n"), 0)
for row in ("0 0 CNCT abc player", "1 90 ZMBI abc player", "2 4 ???? abc player"):
self.assertEqual(module.status_players(header + "--- ----- ---- ---- ----\n" + row), 1)
self.assertIsNone(module.status_players(header + "--- ----- ---- ---- ----\nconnection in progress"))
self.assertEqual(module.status_players('statusResponse\n\\mapname\\mp_kino\\sv_maxclients\\4\n10 45 "joe"'), 1)
def test_udp_rcon_uses_host_local_endpoint_and_returns_structured_occupancy(self):
self.create()
class FakeSocket:
def __enter__(self): return self
def __exit__(self, *_): pass
def settimeout(self, value): pass
def connect(self, endpoint): self.endpoint = endpoint
def send(self, packet): self.packet = packet
def recv(self, _size):
if getattr(self, "done", False): raise module.socket.timeout()
self.done = True
return b"\xff\xff\xff\xffprint\nmap: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n0 42 CNCT abc player\n"
sock = FakeSocket()
with patch.object(module.socket, "socket", return_value=sock):
result = self.agent.rcon(INSTANCE, 1, "status")
self.assertEqual(sock.endpoint, ("127.0.0.1", 28960))
self.assertTrue(sock.packet.startswith(b"\xff\xff\xff\xffrcon rcon-secret status"))
self.assertEqual(result["players"], 1)
self.assertTrue(result["ok"])
def test_invalid_rebuild_preflight_preserves_running_original(self):
payload = self.create()
result = self.agent.execute(self.operation("instance.rebuild", {**payload, "image": "--privileged", "nextGeneration": 2}))
self.assertEqual(result["state"], "failed")
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_file_operation_resists_parent_symlink_swap_after_validation(self):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
outside = Path(self.temp.name) / "outside"
outside.mkdir()
(outside / "server.cfg").write_bytes(b"host-data")
original = module.scoped
def swap(root, path, **kwargs):
result = original(root, path, **kwargs)
(app / "configs").rename(app / "saved-configs")
(app / "configs").symlink_to(outside)
return result
with patch.object(module, "scoped", side_effect=swap):
with self.assertRaises(OSError):
module.managed_write(app, "configs/server.cfg", b"escape-attempt")
self.assertEqual((outside / "server.cfg").read_bytes(), b"host-data")
def test_deleting_absent_file_with_absent_parent_is_idempotent(self):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
module.managed_write(app, "missing/subdirectory/file.gsc", None)
self.assertFalse((app / "missing").exists())
def test_controller_contact_failure_holds_before_running_any_docker_command(self):
self.create()
count = len(self.docker.calls)
self.agent.report = lambda *_: (_ for _ in ()).throw(AgentError("Controller request failed"))
result = self.agent.execute(self.operation("instance.stop"), report_running=True)
self.assertEqual(result["state"], "accepted")
self.assertEqual(len(self.docker.calls), count)
def test_async_import_publication_resumes_after_controller_restart(self):
responses = iter([{"status": "verifying"}, {"status": "pending"}, {"status": "verifying"}, {"status": "published", "version": {"id": "library-one"}}])
methods = []
def request(method, path, *args):
methods.append(method)
return Response(json.dumps(next(responses)).encode())
self.agent.artifact_open = request
with patch.object(self.agent.stopping, "wait", return_value=False):
result = self.agent.complete_import({"completeUrl": "/complete"}, start=True)
self.assertEqual(result["status"], "published")
self.assertEqual(methods, ["POST", "GET", "POST", "GET"])
def test_digest_pinned_runtime_inventory_and_exact_aliases(self):
image_digest = "sha256:" + "1" * 64
image_id = "sha256:" + "2" * 64
original = self.docker.call
def call(*args, **kwargs):
if args[:2] == ("image", "ls"):
return f"ghcr.io/ayymoss/plutainer|latest|{image_digest}|{image_id}\n"
return original(*args, **kwargs)
self.docker.call = call
module.atomic_json(self.agent.root / "agent/runtime-images.json", {"ghcr.io/ayymoss/plutainer": image_id, "removed-image": "missing-id"})
images = self.agent.inventory()["runtimeImages"]
self.assertIn("ghcr.io/ayymoss/plutainer@" + image_digest, images)
self.assertIn("ghcr.io/ayymoss/plutainer:latest", images)
self.assertIn("ghcr.io/ayymoss/plutainer", images)
self.assertNotIn("removed-image", images)
def test_heartbeat_applies_configured_host_port_range_before_deployment(self):
self.agent.request = lambda *_: {"host": {"portMin": 30000, "portMax": 30100}}
self.agent.heartbeat()
self.assertEqual(self.agent.config["portRange"], {"min": 30000, "max": 30100})
def test_download_content_permissions_allow_container_read_access_with_private_umask(self):
content = b"complete-game-file"
manifest = self.manifest(content)
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode() if path == "/manifest" else content)
previous = module.os.umask(0o077)
try:
self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
finally:
module.os.umask(previous)
content_root = self.agent.library_dir("t6", "downloaded") / "content"
self.assertEqual(content_root.stat().st_mode & 0o777, 0o555)
self.assertEqual((content_root / "zone").stat().st_mode & 0o777, 0o555)
self.assertEqual((content_root / "zone/test.ff").stat().st_mode & 0o777, 0o444)
def test_agent_restart_resumes_same_immutable_library_operation(self):
content = b"complete-game-file"
manifest = self.manifest(content)
payload = {"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files", "manifestSha256": manifest["manifestSha256"]}
operation = self.operation("library.download", payload)
self.agent.journal.accept(operation)
self.agent.journal.update(operation["id"], "running")
staging = self.agent.root / "libraries/t6/.downloaded.partial/content/zone"
staging.mkdir(parents=True)
(staging / "test.ff").write_bytes(content[:8])
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
def fetch(method, path, data=None, extra_headers=None):
if path == "/manifest":
return Response(json.dumps(manifest).encode())
self.assertEqual(extra_headers, {"Range": "bytes=8-"})
return Response(content[8:], 206, {"Content-Range": f"bytes 8-{len(content)-1}/{len(content)}"})
self.agent.artifact_open = fetch
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed")
self.assertEqual((self.agent.library_dir("t6", "downloaded") / "content/zone/test.ff").read_bytes(), content)
def test_player_join_during_activation_holds_restart_without_reapplying_files(self):
self.create()
target = self.agent.instance_dir(INSTANCE, 1) / "app/configs/server.cfg"
original_write = module.managed_write
for index, kind in enumerate(("instance.restart", "files.activate", "instance.start")):
with self.subTest(kind=kind):
before = target.read_bytes()
stage_id = "late-join-" + str(index)
content = ("new-" + str(index)).encode()
self.agent.execute(self.operation("files.stage", {"stageId": stage_id, "files": [{"id": index, "path": "configs/server.cfg", "contentBase64": base64.b64encode(content).decode()}]}, "stage-late-" + str(index)))
joined, writes = [False], []
self.agent.rcon = lambda *_: {"ok": True, "players": 1 if joined[0] else 0}
def write_then_join(*args, **kwargs):
original_write(*args, **kwargs)
writes.append(args)
joined[0] = True
operation = self.operation(kind, {"stageId": stage_id}, "action-late-" + str(index))
restarts_before = sum(call[:2] == ("container", "restart") for call in self.docker.calls)
with patch.object(module, "managed_write", side_effect=write_then_join):
held = self.agent.execute(operation)
self.assertEqual(held["state"], "accepted")
self.assertEqual(json.loads(held["result"])["blocked"], "occupied")
self.assertEqual(target.read_bytes(), content)
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), restarts_before)
joined[0] = False
completed = self.agent.execute(operation)
self.assertEqual(completed["state"], "completed")
self.assertEqual(len(writes), 1)
self.assertEqual(base64.b64decode(json.loads(completed["result"])["before"][0]["contentBase64"]), before)
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), restarts_before + 1)
def test_player_join_during_rollback_holds_restart_without_repeating_restore(self):
self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.agent.execute(self.operation("files.stage", {"stageId": "rollback-join", "files": [{"path": "configs/server.cfg", "contentBase64": base64.b64encode(b"new").decode()}]}, "stage-rollback-join"))
self.agent.execute(self.operation("files.activate", {"stageId": "rollback-join", "restart": False}, "activate-rollback-join"))
joined, writes = [False], []
original_write = module.managed_write
self.agent.rcon = lambda *_: {"ok": True, "players": 1 if joined[0] else 0}
def restore_then_join(*args, **kwargs):
original_write(*args, **kwargs)
writes.append(args)
joined[0] = True
operation = self.operation("files.rollback", {"stageId": "rollback-join"}, "rollback-join")
with patch.object(module, "managed_write", side_effect=restore_then_join):
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls))
joined[0] = False
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertEqual(len(writes), 1)
self.assertEqual((self.agent.instance_dir(INSTANCE, 1) / "app/configs/server.cfg").read_bytes(), b"original")
def test_start_with_stage_does_not_reconcile_success_before_required_restart(self):
self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.agent.execute(self.operation("files.stage", {"stageId": "start-stage", "files": [{"path": "configs/server.cfg", "contentBase64": base64.b64encode(b"new").decode()}]}, "stage-start"))
self.docker.fail_restart = True
operation = self.operation("instance.start", {"stageId": "start-stage"})
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
def test_running_unhealthy_container_uses_fresh_occupancy_before_recovery(self):
self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"]["Health"] = {"Status": "unhealthy"}
operation = self.operation("instance.restart")
self.agent.rcon = lambda *_: {"ok": True, "players": 2}
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.assertEqual(self.agent.execute(operation)["state"], "completed")
def test_game_log_can_follow_internal_relative_plutainer_symlink(self):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
(app / "logs").mkdir()
(app / "runtime").mkdir()
(app / "runtime/game.log").write_text("first\nsecond\nthird\n")
(app / "logs/games_sp.log").symlink_to("../runtime/game.log")
result = self.agent.execute(self.operation("logs.read", {"path": "logs/games_sp.log", "lines": 2}))
self.assertEqual(result["state"], "completed")
self.assertEqual(json.loads(result["result"])["lines"], ["second", "third"])
with self.assertRaises(AgentError):
module.managed_write(app, "logs/games_sp.log", b"must-not-write-through-link")
def test_game_log_rejects_symlink_escaping_instance(self):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
(app / "logs").mkdir()
outside = Path(self.temp.name) / "host-secret"
outside.write_text("secret")
(app / "logs/games_sp.log").symlink_to(outside)
result = self.agent.execute(self.operation("logs.read", {"path": "logs/games_sp.log"}))
self.assertEqual(result["state"], "failed")
self.assertIn("outside", result["error"])
def test_console_log_falls_back_to_owned_docker_output_including_stderr(self):
self.create()
original = self.docker.call
def call(*args, **kwargs):
if args[:2] == ("container", "logs"):
self.assertTrue(kwargs["merge_stderr"])
self.assertEqual(args[-1], self.agent.container_name(INSTANCE, 1))
return "stdout-line\nstderr-line\n"
return original(*args, **kwargs)
self.docker.call = call
result = self.agent.execute(self.operation("logs.read", {"path": "logs/console.log"}))
self.assertEqual(result["state"], "completed")
self.assertEqual(json.loads(result["result"])["lines"], ["stdout-line", "stderr-line"])
if __name__ == "__main__":
unittest.main()
+16 -28
View File
@@ -7,35 +7,23 @@ services:
restart: unless-stopped
ports:
- "3000:3000"
# Lets RCON_HOST=host.docker.internal reach game servers running on the
# same Komodo host. Docker Engine 20.10+ resolves this alias when the
# extra_hosts entry is present.
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# in prod (Komodo host), the game server tree is at /root/zombies
- /root/zombies:/root/zombies:rw
- /root/zombies/.electric-cherry:/data:rw
# Preserve the existing controller database by default. Set EC_DATA_DIR
# to a dedicated persistent directory for a new controller installation.
- ${EC_DATA_DIR:-/root/zombies/.electric-cherry}:/data:rw
environment:
# SvelteKit's Node adapter caps request bodies at 512KB by default,
# which rejects real-world plugin .dll uploads with HTTP 413. Same
# trust boundary as the CSRF disable (see svelte.config.js) — this is
# LAN-only, so "Infinity" (no cap) is fine. NOTE: "0" does NOT mean
# unlimited in this adapter — it blocks anything > 0 bytes. SvelteKit's
# own error message calls this out: "To disable body size limits,
# specify Infinity rather than 0." If exposed to the public internet,
# replace with a concrete byte cap (e.g. "128M") instead.
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
# Large game files are streamed with manifest size and checksum limits.
BODY_SIZE_LIMIT: "Infinity"
SERVERS_ROOT: /root/zombies/t5
T6_SERVERS_ROOT: /root/zombies/t6
REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp
BETA_ARCHIVE_DIR: /root/zombies/beta-archive
DB_PATH: /data/cherry.db
KOMODO_ADDRESS: ${KOMODO_ADDRESS}
KOMODO_API_KEY: ${KOMODO_API_KEY}
KOMODO_API_SECRET: ${KOMODO_API_SECRET}
KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER}
PLUTAINER_T6_GAMEFILES_VOLUME: ${PLUTAINER_T6_GAMEFILES_VOLUME:-t6_game_files}
RCON_PASSWORD: ${RCON_PASSWORD}
RCON_HOST: ${RCON_HOST:-host.docker.internal}
RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000}
GAME_FILES_ROOT: /data/game-files
BETA_ARCHIVE_DIR: /data/beta-archive
# These are logical legacy paths; native instance files live on agents.
SERVERS_ROOT: /data/legacy/t5
T6_SERVERS_ROOT: /data/legacy/t6
PLUTAINER_IMAGE: ${PLUTAINER_IMAGE:-ghcr.io/ayymoss/plutainer:latest}
# Optional. Native instances otherwise receive individual random secrets.
RCON_PASSWORD: ${RCON_PASSWORD:-}
+125
View File
@@ -0,0 +1,125 @@
# Users and access
Electric Cherry requires a user session for operator pages and management APIs.
HTTPS protects the connection; server-side role checks protect each operation.
There is no anonymous management mode or public registration.
## First administrator and upgrades
1. Generate a setup secret: `openssl rand -hex 32`.
2. Set `EC_SETUP_TOKEN` to that value in the controller environment (or Compose
`.env`), set `EC_PUBLIC_URL` to the external HTTPS URL, and start EC. For a
direct Node deployment, set `ORIGIN` to that same URL.
3. Visit EC. `/setup` asks for the setup token, a username, display name and
password. Only the holder of the token can create the first administrator.
4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller.
Setup remains closed once any user exists, even if the token is still set.
5. Use **Settings → Users** to create additional accounts. Share temporary
passwords privately; each user must change theirs on first login.
Existing installations become protected on upgrade. Existing game servers and
agent enrollment credentials continue working independently of browser login.
Preserve the current database when upgrading: accounts and sessions are stored
there alongside the fleet metadata. Never publish a blank setup page with a
guessable token. An unset setup token holds setup closed.
## Roles
| Capability | Viewer | Operator | Administrator |
| --- | --- | --- | --- |
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes |
| Raw configs, file diffs, logs and telemetry diagnostics | No | Yes | Yes |
| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes |
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage |
| Host setup, enrollment, credentials, runtime preparation and game-file import/cache | Read status | Read status | Manage |
| Server keys and global settings/maintenance policies | No | No | Manage |
| Create users, change roles, disable accounts and reset passwords | No | No | Yes |
| Own password and sign out | Yes | Yes | Yes |
Operators can launch using server keys already saved by an administrator. They
cannot save keys indirectly through a launch request. An operator's content
permissions include executable scripts and plugins; assign that role only to
people trusted to operate the game fleet.
Permissions are checked on the server for API calls, normal pages and Svelte
page-data requests. Hiding UI controls is a convenience, not the access boundary.
The controller prevents disabling or demoting the last enabled administrator.
Accounts are disabled rather than deleted so history retains their identity.
## Passwords, sessions and recovery
Passwords are 15–128 characters and stored using salted scrypt hashes. Temporary
passwords created or reset by administrators must be changed before any fleet
access. Password changes require the current password.
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
30 minutes without authenticated requests; background dashboard polling counts
as activity. Login replaces the existing browser session. Logout revokes it.
Changing a password, changing a role, disabling an account, or revoking that
user's sessions invalidates their existing sessions. Live event streams check
revocation every 15 seconds and then close.
Login, initial setup and password verification are rate-limited with persistent
15-minute windows. The database stores audit entries for login results, account
changes and successful management requests without recording passwords, tokens
or request bodies. Administrators can review recent entries in Users.
An administrator can reset another user's password from Users. If the only
administrator forgets their password, an operator with local controller/container
access can run the recovery command in an interactive terminal:
```sh
docker compose exec electric-cherry node scripts/reset-admin.mjs admin
# Direct Node deployment, using the correct database:
DB_PATH=./data/cherry.db node scripts/reset-admin.mjs admin
```
Replace `admin` with the existing administrator's username. The command prompts
without echoing the password, resets that enabled administrator only, revokes
their sessions and records the recovery. It does not expose a remote recovery
endpoint or accept passwords as command arguments.
## Agent and telemetry connections
Host agents retain enrollment/host-token authentication under `/api/agent/`.
Installer and release files remain public so a new machine can enroll. A browser
login never substitutes for a host credential, and host tokens cannot manage EC.
Game reporters must send a separate secret of at least 32 characters configured
as `EC_TELEMETRY_TOKEN`:
```http
POST /api/telemetry
Authorization: Bearer <telemetry-token>
Content-Type: application/json
```
`X-EC-Telemetry-Token` is also supported. Cookies, query parameters and JSON fields
cannot supply it. A missing server token disables ingestion; a missing or wrong
request token is rejected before parsing or recording the payload. Existing JSON
event fields are unchanged. Configure your uploaded reporter/plugin, or a trusted
relay if that reporter cannot set headers, before upgrading. Use HTTPS for the
reporter-facing endpoint. Rotating this shared token requires updating reporters.
## Proxy and local development
Keep Node's internal port behind your TLS proxy and configure `ORIGIN` correctly.
Management writes require an exact matching `Origin` header, including login,
JSON requests and multipart uploads. Do not configure a proxy to replace arbitrary
incoming origins with your trusted origin. Machine endpoints use their own token
checks and do not require a browser Origin.
Use the adapter's real client-address configuration only with a trusted proxy
that strips spoofed forwarding headers; login throttling otherwise uses the direct
peer address. Forwarded host or user headers do not authenticate users.
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
cookie. It never enables HTTP login for a LAN/public hostname. Agent HTTP
development remains separately controlled by `EC_AGENT_ALLOW_HTTP` and the agent's
loopback-only option.
The implementation follows OWASP's guidance for [authorization on every request](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html#validate-the-permissions-on-every-request),
[password hashing](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#scrypt),
and [session management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
+27 -5
View File
@@ -6,7 +6,18 @@ beta on that server.
## Game reporter protocol
Send JSON to `POST /api/telemetry` with `Content-Type: application/json`.
Set `EC_TELEMETRY_TOKEN` on the Electric Cherry controller to a dedicated random
secret of at least 32 characters (for example, generate 64 hexadecimal characters
with `openssl rand -hex 32`). Configure the same secret in each game reporter or
its trusted local relay. Use a production HTTPS URL or a reverse proxy that
terminates TLS; browser login cookies do not authorize game telemetry.
Send JSON to `POST /api/telemetry` with `Content-Type: application/json` and either
`Authorization: Bearer <EC_TELEMETRY_TOKEN>` or
`X-EC-Telemetry-Token: <EC_TELEMETRY_TOKEN>`. If both headers are present, they must
contain the same secret. Keep the secret out of URLs, payloads and browser code,
and use a different secret from host-agent credentials or account passwords.
The JSON event format is unchanged.
There are four events. Send them whether or not a beta is active.
| Event | Fields |
@@ -74,8 +85,10 @@ Send the final round and each player's final stats when the game ends.
Success returns HTTP 200 with `{ "ok": true, "telemetry": { ... } }`.
The returned telemetry contains the event and an Electric Cherry `receivedAt`
timestamp. Invalid JSON or fields return 400, an unknown/destroyed server 404,
and a non-JSON content type 415.
timestamp. A missing/invalid machine token returns 401; an unset or invalid
controller `EC_TELEMETRY_TOKEN` returns 503. Authentication happens before payload
validation. Authenticated invalid JSON or fields return 400, an unknown/destroyed
server 404, and a non-JSON content type 415.
The script sends each event once, in gameplay order, directly to Electric Cherry.
There is no discovery request, beta token, boot/session ID, sequence number,
@@ -93,7 +106,12 @@ of beta activity. That page documents cumulative stat accounting, round credit,
and the limits of identifying matches from this protocol.
The game scripts are uploaded artifacts, not source maintained in this
repository. Their reporter should emit the four payloads above.
repository. Their reporter should emit the four payloads above and add the
authentication header. Existing anonymous reporters must be updated before
telemetry is accepted; if a reporter cannot set HTTP headers, configure a trusted
host-local relay to add the header. This repository does not bundle that relay or
assume a particular GSC HTTP implementation. Rotating the shared secret requires
updating the controller environment and all reporters/relays together.
### Telemetry diagnostics
@@ -102,7 +120,9 @@ Open a server's **Diagnostics → Telemetry API requests** to inspect incoming
name. Each entry includes the Electric Cherry receipt time, HTTP result, error
reason, event, map, round, player count, content type, and submitted JSON. Rejected
reports are included, even when the content type is wrong but the body is readable
JSON. Processing failures return 500 and record the internal error when diagnostic
JSON after authentication. Unauthorized requests are rejected before their bodies
are read and are not retained in per-server diagnostics. Authentication headers
and their secrets are never stored. Processing failures return 500 and record the internal error when diagnostic
storage is available; failed database transactions do not update the scoreboard.
The panel refreshes every five seconds while visible and has a manual refresh
@@ -118,6 +138,8 @@ newest first, with caching disabled.
Check the reporter's destination and server field. Calls that never reach
Electric Cherry, malformed JSON, and bodies without a usable server name
cannot appear in this server's log. Earlier requests are not backfilled.
Missing/invalid telemetry credentials also produce no retained request; inspect
the reporter's HTTP result for 401 or an unconfigured-controller 503.
- **400 / 415:** inspect the error and payload. One invalid player's stats or a
duplicate GUID rejects the entire report. These requests do not update the
scoreboard.
+174
View File
@@ -0,0 +1,174 @@
# Native host management
Electric Cherry controls Docker/Plutainer through an outbound host agent. A native
instance never uses Komodo or a filesystem mounted into the controller. The
controller owns desired state, campaigns, collections, safety queues and history;
the agent owns local execution and its durable operation journal.
## Install and launch
1. Run the controller with persistent `/data` storage. Set `EC_PUBLIC_URL` to its
HTTPS origin and proxy TLS to port 3000. Set a random `EC_SETUP_TOKEN` and create
the first administrator at `/setup`. Management pages/APIs require user login
and roles; agent endpoints authenticate their own host credentials. See
[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,
and generate an enrollment. Run the displayed command on Debian 12/13 amd64.
3. The installer validates the platform, installs Python 3 and an absent Docker
installation, verifies the downloaded agent SHA-256 over HTTPS, enrolls it and
enables `electric-cherry-agent.service`. It needs no Git, Node.js or local build.
4. In **Hosts**, prepare a Plutainer runtime image. In **Game Files**, import your
existing `t5_game_files` or `t6_game_files` Docker volume on an enrolled source
host. Name the library and version and record its DLC metadata.
5. Cache the published version on each destination host. Wait for verification.
Hosts report connection and readiness separately; deployment also checks the
particular library, image, capacity and ports you selected.
6. Launch a server or collection. Select the host, immutable game-file version(s)
and runtime image. EC selects lobby IDs and reserves UDP/TCP ports atomically.
Saved Plutonium server keys remain associated with their exact server names.
A fresh destination host downloads centrally stored game files without manual
copying or per-container setup. EC does not supply the game content itself.
The default host root is `/var/lib/electric-cherry`; installer `--data-dir` changes
it. Data is separated into `libraries/<game>/<version>/content`,
`instances/<UUID>/<generation>/app`, and `agent/operations.sqlite3`. Base libraries
are mounted read-only and instance deletion removes only that instance's files.
The controller stores large files under `GAME_FILES_ROOT` (default beside the
SQLite database), with manifests/checksums and operation metadata in SQLite.
The agent uses Plutainer's documented [game-file and app mounts](https://github.com/Ayymoss/Plutainer/blob/main/docs/volumes-and-configs.md).
Its log reader supports relative log links that resolve inside the instance app
directory, while file writes reject symlinks and paths outside that directory.
## Identity, transport and credentials
Enrollment tokens are random, hashed at rest, single-use and expire after 15
minutes. Consumption and credential issuance are transactional. Enrollment only
works at the enrollment endpoint. Each host receives a distinct bearer credential
and the controller only stores its hash. Host and enrollment bearer credentials
are rejected outside `/api/agent/`.
The agent uses HTTPS, operating-system certificate validation, outbound heartbeats
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
insecure development option is limited to loopback. Public release assets contain
no credentials. The release checksum is authenticated by the controller's HTTPS
origin; there is no separate offline release-signing infrastructure.
Browser sessions do not authorize agents. Similarly, host credentials never grant
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
configured in each reporter's authorization header; see the
[telemetry contract](beta-test-plans.md#game-reporter-protocol).
Hosts offers credential rotation and revocation. Rotation persists the new secret
on the agent before switching controller credentials, so a lost response can be
retried using the same proposed secret. 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.
API outline:
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
- `/api/agent/imports/...` and `/api/agent/libraries/...`: host-scoped uploads and
authenticated game-file downloads.
The operation envelope is independent of polling and can be carried over a
persistent transport later. No unrestricted shell operation is available.
## Safety and durable operations
Each remote request has an immutable ID and payload, plus an instance UUID and
generation where applicable. Reusing a key with a different request is rejected.
The controller records remote IDs before dispatch. The agent persists accepted,
running, completed, failed and unknown states in SQLite. Lost acknowledgments
return the recorded result; restarting the controller never creates a new
mutation simply because the previous reply was lost.
The agent reconciles ambiguous results using owned Docker labels, generations and
persisted execution checkpoints when those observations prove the outcome.
Otherwise the result remains **Unknown** and holds dependent work. Running alone
is insufficient evidence that a particular restart happened. EC neither assumes
an unreachable server stopped nor launches a duplicate replacement elsewhere.
Campaign bytes are staged and verified while players remain connected. Only
Provisioning can dispatch activation. It checks source eligibility and shared
capacity, and the agent repeats a fresh local RCON occupancy check immediately
before activation/restart, stop, removal or rebuild. Busy or unverifiable lobbies
wait indefinitely, including multi-hour Zombies sessions. Before images returned
by the agent populate existing campaign/beta rollback history. A beta cannot be
closed while its remote activation receipt is still unresolved.
Rebuild uses a new generation, fresh instance storage and the latest promoted
configuration/scripts/plugins. It retains the selected game-file version and
runtime image. Old generation commands cannot target the replacement. Start,
restart and create complete only after post-operation health evidence; creation
and rebuild also verify the selected map and running Plutonium revision. The
base-game library and runtime revision are independent records.
**Drain** blocks new deployments and queues safe retirement of existing lobbies.
Active beta ownership and other operations continue to gate retirement. Players
may still join until an eligible empty moment; draining does not kick them.
**Disable** holds agent dispatch. Neither mode, controller downtime nor agent
communication loss stops unrelated running containers.
## Libraries and ports
Imports scan the source volume and generate per-file SHA-256 manifests. Uploads
stream to temporary controller storage, are bounded by their manifest sizes and
verified before atomic publication. Published versions cannot be updated;
changing files requires a new version. Hosts download with HTTP Range resumption,
report progress, verify all checksums and atomically promote the complete cache.
Corrupt or incomplete content never becomes Ready. Cache inventory is reconciled
on heartbeats, including vanished caches. Older immutable versions remain usable
when their exact manifest is verified.
Reservations include host ID, local bind address, protocol and port. Wildcard
binds conflict with concrete addresses on the same host. UDP and TCP reservations
for one launch commit together; the same number can be used independently on
another host. Optional public/NAT address and port are recorded separately and do
not change local Docker bindings. EC does not configure external NAT/firewalls.
## Development and validation
```sh
npm run agent:package
npm run check
npm test
npm run test:agent
npm run build
```
The build packages `agent/ec_agent.py` and `agent/install.sh` into the static
release, generates its checksum manifest and serves those same assets in the
Node-adapter image. The Python agent uses only its standard library.
Automated tests cover enrollment/authentication, rotation/revocation, host
readiness, scoped ports, immutable library publication, streaming/ranges,
checksums, interrupted downloads, instance generation ownership, local occupancy
holds, controller restart/reconnect, ambiguous results, occupied campaign staging,
activation before images, collections and draining. Docker/UDP and host
observations are simulated in these tests.
A real Debian/Docker/Plutonium acceptance run is still required before production
rollout. This workspace has no Docker engine or Debian game host, so automated
coverage is not evidence that a real T5/T6 server has been deployed here. Run:
- Installer on Debian 12 and 13 amd64, then rerun without changing identity/data.
- T5 and T6 deployments and a mixed collection from a fresh destination host.
- Start, stop, restart, rebuild, destroy; verify RCON, map and runtime revision.
- Read console and game logs, including Plutainer's relative game-log links.
- File campaign and rollback, with a player joining between controller check and
agent activation, and during file activation before restart; verify that the
agent holds disruption.
- Drain occupied lobbies and verify shared libraries survive their retirement.
- Corrupt/interrupted library downloads, resumed transfer and verified promotion.
- Controller restart, agent restart and network loss before execution, during
execution and after execution but before acknowledgement. Verify the same
operation IDs and absence of duplicate containers/restarts.
Legacy backend code remains for existing database registrations. New native
launches do not require its configuration, API, network or mounted directories.
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "electric-cherry",
"version": "0.1.0",
"description": "T5/T6 Plutonium Zombies fleet manager — replaces push_scripts.sh with a live web UI",
"description": "T5/T6 Plutonium Zombies fleet manager \u2014 replaces push_scripts.sh with a live web UI",
"private": true,
"type": "module",
"scripts": {
@@ -12,7 +12,10 @@
"test": "vitest run",
"test:watch": "vitest",
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
"seed:fixture": "tsx scripts/seed-fixture.ts"
"seed:fixture": "tsx scripts/seed-fixture.ts",
"agent:package": "python3 agent/package_release.py",
"prebuild": "npm run agent:package",
"test:agent": "python3 -m unittest discover -s agent/tests -v"
},
"dependencies": {
"@fontsource/alfa-slab-one": "^5.3.0",
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
// Local recovery only: requires filesystem/container access to the controller.
import Database from 'better-sqlite3';
import { emitKeypressEvents } from 'node:readline';
import { hashPassword } from '../src/lib/server/auth/passwords.js';
function secret(prompt) {
return new Promise((resolve, reject) => {
let value = '';
process.stdout.write(prompt);
emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
const cleanup = () => {
process.stdin.off('keypress', listener);
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdout.write('\n');
};
const listener = (text, key) => {
if (key?.ctrl && key.name === 'c') { cleanup(); reject(new Error('Cancelled.')); }
else if (key?.name === 'return' || key?.name === 'enter') { cleanup(); resolve(value); }
else if (key?.name === 'backspace') value = [...value].slice(0,-1).join('');
else if (text && !key?.ctrl && !key?.meta && !/[\x00-\x1f\x7f]/.test(text) && [...value].length < 128) value += text;
};
process.stdin.on('keypress',listener);
});
}
try {
const username = process.argv[2]?.toLowerCase();
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>');
if (!process.stdin.isTTY) throw new Error('Run this command in an interactive terminal. Passwords are never accepted as command arguments.');
const database = new Database(process.env.DB_PATH || './data/cherry.db',{fileMustExist:true});
database.pragma('busy_timeout=5000');
const user = database.prepare("SELECT id 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.');
const password = await secret('New administrator password (15–128 characters): ');
const confirmation = await secret('Repeat password: ');
if (password !== confirmation) throw new Error('Passwords did not match.');
const passwordHash = await hashPassword(password);
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);
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();
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 -1
View File
@@ -1,5 +1,10 @@
declare global {
namespace App {}
namespace App {
interface Locals {
user: import('./lib/auth.js').AuthUser | null;
sessionHash: string | null;
}
}
}
export {};
+3 -2
View File
@@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { migrate } from '$lib/server/db/migrate.js';
import { startWorker } from '$lib/server/pipeline/worker.js';
import { interruptDeployments } from '$lib/server/provisioning/history.js';
import { authenticationHandle } from '$lib/server/auth/guard.js';
let booted = false;
function boot(): void {
@@ -15,7 +16,7 @@ function boot(): void {
// SvelteKit calls handle() for every request; using the first request as the
// boot trigger keeps the whole app single-process without needing an
// out-of-band init hook.
export const handle: Handle = async ({ event, resolve }) => {
export const handle: Handle = async (input) => {
boot();
return resolve(event);
return authenticationHandle(input);
};
+17
View File
@@ -0,0 +1,17 @@
export type UserRole = 'admin' | 'operator' | 'viewer';
export interface AuthUser {
id: string;
username: string;
displayName: string;
role: UserRole;
disabled: boolean;
mustChangePassword: boolean;
createdAt: string;
updatedAt: string;
lastLoginAt: string | null;
}
export const roleLabels: Record<UserRole, string> = {
admin: 'Administrator', operator: 'Operator', viewer: 'Viewer'
};
+1
View File
@@ -11,6 +11,7 @@ export interface LaunchFileSummary {
}
export interface LaunchRecipeSummary {
native?: { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number };
game: ZombieGame;
mapSlug: string;
mapName: string;
+28
View File
@@ -0,0 +1,28 @@
export type GameLibraryGame = 't5' | 't6';
export type GameLibraryState = 'importing' | 'ready' | 'failed';
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
export interface GameFileEntry { path: string; size: number; sha256: string }
export interface HostLibraryCache {
hostId: string; libraryVersionId: string; status: LibraryCacheState;
bytesDownloaded: number; bytesTotal: number; manifestSha256: string | null;
error: string | null; updatedAt: string; operationId: string | null;
}
export interface GameLibraryVersion {
id: string; libraryId: string; game: GameLibraryGame; version: string;
description: string; dlc: string[]; state: GameLibraryState; sizeBytes: number;
fileCount: number; manifestSha256: string | null; files: GameFileEntry[];
createdAt: string; publishedAt: string | null; storageLocation: string;
sourceHostId: string; sourceVolume: string; error: string | null; operationId: string | null;
caches: HostLibraryCache[];
}
export interface GameLibrary {
id: string; name: string; game: GameLibraryGame; description: string;
createdAt: string; versions: GameLibraryVersion[];
}
export function formatGameBytes(bytes: number): string {
if (!bytes) return '0 B';
const unit = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${['B', 'KiB', 'MiB', 'GiB', 'TiB'][unit]}`;
}
+29
View File
@@ -0,0 +1,29 @@
export type HostMode = 'active' | 'draining' | 'disabled';
export type HostReadiness = 'preparing' | 'ready' | 'degraded' | 'draining' | 'disabled';
export type HostOperationState = 'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
export interface HostLibrary { id: string; state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; progress?: number; error?: string }
export interface HostInventory {
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
cpu?: string; cpuCount?: number; memoryTotal?: number; memoryAvailable?: number;
storageTotal?: number; storageAvailable?: number; dockerVersion?: string; dockerRunning?: boolean;
directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[];
currentOperation?: string | null; [key: string]: unknown;
}
export interface Host {
id: string; name: string; hostname: string; mode: HostMode; portMin: number; portMax: number;
bindAddress: string; publicAddress: string; minimumFreeBytes: number; createdAt: string;
lastHeartbeat: string | null; connected: boolean; connectionState: 'online' | 'offline';
readiness: HostReadiness; readinessReasons: string[]; inventory: HostInventory;
enrolled: boolean; credentialActive: boolean;
}
export interface HostOperation {
id: string; hostId: string; kind: string; instanceId: string | null; generation: number | null;
payload: Record<string, unknown>; state: HostOperationState; result: unknown; error: string | null;
createdAt: string; updatedAt: string;
}
export interface NativeDeploymentSelection {
hostId: string; libraryIds: Partial<Record<'t5' | 't6', string>>; image: string;
bindAddress?: string; publicAddress?: string; publicPort?: number;
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const provisioningPhases = ['requested', 'eligibility', 'startup-queue',
'verify-map', 'verify-plutonium', 'verify-stopped'] as const;
export type ProvisioningPhase = typeof provisioningPhases[number];
export type EvidenceResult = 'pass' | 'waiting' | 'fail' | 'unknown';
export type EvidenceSource = 'komodo' | 'rcon' | 'telemetry' | 'combined' | 'manifest' | null;
export type EvidenceSource = 'komodo' | 'agent' | 'rcon' | 'telemetry' | 'combined' | 'manifest' | null;
export interface ProvisioningCheck {
key: 'container' | 'game' | 'map' | 'plutonium-release' | 'plutonium-version' | 'occupancy' | 'stopped';
+51
View File
@@ -0,0 +1,51 @@
import type { UserRole } from '../../auth.js';
export type RoutePermission = 'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin';
const within = (route: string, base: string): boolean => route === base || route.startsWith(base + '/');
const readMethod = (method: string): boolean => method === 'GET' || method === 'HEAD';
/** Classify SvelteKit's resolved route ID, never a user-supplied URL pathname.
* Route IDs preserve the same policy for page-data requests and encoded URLs.
* Machine endpoints enforce their own host/enrollment/telemetry credentials.
*/
export function routePermission(routeId: string | null, method: string): RoutePermission {
method = method.toUpperCase();
const reading = readMethod(method);
if (routeId === null) return reading ? 'viewer' : 'operator';
// These cannot occur in this application's resolved route IDs. Fail closed
// if a future caller accidentally supplies a raw or noncanonical pathname.
if (!routeId.startsWith('/') || /[%\\?#\x00-\x1f\x7f]/.test(routeId)
|| routeId !== '/' && routeId.split('/').slice(1).some(part => !part || part === '.' || part === '..')) return 'admin';
if (within(routeId, '/api/agent')) return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
if (within(routeId, '/api/users') || within(routeId, '/settings') || within(routeId, '/api/auth')
|| within(routeId, '/api/server-keys') || within(routeId, '/server-keys') || within(routeId, '/keys')) return 'admin';
if (!reading && ['/api/hosts', '/api/settings', '/api/game-files'].some(base => within(routeId, base))) return 'admin';
// These endpoints mix operational actions with global policy and schedule
// changes. Per-incident/job actions remain available to operators.
if (!reading && ['/api/maintenance', '/api/plutonium', '/api/watchdog'].includes(routeId)) return 'admin';
// Summaries remain visible to viewers; raw configuration, historical file
// bytes, and unfiltered diagnostics may contain runtime credentials.
if (routeId === '/configs/[id]' || routeId === '/api/configs/[id]' || routeId === '/api/configs/inspect'
|| routeId === '/api/campaigns/[id]/file/[fileId]' || routeId === '/api/deployments/[id]/file/[fileId]'
|| routeId === '/api/servers/[name]/logs/[file]' || routeId === '/api/servers/[name]/telemetry-requests') return 'operator';
return reading ? 'viewer' : 'operator';
}
/** A browser role never substitutes for a machine credential. */
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
if (!['viewer', 'operator', 'admin'].includes(role)) return false;
if (permission === 'machine') return false;
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') return true;
if (permission === 'operator') return role === 'operator' || role === 'admin';
return permission === 'admin' && role === 'admin';
}
+111
View File
@@ -0,0 +1,111 @@
import { error, json, redirect, type Handle } from '@sveltejs/kit';
import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js';
import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js';
import { roleAllows, routePermission, type RoutePermission } from './access.js';
function publicAsset(routeId: string | null, path: string, method: string) {
return routeId === null && ['GET','HEAD'].includes(method) &&
(path.startsWith('/_app/') || path === '/favicon.svg' || ['/agent/ec-agent.py','/agent/install.sh','/agent/manifest.json'].includes(path));
}
/** Streaming responses must lose access when the underlying session does. */
export function protectEventStream(response: Response, tokenHash: string, permission: RoutePermission, signal: AbortSignal): Response {
if (!response.body) return response;
const reader = response.body.getReader();
let timer: ReturnType<typeof setInterval> | undefined;
let closed = false;
let closeStream: () => void = () => {};
const cleanup = () => { if (timer) clearInterval(timer); signal.removeEventListener('abort', closeStream); };
const stream = new ReadableStream<Uint8Array>({
start(controller) {
closeStream = () => {
if (closed) return; closed = true; cleanup();
void reader.cancel().catch(() => {}); controller.close();
};
timer = setInterval(() => {
try {
const current = readSessionHash(tokenHash);
if (!current || current.user.mustChangePassword || !roleAllows(current.user.role,permission)) closeStream();
} catch { closeStream(); }
},15_000);
signal.addEventListener('abort',closeStream,{once:true});
if (signal.aborted) { closeStream(); return; }
},
async pull(controller) {
try {
const next = await reader.read();
if (closed) return;
if (next.done) { closed=true; cleanup(); controller.close(); }
else controller.enqueue(next.value);
} catch (cause) { if (!closed) { closed=true; cleanup(); controller.error(cause); } }
},
cancel() { closed=true; cleanup(); return reader.cancel(); }
});
return new Response(stream,{status:response.status,statusText:response.statusText,headers:response.headers});
}
export const authenticationHandle: Handle = async ({ event, resolve }) => {
event.locals.user = null; event.locals.sessionHash = null;
const routeId = event.route.id;
const method = event.request.method.toUpperCase();
if (publicAsset(routeId,event.url.pathname,method)) return resolve(event);
const permission = routePermission(routeId,method);
if (!routeId?.startsWith('/api/agent/') && /^Bearer ec_(?:host|enroll)_/i.test(event.request.headers.get('authorization')??'')) {
return json({error:'Host credentials only authorize the host agent API.'},{status:403});
}
if (permission === 'machine') return resolve(event);
const api = routeId?.startsWith('/api/') || event.url.pathname.startsWith('/api/');
try {
requireUserTransport(event.url);
if (!['GET','HEAD','OPTIONS'].includes(method)) requireSameOrigin(event.request,event.url);
} catch (cause) { if (api) return authError(cause); throw cause; }
const session = readSession(event.cookies.get(sessionCookie(event.url)));
event.locals.user = session?.user??null;
event.locals.sessionHash = session?.tokenHash??null;
const setup = setupRequired();
if (permission !== 'public') {
if (!session) {
if (api) return json({error:setup?'Complete administrator setup before using the management API.':'Sign in to continue.',setupRequired:setup},{status:401,headers:{'Cache-Control':'no-store'}});
redirect(303,setup?'/setup':`/login?next=${encodeURIComponent(event.url.pathname+event.url.search)}`);
}
if (session.user.mustChangePassword && permission !== 'authenticated') {
if (api) return json({error:'Change your temporary password from Account before continuing.',mustChangePassword:true},{status:403,headers:{'Cache-Control':'no-store'}});
redirect(303,'/account');
}
if (!roleAllows(session.user.role,permission)) {
const message = permission==='admin'?'Administrator access is required.':'Operator access is required for this action.';
if (api) return json({error:message},{status:403,headers:{'Cache-Control':'no-store'}});
error(403,message);
}
// Launch APIs can also save credentials. Operators may launch with saved
// keys, but only administrators may introduce or replace server keys.
if (session.user.role === 'operator' && method === 'POST' && ['/api/servers','/api/deployment-collections'].includes(routeId??'')) {
try {
const body = await authBody(event.request.clone(),1024*1024);
if (body.plutoKey || (body.keys && typeof body.keys==='object' && Object.values(body.keys).some(Boolean))) {
return json({error:'An administrator must save server keys before an operator launches these servers.'},{status:403});
}
} catch (cause) { return authError(cause); }
}
} else if (['/login','/setup'].includes(routeId??'')) {
if (session) redirect(303,session.user.mustChangePassword?'/account':'/');
if (routeId==='/setup'&&!setup) redirect(303,'/login');
if (routeId==='/login'&&setup) redirect(303,'/setup');
}
let response = await resolve(event);
if (session && !['GET','HEAD','OPTIONS'].includes(method) && response.status < 400) {
recordAudit(session.user.id,'request',routeId,{method,status:response.status,path:event.url.pathname});
}
if (session && response.headers.get('content-type')?.startsWith('text/event-stream')) {
response = protectEventStream(response,session.tokenHash,permission,event.request.signal);
}
const headers = new Headers(response.headers);
headers.set('Cache-Control','no-store');
headers.set('Vary',[headers.get('Vary'),'Cookie'].filter(Boolean).join(', '));
headers.set('Referrer-Policy','same-origin');
headers.set('X-Content-Type-Options','nosniff');
headers.set('X-Frame-Options','DENY');
return new Response(response.body,{status:response.status,statusText:response.statusText,headers});
};
+49
View File
@@ -0,0 +1,49 @@
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js';
import type { AuthUser } from '../../auth.js';
export function sessionCookie(url: URL): string { return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev'; }
export function requireUserTransport(url: URL): void {
if (url.protocol === 'https:') return;
if (process.env.EC_AUTH_ALLOW_HTTP === 'true' && ['localhost','127.0.0.1','[::1]','::1'].includes(url.hostname)) return;
error(426, 'Operator login requires HTTPS. For local development only, set EC_AUTH_ALLOW_HTTP=true and use localhost.');
}
export function requireSameOrigin(request: Request, url: URL): void {
if (request.headers.get('origin') !== url.origin || request.headers.get('sec-fetch-site') === 'cross-site') {
error(403, 'This action requires a same-origin request.');
}
}
export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL) {
cookies.set(sessionCookie(url), result.session.token, { path: '/', httpOnly: true, secure: url.protocol === 'https:',
sameSite: 'lax', expires: new Date(result.session.expiresAt) });
return json({ user: result.user }, { headers: { 'Cache-Control':'no-store' } });
}
export function requireUser(user: AuthUser | null): AuthUser {
if (!user) throw new AuthError('Sign in to continue.',401);
return user;
}
export async function authBody(request: Request, maxBytes = 8192): Promise<Record<string, unknown>> {
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new AuthError('Send a JSON request.',415);
if (Number(request.headers.get('content-length')) > maxBytes) throw new AuthError('Request is too large.',413);
const reader = request.body?.getReader(); if (!reader) throw new AuthError('A JSON object is required.');
const chunks: Uint8Array[] = []; let length = 0;
try {
for (;;) {
const { done, value } = await reader.read(); if (done) break;
length += value.byteLength;
if (length > maxBytes) { void reader.cancel().catch(() => {}); throw new AuthError('Request is too large.',413); }
chunks.push(value);
}
} finally { reader.releaseLock(); }
let body: unknown;
try { body = JSON.parse(Buffer.concat(chunks,length).toString('utf8')); } catch { throw new AuthError('Invalid JSON.'); }
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new AuthError('A JSON object is required.');
return body as Record<string, unknown>;
}
export function authError(cause: unknown): Response {
if (isHttpError(cause)) return json({ error: cause.body.message }, { status:cause.status, headers:{'Cache-Control':'no-store'} });
const message = cause instanceof Error ? cause.message : 'Authentication request failed.';
const status = cause instanceof AuthError ? cause.status : message.includes('busy') ? 429 : message.startsWith('Use a password') ? 400 : 500;
return json({ error: status === 500 ? 'The authentication request could not be completed.' : message },
{ status, headers:{'Cache-Control':'no-store',...(status===429?{'Retry-After':'900'}:{})} });
}
+184
View File
@@ -0,0 +1,184 @@
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { db } from '../db/index.js';
import { ensureAuthSchema } from './schema.js';
import { hashPassword, verifyPassword } from './passwords.js';
import type { AuthUser, UserRole } from '../../auth.js';
export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
export const SESSION_IDLE_MS = 30 * 60_000;
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() { const handle = db(); ensureAuthSchema(handle); return handle; }
type UserRow = { id: string; username: string; display_name: string; password_hash: string; role: UserRole; disabled: number;
must_change_password: number; created_at: number; updated_at: number; last_login_at: number | null };
export class AuthError extends Error {
constructor(message: string, public status = 400) { super(message); }
}
function present(row: UserRow): AuthUser {
return { id: row.id, username: row.username, displayName: row.display_name, role: row.role, disabled: !!row.disabled,
mustChangePassword: !!row.must_change_password, createdAt: new Date(row.created_at).toISOString(),
updatedAt: new Date(row.updated_at).toISOString(), lastLoginAt: row.last_login_at === null ? null : new Date(row.last_login_at).toISOString() };
}
function userRow(id: string) { return database().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as UserRow | undefined; }
export function getUser(id: string): AuthUser | null { const row = userRow(id); return row ? present(row) : null; }
export function listUsers(): AuthUser[] { return (database().prepare('SELECT * FROM auth_users ORDER BY username').all() as UserRow[]).map(present); }
export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); }
function username(value: unknown): string {
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.');
return value.toLowerCase();
}
function displayName(value: unknown, fallback: string): string {
const name = value === undefined ? fallback : value;
if (typeof name !== 'string' || !name.trim() || name.length > 100 || /[\x00-\x1f\x7f]/.test(name)) throw new AuthError('Display name must contain 1–100 printable characters.');
return name.trim();
}
function role(value: unknown): UserRole {
if (!['admin','operator','viewer'].includes(value as string)) throw new AuthError('Choose administrator, operator or viewer.');
return value as UserRole;
}
export function recordAudit(actorId: string | null, action: string, targetId: string | null = null, details: Record<string, unknown> = {}): void {
database().prepare('INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(?,?,?,?,?)')
.run(actorId, action, targetId, JSON.stringify(details), Date.now());
}
export function listAuthAudit() {
return database().prepare(`SELECT a.id,a.actor_id AS actorId,u.username AS actor,a.action,a.target_id AS targetId,a.details,a.created_at AS createdAt
FROM auth_audit a LEFT JOIN auth_users u ON u.id=a.actor_id ORDER BY a.id DESC LIMIT 200`).all();
}
/** Persistent fixed windows are consumed before hashing, including failed setup. */
export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void {
const now = Date.now(); const windowMs = 15 * 60_000;
const ipKey = digest(`${kind}:ip:${clientAddress}`);
const accountKey = digest(`${kind}:account:${account.toLowerCase()}`);
database().transaction(() => {
database().prepare('DELETE FROM auth_rate_limits WHERE window_start < ?').run(now - 24 * 60 * 60_000);
for (const [key, limit] of [[ipKey, 30], [accountKey, 10]] as const) {
const previous = database().prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?').get(key) as { window_start: number; attempts: number } | undefined;
if (previous && previous.window_start > now - windowMs && previous.attempts >= limit) throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
}
for (const key of [ipKey, accountKey]) database().prepare(`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN window_start <= ? THEN 1 ELSE attempts+1 END,
window_start=CASE WHEN window_start <= ? THEN excluded.window_start ELSE window_start END`).run(key, now, now - windowMs, now - windowMs);
}).immediate();
}
function insertUser(input: { username: string; displayName: string; role: UserRole; passwordHash: string; mustChangePassword: boolean }): AuthUser {
if (database().prepare('SELECT 1 FROM auth_users WHERE username=?').get(input.username)) throw new AuthError('That username is already in use.', 409);
const id = randomUUID(); const now = Date.now();
database().prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,must_change_password,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`)
.run(id,input.username,input.displayName,input.passwordHash,input.role,Number(input.mustChangePassword),now,now);
return getUser(id)!;
}
export interface AuthResult { user: AuthUser; session: { token: string; expiresAt: number } }
export async function bootstrapAdmin(input: Record<string, unknown>, clientAddress: string): Promise<AuthResult> {
consumeAuthAttempt(clientAddress, 'bootstrap', 'setup');
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
const expected = process.env.EC_SETUP_TOKEN;
if (!expected || expected.length < 32) throw new AuthError('The controller owner must configure EC_SETUP_TOKEN with at least 32 characters before setup.', 503);
if (typeof input.token !== 'string' || !timingSafeEqual(Buffer.from(digest(input.token), 'hex'), Buffer.from(digest(expected), 'hex'))) throw new AuthError('Invalid setup token.', 401);
const name = username(input.username); const label = displayName(input.displayName, name);
const passwordHash = await hashPassword(input.password);
return database().transaction(() => {
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
const user = insertUser({ username: name, displayName: label, passwordHash, role: 'admin', mustChangePassword: false });
recordAudit(user.id, 'setup', user.id);
return { user, session: createSession(user.id) };
}).immediate();
}
export async function authenticatePassword(input: Record<string, unknown>, clientAddress: string): Promise<AuthResult> {
const name = typeof input.username === 'string' ? input.username.slice(0,64).toLowerCase() : '';
consumeAuthAttempt(clientAddress, name);
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as UserRow | undefined;
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
const result = database().transaction(() => {
const current = row ? userRow(row.id) : undefined;
if (!valid || !current || current.disabled || current.password_hash !== row?.password_hash) {
recordAudit(null, 'login.failed'); return null;
}
database().prepare('UPDATE auth_users SET last_login_at=? WHERE id=?').run(Date.now(), current.id);
recordAudit(current.id, 'login', current.id);
return { user: getUser(current.id)!, session: createSession(current.id) };
}).immediate();
if (!result) throw new AuthError('Invalid username or password.', 401);
return result;
}
export function createSession(userId: string): { token: string; expiresAt: number } {
const now = Date.now(); const user = userRow(userId);
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
const token = randomBytes(32).toString('base64url'); const expiresAt = now + SESSION_LIFETIME_MS;
database().prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?').run(now,now-SESSION_IDLE_MS);
database().prepare('INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)').run(digest(token),userId,now,expiresAt,now);
return { token, expiresAt };
}
export function readSession(token: string | undefined, touch = true): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return readSessionHash(digest(token), touch);
}
export function readSessionHash(tokenHash: string, touch = false): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
const now = Date.now();
const row = database().prepare(`SELECT u.*,s.expires_at,s.last_seen_at 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`).get(tokenHash,now,now-SESSION_IDLE_MS) as (UserRow & { expires_at: number; last_seen_at: number }) | undefined;
if (!row) return null;
if (touch && row.last_seen_at < now-60_000) database().prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?').run(now,tokenHash);
return { user: present(row), tokenHash, expiresAt: row.expires_at };
}
export function revokeSession(token: string | undefined): void { if (token) database().prepare('DELETE FROM auth_sessions WHERE token_hash=?').run(digest(token)); }
export function revokeUserSessions(actorId: string, userId: string): void {
database().transaction(() => {
requireAdministrator(actorId);
if (!userRow(userId)) throw new AuthError('User not found.',404);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
recordAudit(actorId,'sessions.revoked',userId);
})();
}
function requireAdministrator(actorId: string) {
const actor = userRow(actorId);
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password) throw new AuthError('Administrator access is required.',403);
}
export async function createUser(actorId: string, input: Record<string, unknown>): Promise<AuthUser> {
requireAdministrator(actorId);
const name = username(input.username), label = displayName(input.displayName,name), userRole = role(input.role);
const passwordHash = await hashPassword(input.password);
return database().transaction(() => {
requireAdministrator(actorId);
const user = insertUser({ username:name,displayName:label,role:userRole,passwordHash,mustChangePassword:true });
recordAudit(actorId,'user.created',user.id,{role:userRole}); return user;
}).immediate();
}
export async function updateUser(actorId: string, id: string, input: Record<string, unknown>): Promise<AuthUser> {
requireAdministrator(actorId);
if (input.disabled !== undefined && typeof input.disabled !== 'boolean') throw new AuthError('Disabled must be true or false.');
if (input.password !== undefined && actorId === id) throw new AuthError('Change your own password from Account using your current password.');
const passwordHash = input.password === undefined ? null : await hashPassword(input.password);
return database().transaction(() => {
requireAdministrator(actorId);
const current = userRow(id); if (!current) throw new AuthError('User not found.',404);
const nextRole = input.role === undefined ? current.role : role(input.role);
const disabled = input.disabled === undefined ? current.disabled : Number(input.disabled);
const label = displayName(input.displayName,current.display_name);
if (current.role === 'admin' && !current.disabled && (nextRole !== 'admin' || disabled)) {
const other = database().prepare("SELECT 1 FROM auth_users WHERE role='admin' AND disabled=0 AND id!=? LIMIT 1").get(id);
if (!other) throw new AuthError('Keep at least one enabled administrator.',409);
}
database().prepare(`UPDATE auth_users SET display_name=?,role=?,disabled=?,password_hash=?,must_change_password=?,updated_at=? WHERE id=?`)
.run(label,nextRole,disabled,passwordHash??current.password_hash,passwordHash?1:current.must_change_password,Date.now(),id);
if (passwordHash || disabled !== current.disabled || nextRole !== current.role) database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id);
recordAudit(actorId,'user.updated',id,{role:nextRole,disabled:!!disabled,passwordReset:!!passwordHash});
return getUser(id)!;
}).immediate();
}
export async function changePassword(userId: string, currentPassword: unknown, newPassword: unknown): Promise<AuthResult> {
const original = userRow(userId);
if (!original || original.disabled || !await verifyPassword(currentPassword,original.password_hash)) throw new AuthError('Current password is incorrect.',400);
if (currentPassword === newPassword) throw new AuthError('Choose a different password.');
const passwordHash = await hashPassword(newPassword);
return database().transaction(() => {
const current = userRow(userId);
if (!current || current.disabled || current.password_hash !== original.password_hash) throw new AuthError('Account changed during password verification. Sign in again.',409);
database().prepare('UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=?').run(passwordHash,Date.now(),userId);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
recordAudit(userId,'password.changed',userId);
return { user: getUser(userId)!, session: createSession(userId) };
}).immediate();
}
+45
View File
@@ -0,0 +1,45 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
// OWASP's scrypt baseline: 128 MiB, r=8, p=1. Bound concurrent work so
// unauthenticated password attempts cannot allocate unlimited memory.
const parameters = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
let active = 0;
/** @param {unknown} password */
export function validatePassword(password) {
if (typeof password !== 'string' || [...password].length < 15 || [...password].length > 128) {
throw new Error('Use a password or passphrase between 15 and 128 characters.');
}
return password;
}
/** @param {string} password @param {string} salt @returns {Promise<Buffer>} */
function derive(password, salt) {
if (active >= 2) return Promise.reject(new Error('Password service is busy. Try again shortly.'));
active++;
return new Promise((resolve, reject) => {
scrypt(password, salt, 64, parameters, (error, result) => {
active--;
if (error) reject(error); else resolve(result);
});
});
}
/** @param {unknown} password */
export async function hashPassword(password) {
const validated = validatePassword(password);
const salt = randomBytes(32).toString('hex');
const key = await derive(validated, salt);
return `scrypt$131072$8$1$${salt}$${key.toString('hex')}`;
}
/** @param {unknown} password @param {string | null} encoded */
export async function verifyPassword(password, encoded) {
if (typeof password !== 'string' || [...password].length > 128) return false;
const parts = encoded?.split('$');
const valid = parts?.length === 6 && parts[0] === 'scrypt' && parts[1] === '131072' && parts[2] === '8' && parts[3] === '1'
&& /^[a-f0-9]{64}$/.test(parts[4]) && /^[a-f0-9]{128}$/.test(parts[5]);
// Missing accounts perform the same expensive operation as real accounts.
const result = await derive(password, valid ? parts[4] : '0'.repeat(64));
return timingSafeEqual(result, Buffer.from(valid ? parts[5] : '0'.repeat(128), 'hex')) && !!valid;
}
+27
View File
@@ -0,0 +1,27 @@
import type Database from 'better-sqlite3';
const initialized = new WeakSet<Database.Database>();
export function ensureAuthSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
handle.exec(`
CREATE TABLE IF NOT EXISTS auth_users (
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','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
);
CREATE TABLE IF NOT EXISTS auth_sessions (
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, last_seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS auth_sessions_user ON auth_sessions(user_id);
CREATE TABLE IF NOT EXISTS auth_rate_limits (
key TEXT PRIMARY KEY, window_start INTEGER NOT NULL, attempts INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT, action TEXT NOT NULL,
target_id TEXT, details TEXT NOT NULL, created_at INTEGER NOT NULL
);
`);
initialized.add(handle);
}
+34
View File
@@ -0,0 +1,34 @@
import { createHash, timingSafeEqual } from 'node:crypto';
export type TelemetryAuthentication =
| { ok: true }
| { ok: false; status: 401 | 503; error: string };
const tokenPattern = /^[A-Za-z0-9._~+\/-]+=*$/;
const validToken = (value: string): boolean => value.length >= 32 && value.length <= 512 && tokenPattern.test(value);
const hash = (value: string): Buffer => createHash('sha256').update(value, 'utf8').digest();
/** Machine telemetry has its own credential; browser sessions never authorize it. */
export function authenticateTelemetry(request: Request): TelemetryAuthentication {
const expected = process.env.EC_TELEMETRY_TOKEN ?? '';
if (!validToken(expected)) {
return { ok: false, status: 503, error: 'Game telemetry authentication is not configured. Set EC_TELEMETRY_TOKEN to a secret of at least 32 characters.' };
}
const authorization = request.headers.get('authorization');
const custom = request.headers.get('x-ec-telemetry-token');
const bearer = authorization?.match(/^Bearer ([A-Za-z0-9._~+\/-]+=*)$/i)?.[1];
const denied = (): TelemetryAuthentication => ({ ok: false, status: 401, error: 'A valid game telemetry token is required.' });
// Reject malformed/ambiguous credentials rather than falling back to a
// second header. A shared secret must never come from a URL, cookie or body.
if (authorization !== null && !bearer) return denied();
if (custom !== null && !validToken(custom)) return denied();
if (bearer !== undefined && !validToken(bearer)) return denied();
const supplied = bearer ?? custom;
if (!supplied) return denied();
const suppliedHash = hash(supplied);
if (bearer !== undefined && custom !== null && !timingSafeEqual(suppliedHash, hash(custom))) return denied();
if (!timingSafeEqual(suppliedHash, hash(expected))) return denied();
return { ok: true };
}
+50
View File
@@ -0,0 +1,50 @@
import * as fs from 'node:fs/promises';
import { relative, resolve, sep } from 'node:path';
import { config } from '../config.js';
import { nativeInstance, nativeRequest } from './native.js';
function remote(path: string) {
for (const root of [config.serversRoot, config.t6ServersRoot]) {
if (!root) continue;
const rel = relative(resolve(root), resolve(path));
if (!rel || rel.startsWith('..') || rel.startsWith(sep)) continue;
const [name, ...parts] = rel.split(sep);
const instance = nativeInstance(name);
if (instance) return { instance, path: parts.join('/') || '.' };
}
return null;
}
function missing(path: string): never { throw Object.assign(new Error(`File does not exist: ${path}`), { code: 'ENOENT' }); }
export async function readFile(path: string): Promise<Buffer> {
const target = remote(path);
if (!target) return fs.readFile(path);
const result = await nativeRequest<{ contentBase64: string | null }>(target.instance, 'files.read', { path: target.path });
return result.contentBase64 === null ? missing(path) : Buffer.from(result.contentBase64, 'base64');
}
export async function exists(path: string): Promise<boolean> {
try { await stat(path); return true; }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
}
export async function stat(path: string): Promise<{ size: number; mtime: Date; isDirectory(): boolean; isFile(): boolean }> {
const target = remote(path);
if (!target) return fs.stat(path);
const result = await nativeRequest<{ exists?: boolean; size: number; mtime: string | number; isDirectory: boolean; isFile: boolean } | null>(target.instance, 'files.stat', { path: target.path });
if (result === null || result.exists === false) return missing(path);
return { size: result.size, mtime: new Date(typeof result.mtime === 'number' ? result.mtime * 1000 : result.mtime), isDirectory: () => result.isDirectory, isFile: () => result.isFile };
}
export async function readdir(path: string, options: { withFileTypes: true }): Promise<{ name: string; isDirectory(): boolean; isFile(): boolean }[]> {
const target = remote(path);
if (!target) return fs.readdir(path, options);
const result = await nativeRequest<{ entries: { name: string; isDirectory: boolean; isFile?: boolean }[] }>(target.instance, 'files.list', { path: target.path });
return result.entries.map(entry => ({ name: entry.name, isDirectory: () => entry.isDirectory, isFile: () => entry.isFile ?? !entry.isDirectory }));
}
export async function nativeTail(path: string, lines: number) {
const target = remote(path);
if (!target) return undefined;
return nativeRequest<import('../fleet/logs.js').TailResult | null>(target.instance, 'logs.read', { path: target.path, lines });
}
+41
View File
@@ -0,0 +1,41 @@
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { getHost, queueHostOperation, waitHostOperation } from '../hosts/index.js';
export interface NativeInstance {
id: string; server: string; host_id: string; generation: number; library_id: string;
image: string; bind_address: string; port: number; public_address: string | null;
public_port: number | null; created_at: string; retired_at: string | null;
}
export function nativeInstance(server: string): NativeInstance | null {
return db().prepare('SELECT * FROM native_instances WHERE server = ? AND retired_at IS NULL').get(server) as NativeInstance | undefined ?? null;
}
export function nativeDeployment(id: string): boolean {
return !!db().prepare('SELECT 1 FROM native_provisioning WHERE deployment_id = ?').get(id);
}
/** Only the agent receives host paths, Docker access, or game credentials. */
export async function nativeRequest<T = Record<string, unknown>>(instance: NativeInstance, kind: string, payload: Record<string, unknown> = {}, key?: string): Promise<T> {
const host = getHost(instance.host_id);
if (!host || host.connectionState !== 'online') throw new Error('Host agent is offline; instance state is unknown.');
const operation = queueHostOperation(instance.host_id, kind, payload, {
instanceId: instance.id, generation: instance.generation, idempotencyKey: key ?? randomUUID()
});
const result = await waitHostOperation(operation.id, 25_000);
if (result.state !== 'completed') throw new Error(result.error || `Agent operation ${result.id} is ${result.state}; its original ID is retained.`);
// Do not apply replies from a retired generation to a replacement server.
const current = nativeInstance(instance.server);
if (current?.id !== instance.id || current.generation !== instance.generation) throw new Error('Instance was replaced during the operation.');
return result.result as T;
}
export async function nativeHealth(server: string): Promise<{ ok: true; state: string } | { ok: false; reason: string; state?: string }> {
const instance = nativeInstance(server);
if (!instance) return { ok: false, reason: 'Native instance is not registered' };
try {
const result = await nativeRequest<{ state: string; healthy: boolean }>(instance, 'instance.inspect');
return result.healthy ? { ok: true, state: result.state } : { ok: false, state: result.state, reason: `Container ${result.state}` };
} catch (error) { return { ok: false, state: 'unknown', reason: (error as Error).message }; }
}
+20
View File
@@ -0,0 +1,20 @@
import type Database from 'better-sqlite3';
export function migrateNativeInstances(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS native_instances (
id TEXT PRIMARY KEY, server TEXT NOT NULL, host_id TEXT NOT NULL,
generation INTEGER NOT NULL DEFAULT 1, library_id TEXT NOT NULL,
image TEXT NOT NULL, bind_address TEXT NOT NULL DEFAULT '0.0.0.0',
port INTEGER NOT NULL, public_address TEXT, public_port INTEGER,
created_at TEXT NOT NULL, retired_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS native_instance_live_name ON native_instances(server) WHERE retired_at IS NULL;
CREATE TABLE IF NOT EXISTS native_provisioning (
deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id),
instance_id TEXT NOT NULL REFERENCES native_instances(id), generation INTEGER NOT NULL,
stage_operation_id TEXT, operation_id TEXT, files_json TEXT,
applied_at TEXT, verified_at TEXT
);
`);
}
+2
View File
@@ -8,6 +8,7 @@ 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';
import { migrateNativeInstances } from '../backends/schema.js';
let _db: Database.Database | null = null;
@@ -366,6 +367,7 @@ export function db(): Database.Database {
]);
backfillLeaderboard(handle);
initializeFleetSettings(handle);
migrateNativeInstances(handle);
} catch (err) {
handle.close();
throw err;
+19 -7
View File
@@ -10,8 +10,10 @@ async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
try { return await action(); } finally { operations.delete(id); }
}
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { readFile, exists } from '../backends/files.js';
import { getHostOperation } from '../hosts/index.js';
import { join, posix } from 'node:path';
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
import { db } from '../db/index.js';
@@ -204,6 +206,14 @@ export class BetaError extends Error {
}
}
function assertNativeActivationSettled(betaId: number): void {
const pending = db().prepare(`SELECT n.operation_id FROM native_provisioning n
JOIN provisioning_sources s ON s.operation_id=n.deployment_id
WHERE s.kind='beta' AND s.source_id=? AND n.operation_id IS NOT NULL
AND n.files_json IS NOT NULL AND n.applied_at IS NULL`).all(String(betaId)) as { operation_id: string }[];
if (pending.some(row => getHostOperation(row.operation_id)?.state !== 'failed')) throw new BetaError('The host is still reconciling file activation. Wait for its durable before images before promoting or rolling back.', 'conflict');
}
function nowIso(): string {
return new Date().toISOString();
}
@@ -563,6 +573,7 @@ export async function rollbackBeta(betaId: number, options: { restart?: 'manual'
return exclusive(betaId, () => rollbackBetaInternal(betaId, options));
}
async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual' | 'when-empty' }): Promise<RollbackResult> {
assertNativeActivationSettled(betaId);
const beta = getBeta(betaId);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
@@ -658,7 +669,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
// Wipe archive + close out beta.
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
if (await exists(archive)) await rm(archive, { recursive: true, force: true });
// Per-version test-failed transition. Skip a version if (a) another beta
// still gates it at ready-promote/promoted, or (b) it's the currently-
@@ -764,6 +775,7 @@ export async function promoteBeta(betaId: number, reviewedToken?: string): Promi
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
}
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
assertNativeActivationSettled(betaId);
const test = storedPlan(betaId);
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
const beta = getBeta(betaId);
@@ -842,7 +854,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
// Non-beta matching-scope server: current on-disk file (if any)
// is the "before" state for a promote-then-rollback round trip.
const targetPath = join(dest, script.target_rel_path);
if (existsSync(targetPath)) {
if (await exists(targetPath)) {
const priorContent = await readFile(targetPath);
const inv = getServerScript(name, script.target_rel_path);
plan = {
@@ -906,13 +918,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
hadFile = true;
}
}
if (!betaServerSet.has(name) && existsSync(targetPath)) {
if (!betaServerSet.has(name) && await exists(targetPath)) {
// Capture an initial preview; activation records the actual before image.
priorContent = await readFile(targetPath);
priorSha = sha256Buffer(priorContent);
hadFile = true;
}
const requiresActivation = !betaServerSet.has(name) || !existsSync(targetPath)
const requiresActivation = !betaServerSet.has(name) || !await exists(targetPath)
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
}
@@ -1038,7 +1050,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
if (await exists(archive)) await rm(archive, { recursive: true, force: true });
const landed = Array.from(new Set([
...plans.map((p) => p.server),
+23 -7
View File
@@ -11,6 +11,8 @@ import { getServerKey } from './server-keys.js';
import { serverObservation } from './status.js';
import { activeBetaRemovalBlocker, getServer } from './servers.js';
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
import { nativeSelection, nextNativePort } from '../provisioning/native.js';
import { nativeInstance } from '../backends/native.js';
function coverage(entries: BlueprintEntry[]) {
type Server = { name: string; game: string | null; map_slug: string | null; map: string | null };
@@ -76,18 +78,25 @@ function allocate(entries: BlueprintEntry[], releasingServers: string[] = []): C
const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`;
const lobby = nextLobbyNumber(base, names);
const name = lobby === null ? null : `${base}_${lobby}`;
const port = nextAvailablePort(ports, releasingServers);
const port = entry.recipe.native ? nextNativePort(entry.recipe.native, ports) : nextAvailablePort(ports, releasingServers);
if (name) names.push(name);
if (port !== null) ports.push(port);
return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) };
}));
}
function blockersFor(servers: CollectionPreview['servers']): string[] {
function blockersFor(servers: CollectionPreview['servers'], entries: BlueprintEntry[] = []): string[] {
const blockers: string[] = [];
if (!servers.length) return blockers;
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.');
if (entries.some(entry => entry.recipe.native)) {
for (const entry of entries) {
try { nativeSelection(entry.recipe.native as unknown as Record<string, unknown>, entry.recipe.game); }
catch (error) { blockers.push((error as Error).message); }
}
} else {
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Select and prepare a host before launching.');
if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.');
}
if (servers.some(server => !server.name)) blockers.push('There are not enough free lobbies for one or more maps (20 per map). Reduce their counts or free capacity.');
if (servers.some(server => server.port === null)) blockers.push(`There are not enough free game ports in ${config.portRange.min}–${config.portRange.max} for the whole collection.`);
return blockers;
@@ -95,7 +104,7 @@ function blockersFor(servers: CollectionPreview['servers']): string[] {
function removalBlockersFor(removals: CollectionPreview['removals']): string[] {
const blockers: string[] = [];
if (removals.length && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure Komodo and RCON before removing servers.');
if (removals.some(server => !nativeInstance(server.name)) && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure the legacy backend before removing its servers.');
for (const server of removals) {
const blocker = activeBetaRemovalBlocker(server.name);
if (blocker) blockers.push(blocker);
@@ -103,14 +112,21 @@ function removalBlockersFor(removals: CollectionPreview['removals']): string[] {
return blockers;
}
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'): CollectionPreview {
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill', selection?: Record<string, unknown>): CollectionPreview {
if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a collection to launch.');
if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact collection mode.');
return db().transaction((): CollectionPreview => {
const { entries, blueprintName } = blueprintEntries(blueprintId);
if (selection?.hostId !== undefined || !config.komodo.address) {
const libraries = selection?.libraryIds as Record<string, unknown> | undefined;
for (const entry of entries) {
entry.recipe.native = nativeSelection({ ...selection, libraryId: libraries?.[entry.recipe.game] ?? selection?.libraryId }, entry.recipe.game);
entry.recipe.image = entry.recipe.native.image!;
}
}
const { counts, retainedServers, removals } = collectionPlan(entries, mode);
const servers = allocate(entries, removals.map(server => server.name));
const blockers = [...blockersFor(servers), ...removalBlockersFor(removals)];
const blockers = [...blockersFor(servers, entries), ...removalBlockersFor(removals)];
const id = randomUUID();
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString());
@@ -141,7 +157,7 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
const { retainedServers, removals } = collectionPlan(entries, preview.mode);
const releasingServers = removals.map(server => server.name);
const servers = allocate(entries, releasingServers);
const blockers = [...blockersFor(servers), ...removalBlockersFor(removals)];
const blockers = [...blockersFor(servers, entries), ...removalBlockersFor(removals)];
if (blockers.length) throw new Error(blockers.join(' '));
if (servers.length !== reviewed.length || servers.some((server, i) => server.entryIndex !== reviewed[i]?.entryIndex || server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) {
throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
+1 -1
View File
@@ -1,4 +1,4 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { readdir, readFile, stat } from '../backends/files.js';
import { randomUUID } from 'node:crypto';
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
import { join, posix, relative, sep } from 'node:path';
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { readFile, stat } from '../backends/files.js';
export async function sha256(path: string): Promise<string> {
const buf = await readFile(path);
+1
View File
@@ -9,6 +9,7 @@ import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from '.
import { sha256Buffer } from './files.js';
export interface LaunchRecipe {
native?: { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number };
format: 1;
game: ZombieGame;
mapSlug: string;
+3
View File
@@ -1,6 +1,7 @@
import { open, stat } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { rootOfServer, gameOfServer } from '../config.js';
import { nativeTail } from '../backends/files.js';
// Whitelisted log files exposed to the UI, mapped to their path relative
// to each server's root directory. Adding a new supported log = one entry
@@ -63,6 +64,8 @@ export async function tailLog(
path: string,
lines: number = DEFAULT_TAIL_LINES
): Promise<TailResult | null> {
const remote = await nativeTail(path, lines);
if (remote !== undefined) return remote;
let st;
try {
st = await stat(path);
+14 -2
View File
@@ -3,6 +3,8 @@ import { existsSync } from 'node:fs';
import { config, destOf, serverDir, rootOfServer } from '../config.js';
import { db } from '../db/index.js';
import { parseServerName } from './map-aliases.js';
import { nativeInstance } from '../backends/native.js';
import { stat as managedStat } from '../backends/files.js';
/**
* All server folders under the game roots. Used by seedServerRegistry to backfill
@@ -46,6 +48,10 @@ export async function ensureDest(
create: boolean
): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
const dest = destOf(server);
if (nativeInstance(server)) {
try { return (await managedStat(dest)).isDirectory() ? { ok: true, path: dest } : { ok: false, reason: 'Agent script directory is unavailable' }; }
catch (error) { return { ok: false, reason: (error as Error).message }; }
}
if (existsSync(dest)) {
try {
const s = await stat(dest);
@@ -63,6 +69,10 @@ export async function ensureDest(
// -- server registry --
export interface ServerRow {
native_instance_id?: string | null;
host_id?: string | null;
instance_generation?: number | null;
game_library_id?: string | null;
name: string;
komodo_type: 'deployment' | 'stack' | 'container';
komodo_name: string;
@@ -146,11 +156,13 @@ export async function seedServerRegistry(): Promise<number> {
}
export function getServer(name: string): ServerRow | undefined {
return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined;
return db().prepare(`SELECT s.*, n.id AS native_instance_id, n.host_id, n.generation AS instance_generation, n.library_id AS game_library_id
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL WHERE s.name=?`).get(name) as ServerRow | undefined;
}
export function listServers(): ServerRow[] {
return db().prepare('SELECT * FROM servers WHERE destroyed_at IS NULL ORDER BY name').all() as ServerRow[];
return db().prepare(`SELECT s.*, n.id AS native_instance_id, n.host_id, n.generation AS instance_generation, n.library_id AS game_library_id
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL WHERE s.destroyed_at IS NULL ORDER BY s.name`).all() as ServerRow[];
}
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
+44
View File
@@ -0,0 +1,44 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { json } from '@sveltejs/kit';
import { getLibraryFile } from './index.js';
import { assertRegularPath, parseByteRange } from './storage.js';
export function gameFileError(cause: unknown): Response {
const status = typeof cause === 'object' && cause !== null && 'status' in cause && typeof cause.status === 'number' ? cause.status : 400;
return json({ error: cause instanceof Error ? cause.message : 'Game file request failed.' }, { status });
}
export async function readGameJson(request: Request, maxBytes = 64 * 1024): Promise<Record<string, unknown>> {
if (!request.body) throw new Error('A JSON request body is required.');
const reader = request.body.getReader();
const chunks: Uint8Array[] = []; let bytes = 0;
try {
while (true) {
const next = await reader.read(); if (next.done) break;
bytes += next.value.byteLength;
if (bytes > maxBytes) { await reader.cancel(); throw new Error('Request body exceeds its size limit.'); }
chunks.push(next.value);
}
} finally { reader.releaseLock(); }
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Expected a JSON object.');
return body;
}
export async function serveGameFile(request: Request, versionId: string, requestedPath: string): Promise<Response> {
const file = getLibraryFile(versionId, requestedPath);
if (!file || file.state !== 'ready') return json({ error: 'File is not in a published manifest.' }, { status: 404 });
const path = await assertRegularPath(file.storageLocation, file.path);
if ((await stat(path)).size !== file.size) return json({ error: 'Stored file failed size verification.' }, { status: 503 });
const etag = `"${file.sha256}"`;
const headers = new Headers({ 'Content-Type': 'application/octet-stream', 'Accept-Ranges': 'bytes', ETag: etag, 'Cache-Control': 'private, no-cache', 'X-Content-SHA256': file.sha256 });
if (request.headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers });
let range;
try { range = parseByteRange(request.headers.has('if-range') && request.headers.get('if-range') !== etag ? null : request.headers.get('range'), file.size); }
catch { headers.set('Content-Range', `bytes */${file.size}`); return new Response(null, { status: 416, headers }); }
headers.set('Content-Length', String(range ? range.end - range.start + 1 : file.size));
if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`);
const body = request.method === 'HEAD' ? null : Readable.toWeb(createReadStream(path, range ?? undefined)) as ReadableStream<Uint8Array>;
return new Response(body, { status: range ? 206 : 200, headers });
}
+256
View File
@@ -0,0 +1,256 @@
import { randomUUID } from 'node:crypto';
import { mkdir, rename, stat } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { db } from '../db/index.js';
import { config } from '../config.js';
import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hosts/index.js';
import { ensureGameFileSchema } from './schema.js';
import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js';
import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js';
function database() { const handle = db(); ensureGameFileSchema(handle); return handle; }
export const gameFilesRoot = () => resolve(process.env.GAME_FILES_ROOT ?? join(dirname(config.dbPath), 'game-files'));
const stagingRoot = (id: string) => join(gameFilesRoot(), '.imports', id);
type VersionRow = { id: string; library_id: string; game: GameLibraryGame; version: string; description: string; dlc: string; state: GameLibraryVersion['state']; size_bytes: number; manifest: string; manifest_sha256: string | null; created_at: string; published_at: string | null; storage_location: string; source_host_id: string; source_volume: string; error: string | null; operation_id: string | null };
type CacheRow = { host_id: string; version_id: string; status: LibraryCacheState; bytes_downloaded: number; bytes_total: number; manifest_sha256: string | null; error: string | null; updated_at: string; operation_id: string | null };
const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id, libraryVersionId: row.version_id, status: row.status, bytesDownloaded: row.bytes_downloaded, bytesTotal: row.bytes_total, manifestSha256: row.manifest_sha256, error: row.error, updatedAt: row.updated_at, operationId: row.operation_id });
function versionFromRow(row: VersionRow): GameLibraryVersion {
const files = JSON.parse(row.manifest) as GameFileEntry[];
return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version,
description: row.description, dlc: JSON.parse(row.dlc), state: row.state, sizeBytes: row.size_bytes,
files, fileCount: files.length, manifestSha256: row.manifest_sha256, createdAt: row.created_at,
publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id,
sourceVolume: row.source_volume, error: row.error, operationId: row.operation_id,
caches: (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) };
}
export function getLibraryVersion(id: string): GameLibraryVersion | null {
const row = database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE v.id=?').get(id) as VersionRow | undefined;
return row ? versionFromRow(row) : null;
}
export function getLibraryFile(versionId: string, path: string) {
validateGamePath(path);
return database().prepare(`SELECT f.path,f.size,f.sha256,v.state,v.source_host_id AS sourceHostId,v.storage_location AS storageLocation
FROM game_file_entries f JOIN game_file_versions v ON v.id=f.version_id WHERE f.version_id=? AND f.path=?`)
.get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null;
}
export function newestReadyLibrary(game: string): GameLibraryVersion | null {
const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined;
return row ? versionFromRow(row) : null;
}
export function listLibraries(): GameLibrary[] {
reconcileLibraryOperations();
return (database().prepare('SELECT * FROM game_file_libraries ORDER BY name').all() as Array<{ id: string; name: string; game: GameLibraryGame; description: string; created_at: string }>).map(row => ({
id: row.id, name: row.name, game: row.game, description: row.description, createdAt: row.created_at,
versions: (database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.id=? ORDER BY v.created_at DESC, v.rowid DESC').all(row.id) as VersionRow[]).map(versionFromRow)
}));
}
export function getHostLibraryCache(hostId: string, versionId: string): HostLibraryCache | null {
const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined;
return row ? cacheFromRow(row) : null;
}
export function requireReadyLibrary(hostId: string, versionId: string): GameLibraryVersion {
const version = getLibraryVersion(versionId);
if (!version || version.state !== 'ready') throw new Error('Select a published game-file version.');
const cache = getHostLibraryCache(hostId, versionId);
if (!cache || !['ready', 'outdated'].includes(cache.status) || cache.manifestSha256 !== version.manifestSha256) throw new Error('The selected host must finish downloading and verifying this game-file version.');
return version;
}
function textField(input: unknown, label: string, max: number, required = true): string {
if (typeof input !== 'string' || input.length > max || (required && !input.trim())) throw new Error(`Provide a valid ${label}.`);
return input.trim();
}
export function createLibraryImport(input: Record<string, unknown>): GameLibraryVersion {
const game = input.game;
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
const hostId = textField(input.hostId, 'source host', 128);
const host = getHost(hostId);
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.');
const name = textField(input.name, 'library name', 160);
const description = textField(input.description ?? '', 'description', 4000, false);
const version = textField(input.version, 'version label', 120);
const dlcInput = input.dlc ?? [];
if (!Array.isArray(dlcInput) || dlcInput.length > 64) throw new Error('DLC must be a list of content names.');
const dlc = dlcInput.map(value => textField(value, 'DLC name', 120));
const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files';
const handle = database();
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined;
if (library && library.game !== game) throw new Error('This library name is already used for another game.');
const id = randomUUID(); const now = new Date().toISOString();
handle.transaction(() => {
if (!library) {
library = { id: randomUUID(), game };
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now);
}
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume)
VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, JSON.stringify(dlc), now, join(gameFilesRoot(), game, id), hostId, sourceVolume);
})();
try {
const operation = queueHostOperation(hostId, 'library.import', {
importId: id, game, sourceVolume, manifestUrl: `/api/agent/imports/${id}`,
uploadBaseUrl: `/api/agent/imports/${id}/files`, completeUrl: `/api/agent/imports/${id}/complete`
}, { idempotencyKey: `library-import:${id}` });
handle.prepare('UPDATE game_file_versions SET operation_id=? WHERE id=?').run(operation.id, id);
} catch (cause) {
handle.prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=?").run((cause as Error).message, id);
throw cause;
}
return getLibraryVersion(id)!;
}
export function requireImportOwner(hostId: string, id: string): GameLibraryVersion {
const version = getLibraryVersion(id);
if (!version || version.sourceHostId !== hostId) throw new Error('Import not found for this host.');
return version;
}
export function setImportManifest(hostId: string, id: string, input: unknown): GameLibraryVersion {
const version = requireImportOwner(hostId, id);
const files = validateManifest(input); const checksum = manifestChecksum(files);
if (version.manifestSha256) {
if (version.manifestSha256 !== checksum) throw new Error('An import manifest cannot change. Start a new version.');
return version;
}
if (version.state !== 'importing') throw new Error('This import is not writable.');
database().transaction(() => {
database().prepare('UPDATE game_file_versions SET manifest=?,manifest_sha256=?,size_bytes=? WHERE id=? AND state=\'importing\'').run(JSON.stringify(files), checksum, files.reduce((sum, file) => sum + file.size, 0), id);
const insert = database().prepare('INSERT INTO game_file_entries(version_id,path,size,sha256) VALUES(?,?,?,?)');
for (const file of files) insert.run(id, file.path, file.size, file.sha256);
})();
return getLibraryVersion(id)!;
}
export async function uploadImportFile(hostId: string, id: string, path: string, body: ReadableStream<Uint8Array> | null): Promise<void> {
const file = getLibraryFile(id, path);
if (!file || file.sourceHostId !== hostId) throw new Error('File is not listed in a manifest belonging to this host.');
if (file.state !== 'importing' || publishing.has(id)) throw new Error('This import is not writable.');
await receiveGameFile(stagingRoot(id), file, body);
}
const publishing = new Map<string, Promise<GameLibraryVersion>>();
export async function publishImport(hostId: string, id: string): Promise<GameLibraryVersion> {
const version = requireImportOwner(hostId, id);
if (version.state === 'ready') return version;
if (version.state !== 'importing' || !version.files.length || !version.manifestSha256) throw new Error('Import needs a complete manifest before publication.');
const existing = publishing.get(id); if (existing) return existing;
const task = (async () => {
let root = stagingRoot(id);
// A restart between the atomic directory rename and metadata commit is recoverable.
try { if ((await stat(version.storageLocation)).isDirectory()) root = version.storageLocation; }
catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause; }
for (const file of version.files) await verifyGameFile(root, file);
if (root !== version.storageLocation) {
await mkdir(dirname(version.storageLocation), { recursive: true });
await rename(root, version.storageLocation);
}
database().prepare("UPDATE game_file_versions SET state='ready',published_at=?,error=NULL WHERE id=? AND state='importing'").run(new Date().toISOString(), id);
return getLibraryVersion(id)!;
})();
publishing.set(id, task);
try { return await task; } finally { publishing.delete(id); }
}
/** Large libraries can take minutes to verify. Keep HTTP requests bounded while the source host polls. */
export function importPublicationStatus(hostId: string, id: string) {
const version = requireImportOwner(hostId, id);
return {
status: version.state === 'ready' ? 'published' as const : version.state === 'failed' ? 'failed' as const : publishing.has(id) ? 'verifying' as const : 'pending' as const,
version: { id: version.id, game: version.game, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 },
error: version.error
};
}
export function requestImportPublication(hostId: string, id: string) {
const version = requireImportOwner(hostId, id);
if (version.state === 'importing' && !publishing.has(id)) {
if (!version.files.length || !version.manifestSha256) throw new Error('Import requires a manifest before publication.');
void publishImport(hostId, id).catch(cause => {
database().prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=? AND state='importing'")
.run((cause instanceof Error ? cause.message : 'Library publication failed.').slice(0, 2000), id);
});
}
return importPublicationStatus(hostId, id);
}
export function updateHostLibraryCaches(hostId: string, input: unknown, options: { snapshot?: boolean } = {}): void {
if (!Array.isArray(input) || input.length > 2000) throw new Error('Invalid host library report.');
const handle = database();
const now = new Date().toISOString();
handle.transaction(() => {
const reported = new Set<string>();
for (const raw of input) {
if (!raw || typeof raw !== 'object') throw new Error('Invalid library cache report.');
const id = raw.libraryVersionId ?? raw.id;
if (typeof id !== 'string') throw new Error('Invalid library version ID.');
if (reported.has(id)) throw new Error('Duplicate version in host cache report.');
reported.add(id);
const version = getLibraryVersion(id); if (!version || version.state !== 'ready') continue;
let status = raw.status ?? raw.state;
if (!['missing', 'downloading', 'verifying', 'ready', 'failed', 'outdated'].includes(status)) throw new Error('Invalid library cache state.');
const checksum = typeof raw.manifestSha256 === 'string' ? raw.manifestSha256 : null;
let error = typeof raw.error === 'string' ? raw.error.slice(0, 2000) : null;
if (['ready', 'outdated'].includes(status) && checksum !== version.manifestSha256) { status = 'failed'; error = 'Host manifest checksum does not match the published version.'; }
const bytes = raw.bytesDownloaded ?? (['ready', 'outdated'].includes(status) ? version.sizeBytes : 0);
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > version.sizeBytes) throw new Error('Invalid library download progress.');
if (['ready', 'outdated'].includes(status) && bytes !== version.sizeBytes) { status = 'failed'; error = 'Host reported an incomplete library as verified.'; }
handle.prepare(`INSERT INTO host_library_caches(host_id,version_id,status,bytes_downloaded,bytes_total,manifest_sha256,error,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(host_id,version_id) DO UPDATE SET status=excluded.status,
bytes_downloaded=excluded.bytes_downloaded,bytes_total=excluded.bytes_total,manifest_sha256=excluded.manifest_sha256,error=excluded.error,updated_at=excluded.updated_at`)
.run(hostId, id, status, bytes, version.sizeBytes, checksum, error, now);
}
if (options.snapshot) {
const cached = handle.prepare('SELECT version_id FROM host_library_caches WHERE host_id=?').all(hostId) as { version_id: string }[];
for (const cache of cached) if (!reported.has(cache.version_id)) {
handle.prepare("UPDATE host_library_caches SET status='missing',bytes_downloaded=0,manifest_sha256=NULL,error=NULL,updated_at=? WHERE host_id=? AND version_id=?")
.run(now, hostId, cache.version_id);
}
}
})();
}
export function queueLibraryDownload(hostId: string, versionId: string) {
const version = getLibraryVersion(versionId);
if (!version || version.state !== 'ready') throw new Error('Choose a published library version.');
const host = getHost(hostId);
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled host.');
if (Number(host.inventory.storageAvailable ?? 0) < version.sizeBytes + host.minimumFreeBytes) throw new Error('This host has insufficient storage for this library and its minimum free-space reserve.');
const prior = getHostLibraryCache(hostId, versionId);
if (prior?.operationId) {
const operation = getHostOperation(prior.operationId);
if (operation && ['pending', 'accepted', 'running', 'unknown'].includes(operation.state)) return operation;
}
const operation = queueHostOperation(hostId, 'library.download', {
libraryVersionId: versionId, game: version.game, manifestUrl: `/api/agent/libraries/${versionId}/manifest`,
filesBaseUrl: `/api/agent/libraries/${versionId}/files`, statusUrl: `/api/agent/libraries/${versionId}/status`,
manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes
});
updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: 0 }]);
database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId);
return operation;
}
export function reconcileLibraryOperations(): void {
const handle = database();
for (const row of handle.prepare("SELECT id,operation_id FROM game_file_versions WHERE state='importing' AND operation_id IS NOT NULL").all() as { id: string; operation_id: string }[]) {
const operation = getHostOperation(row.operation_id);
if (operation?.state === 'failed') handle.prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=? AND state='importing'").run(operation.error ?? 'Host import failed.', row.id);
}
for (const cache of handle.prepare("SELECT * FROM host_library_caches WHERE status IN ('downloading','verifying') AND operation_id IS NOT NULL").all() as CacheRow[]) {
const operation = getHostOperation(cache.operation_id!);
if (operation?.state === 'failed') handle.prepare("UPDATE host_library_caches SET status='failed',error=?,updated_at=? WHERE host_id=? AND version_id=?").run(operation.error ?? 'Host download failed.', new Date().toISOString(), cache.host_id, cache.version_id);
}
}
/** Lightweight list used in launch forms; large manifests stay on the detail endpoint. */
export function libraryOverview() {
const hosts = listHosts();
return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({
...version, caches: hosts.map(host => {
const cached = getHostLibraryCache(host.id, version.id);
const latest = library.versions.find(item => item.state === 'ready');
if (cached) return { ...cached, status: cached.status === 'ready' && latest?.id !== version.id ? 'outdated' as const : cached.status };
return {
hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0,
bytesTotal: version.sizeBytes, manifestSha256: null, error: null, updatedAt: version.createdAt, operationId: null
};
})
})) })), hosts };
}
+39
View File
@@ -0,0 +1,39 @@
import type Database from 'better-sqlite3';
const initialized = new WeakSet<Database.Database>();
export function ensureGameFileSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
handle.exec(`
CREATE TABLE IF NOT EXISTS game_file_libraries (
id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE,
game TEXT NOT NULL CHECK(game IN ('t5','t6')), description TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS game_file_versions (
id TEXT PRIMARY KEY, library_id TEXT NOT NULL REFERENCES game_file_libraries(id),
version TEXT NOT NULL, description TEXT NOT NULL, dlc TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('importing','ready','failed')),
size_bytes INTEGER NOT NULL DEFAULT 0, manifest TEXT NOT NULL DEFAULT '[]',
manifest_sha256 TEXT, created_at TEXT NOT NULL, published_at TEXT,
storage_location TEXT NOT NULL, source_host_id TEXT NOT NULL, source_volume TEXT NOT NULL,
error TEXT, operation_id TEXT, UNIQUE(library_id, version)
);
CREATE TABLE IF NOT EXISTS host_library_caches (
host_id TEXT NOT NULL, version_id TEXT NOT NULL REFERENCES game_file_versions(id),
status TEXT NOT NULL CHECK(status IN ('missing','downloading','verifying','ready','failed','outdated')),
bytes_downloaded INTEGER NOT NULL DEFAULT 0, bytes_total INTEGER NOT NULL DEFAULT 0,
manifest_sha256 TEXT, error TEXT, updated_at TEXT NOT NULL, operation_id TEXT,
PRIMARY KEY(host_id, version_id)
);
CREATE TABLE IF NOT EXISTS game_file_entries (
version_id TEXT NOT NULL REFERENCES game_file_versions(id), path TEXT NOT NULL,
size INTEGER NOT NULL, sha256 TEXT NOT NULL, PRIMARY KEY(version_id,path)
);
INSERT OR IGNORE INTO game_file_entries(version_id,path,size,sha256)
SELECT v.id,json_extract(f.value,'$.path'),json_extract(f.value,'$.size'),json_extract(f.value,'$.sha256')
FROM game_file_versions v,json_each(v.manifest) f;
CREATE TRIGGER IF NOT EXISTS game_file_versions_immutable BEFORE UPDATE ON game_file_versions
WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END;
`);
initialized.add(handle);
}
+103
View File
@@ -0,0 +1,103 @@
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, lstat, rename, rm, stat } from 'node:fs/promises';
import { dirname, isAbsolute, resolve, sep } from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
import type { GameFileEntry } from '../../game-files.js';
export function validateGamePath(input: unknown): string {
if (typeof input !== 'string' || !input || input.length > 1024 || /[\\\x00-\x1f\x7f:]/.test(input) || Buffer.from(input).toString('utf8') !== input
|| isAbsolute(input) || input.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))) {
throw new Error('Game file paths must be relative, normalized paths without traversal.');
}
return input;
}
export function validateManifest(input: unknown): GameFileEntry[] {
if (!Array.isArray(input) || !input.length || input.length > 200_000) throw new Error('Provide a manifest containing 1–200000 files.');
const paths = new Set<string>();
let total = 0;
const files = input.map(raw => {
if (!raw || typeof raw !== 'object') throw new Error('Invalid manifest entry.');
const path = validateGamePath(raw.path);
if (!Number.isSafeInteger(raw.size) || raw.size < 0 || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256)) throw new Error(`Invalid size or SHA256 for ${path}.`);
const key = path.toLowerCase();
if (paths.has(key)) throw new Error(`Duplicate game file path: ${path}.`);
paths.add(key); total += raw.size;
if (!Number.isSafeInteger(total)) throw new Error('Library size exceeds the supported limit.');
return { path, size: raw.size as number, sha256: raw.sha256 as string };
}).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
for (const file of files) {
const parts = file.path.toLowerCase().split('/');
parts.pop();
while (parts.length) {
if (paths.has(parts.join('/'))) throw new Error('A manifest path cannot be both a file and a directory.');
parts.pop();
}
}
return files;
}
export const manifestChecksum = (files: GameFileEntry[]): string => createHash('sha256').update(JSON.stringify(files)).digest('hex');
export function gameFilePath(root: string, path: string): string {
const base = resolve(root);
const result = resolve(base, validateGamePath(path));
if (!result.startsWith(base + sep)) throw new Error('Invalid game file path.');
return result;
}
/** Refuse symlinks at every existing component, including the managed root. */
export async function assertRegularPath(root: string, path: string, allowMissing = false): Promise<string> {
const result = gameFilePath(root, path);
let current = resolve(root);
const components = [current, ...validateGamePath(path).split('/').map(part => current = resolve(current, part))];
for (let i = 0; i < components.length; i++) {
let info;
try { info = await lstat(components[i]); }
catch (cause) { if (allowMissing && (cause as NodeJS.ErrnoException).code === 'ENOENT') continue; throw cause; }
if (info.isSymbolicLink() || (i < components.length - 1 ? !info.isDirectory() : !info.isFile())) throw new Error('Game storage contains a non-regular path.');
}
return result;
}
export async function verifyGameFile(root: string, file: GameFileEntry): Promise<void> {
const path = await assertRegularPath(root, file.path);
const info = await stat(path);
if (info.size !== file.size) throw new Error(`Size verification failed for ${file.path}.`);
const hash = createHash('sha256');
for await (const chunk of createReadStream(path)) hash.update(chunk);
if (hash.digest('hex') !== file.sha256) throw new Error(`Checksum verification failed for ${file.path}.`);
}
/** Stream to a private temporary file; publish only after byte count and SHA256 match. */
export async function receiveGameFile(root: string, file: GameFileEntry, body: ReadableStream<Uint8Array> | null): Promise<void> {
if (!body) throw new Error('A file body is required.');
const path = await assertRegularPath(root, file.path, true);
await mkdir(dirname(path), { recursive: true });
const temp = `${path}.upload-${randomUUID()}`;
let size = 0;
const hash = createHash('sha256');
const verifier = new Transform({ transform(chunk, _encoding, done) {
size += chunk.length;
if (size > file.size) return done(new Error('Upload exceeds the manifest size.'));
hash.update(chunk); done(null, chunk);
} });
try {
await pipeline(Readable.fromWeb(body as never), verifier, createWriteStream(temp, { flags: 'wx', mode: 0o600, flush: true }));
if (size !== file.size || hash.digest('hex') !== file.sha256) throw new Error(`Upload checksum or size mismatch for ${file.path}.`);
await rename(temp, path);
} finally { await rm(temp, { force: true }); }
}
export function parseByteRange(header: string | null, size: number): { start: number; end: number } | null {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header);
if (!match || (!match[1] && !match[2]) || size === 0) throw new Error('Unsatisfiable range.');
let start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
let end = match[1] && match[2] ? Number(match[2]) : size - 1;
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start || (!match[1] && Number(match[2]) === 0)) throw new Error('Unsatisfiable range.');
return { start, end: Math.min(end, size - 1) };
}
+50
View File
@@ -0,0 +1,50 @@
import { error, json } from '@sveltejs/kit';
import type { Host } from '../../hosts.js';
import { authenticateCredential } from './index.js';
export function requireAgentTransport(request: Request): void {
if (new URL(request.url).protocol !== 'https:' && process.env.EC_AGENT_ALLOW_HTTP !== 'true') error(426, 'Host agents require HTTPS.');
}
export function authenticateAgent(request: Request): Host {
requireAgentTransport(request);
const authorization = request.headers.get('authorization');
const token = authorization?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1];
const host = token ? authenticateCredential(token) : null;
if (!host) error(401, 'A valid host credential is required.');
return host;
}
export function requireManagementOrigin(request: Request): void {
const origin = request.headers.get('origin');
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') error(403, 'Cross-origin host management requests are not allowed.');
// Agent credentials only authorize agent endpoints, never host management.
if (request.headers.get('authorization')?.match(/^Bearer ec_(?:host|enroll)_/i)) error(403, 'Agent credentials cannot manage the controller.');
}
export async function objectBody(request: Request, maxBytes = 1024 * 1024): Promise<Record<string, unknown>> {
if (Number(request.headers.get('content-length')) > maxBytes) error(413, 'Request is too large.');
const reader = request.body?.getReader();
if (!reader) error(400, 'Request body must be valid JSON.');
const chunks: Uint8Array[] = []; let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > maxBytes) { void reader.cancel().catch(() => {}); error(413, 'Request is too large.'); }
chunks.push(value);
}
} finally { reader.releaseLock(); }
const raw = Buffer.concat(chunks, received).toString('utf8');
let body: unknown;
try { body = JSON.parse(raw); } catch { error(400, 'Request body must be valid JSON.'); }
if (!body || typeof body !== 'object' || Array.isArray(body)) error(400, 'Request body must be an object.');
return body as Record<string, unknown>;
}
export function hostApiError(cause: unknown): Response {
if (cause && typeof cause === 'object' && 'status' in cause && 'body' in cause) throw cause;
return json({ error: cause instanceof Error ? cause.message : 'Host request failed.' }, { status: 400 });
}
export function installerCommand(controller: string, token: string): string {
const origin = new URL(controller).origin;
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
return `curl --fail --silent --show-error --proto '=https' ${quote(origin + '/api/agent/install.sh')} | sudo bash -s -- --controller ${quote(origin)} --token ${quote(token)}`;
}
+248
View File
@@ -0,0 +1,248 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { isIP } from 'node:net';
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import type { Host, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
export const AGENT_PROTOCOL_VERSION = 1;
export const HEARTBEAT_MAX_AGE_MS = 90_000;
const now = () => new Date().toISOString();
const digest = (token: string) => createHash('sha256').update(token).digest('hex');
function database() { const handle = db(); ensureHostSchema(handle); return handle; }
type HostRow = { id: string; name: string; hostname: string; mode: HostMode; port_min: number; port_max: number; bind_address: string; public_address: string; minimum_free_bytes: number; created_at: string; enrolled_at: string | null; last_heartbeat: string | null; inventory: string; credential_active: number };
const hostSql = `SELECT h.*, EXISTS(SELECT 1 FROM ec_host_credentials c WHERE c.host_id = h.id AND c.revoked_at IS NULL) AS credential_active FROM ec_hosts h`;
function hostFromRow(row: HostRow): Host {
const inventory: HostInventory = JSON.parse(row.inventory);
const connected = !!row.credential_active && !!row.last_heartbeat && Date.now() - Date.parse(row.last_heartbeat) <= HEARTBEAT_MAX_AGE_MS;
const reasons: string[] = [];
if (!row.enrolled_at) reasons.push('Agent has not enrolled.');
else if (!row.credential_active) reasons.push('Agent credential has been revoked.');
if (!connected) reasons.push('Agent heartbeat is unavailable or stale.');
if (inventory.protocolVersion !== AGENT_PROTOCOL_VERSION) reasons.push('A compatible agent protocol is required.');
if (!inventory.dockerRunning) reasons.push('Docker is not ready.');
if (!inventory.directoriesWritable) reasons.push('Managed directories are not writable.');
if (typeof inventory.storageAvailable !== 'number' || inventory.storageAvailable < row.minimum_free_bytes) reasons.push('Insufficient available game storage.');
const infrastructureReady = reasons.length === 0;
if (!inventory.runtimeImages?.length) reasons.push('Prepare a Plutainer runtime image.');
if (!inventory.libraries?.some(library => library.state === 'ready')) reasons.push('Download and verify a game-file library.');
if (row.mode === 'draining') reasons.push('Host is draining; new deployments are blocked.');
if (row.mode === 'disabled') reasons.push('Host is disabled.');
return { id: row.id, name: row.name, hostname: row.hostname, mode: row.mode, portMin: row.port_min, portMax: row.port_max,
bindAddress: row.bind_address, publicAddress: row.public_address, minimumFreeBytes: row.minimum_free_bytes,
createdAt: row.created_at, lastHeartbeat: row.last_heartbeat, connected, connectionState: connected ? 'online' : 'offline',
readiness: row.mode === 'draining' ? 'draining' : row.mode === 'disabled' ? 'disabled' : !row.enrolled_at || (infrastructureReady && reasons.length) ? 'preparing' : reasons.length ? 'degraded' : 'ready',
readinessReasons: reasons, inventory, enrolled: !!row.enrolled_at, credentialActive: !!row.credential_active };
}
export function listHosts(): Host[] { return (database().prepare(hostSql + ' ORDER BY h.name, h.id').all() as HostRow[]).map(hostFromRow); }
export function getHost(id: string): Host | null {
const row = database().prepare(hostSql + ' WHERE h.id = ?').get(id) as HostRow | undefined;
return row ? hostFromRow(row) : null;
}
function requireHost(id: string): Host { const host = getHost(id); if (!host) throw new Error('Host was not found.'); return host; }
function textField(value: unknown, label: string, max = 200): string {
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f]/.test(value)) throw new Error(`${label} must be a nonempty string up to ${max} characters.`);
return value.trim();
}
function port(value: unknown): number { if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) throw new Error('Ports must be whole numbers between 1 and 65535.'); return value; }
export function createHost(input: Record<string, unknown>): Host {
const id = randomUUID(); const name = textField(input.name, 'Host name');
const min = port(input.portMin ?? 28960); const max = port(input.portMax ?? 28980);
if (max < min) throw new Error('The last game port must not precede the first.');
const bind = input.bindAddress === undefined ? '0.0.0.0' : textField(input.bindAddress, 'Bind address');
if (!isIP(bind)) throw new Error('Bind address must be an IPv4 or IPv6 address.');
database().prepare('INSERT INTO ec_hosts(id,name,port_min,port_max,bind_address,created_at) VALUES(?,?,?,?,?,?)').run(id, name, min, max, bind, now());
return requireHost(id);
}
export function updateHost(id: string, input: Record<string, unknown>): Host {
const host = requireHost(id);
const name = input.name === undefined ? host.name : textField(input.name, 'Host name');
const mode = input.mode ?? host.mode;
if (!['active', 'draining', 'disabled'].includes(mode as string)) throw new Error('Host mode must be active, draining, or disabled.');
const min = port(input.portMin ?? host.portMin); const max = port(input.portMax ?? host.portMax);
if (max < min) throw new Error('The last game port must not precede the first.');
const bind = input.bindAddress === undefined ? host.bindAddress : textField(input.bindAddress, 'Bind address');
if (!isIP(bind)) throw new Error('Bind address must be an IPv4 or IPv6 address.');
const publicAddress = input.publicAddress === undefined ? host.publicAddress : input.publicAddress === '' ? '' : textField(input.publicAddress, 'Public address');
const minimum = input.minimumFreeBytes ?? host.minimumFreeBytes;
if (typeof minimum !== 'number' || !Number.isSafeInteger(minimum) || minimum < 0) throw new Error('Minimum free storage must be a nonnegative byte count.');
database().prepare('UPDATE ec_hosts SET name=?,mode=?,port_min=?,port_max=?,bind_address=?,public_address=?,minimum_free_bytes=? WHERE id=?').run(name, mode, min, max, bind, publicAddress, minimum, id);
return requireHost(id);
}
export function createEnrollment(hostId: string, ttlMs = 15 * 60_000): { id: string; hostId: string; token: string; expiresAt: string } {
requireHost(hostId);
if (!Number.isInteger(ttlMs) || ttlMs <= 0 || ttlMs > 60 * 60_000) throw new Error('Enrollment lifetime must be at most one hour.');
const token = 'ec_enroll_' + randomBytes(32).toString('base64url'); const id = randomUUID();
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
database().transaction(() => {
database().prepare('UPDATE ec_host_enrollments SET consumed_at=? WHERE host_id=? AND consumed_at IS NULL').run(now(), hostId);
database().prepare('INSERT INTO ec_host_enrollments(id,host_id,token_hash,created_at,expires_at) VALUES(?,?,?,?,?)').run(id, hostId, digest(token), now(), expiresAt);
})();
return { id, hostId, token, expiresAt };
}
function issueCredential(hostId: string, credential = 'ec_host_' + randomBytes(32).toString('base64url')): string {
database().prepare('INSERT INTO ec_host_credentials(id,host_id,token_hash,created_at) VALUES(?,?,?,?)').run(randomUUID(), hostId, digest(credential), now());
return credential;
}
export function validateInventory(value: unknown): HostInventory {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Host inventory must be an object.');
const inventory = value as HostInventory;
if (JSON.stringify(inventory).length > 256_000) throw new Error('Host inventory is too large.');
for (const key of ['hostname','agentVersion','os','architecture','cpu','dockerVersion']) {
if (inventory[key] !== undefined && (typeof inventory[key] !== 'string' || String(inventory[key]).length > 1000)) throw new Error(`Invalid inventory ${key}.`);
}
for (const key of ['cpuCount','memoryTotal','memoryAvailable','storageTotal','storageAvailable','protocolVersion']) {
if (inventory[key] !== undefined && (typeof inventory[key] !== 'number' || !Number.isSafeInteger(inventory[key]) || Number(inventory[key]) < 0)) throw new Error(`Invalid inventory ${key}.`);
}
for (const key of ['dockerRunning','directoriesWritable']) if (inventory[key] !== undefined && typeof inventory[key] !== 'boolean') throw new Error(`Invalid inventory ${key}.`);
if (inventory.runtimeImages !== undefined && (!Array.isArray(inventory.runtimeImages) || inventory.runtimeImages.some(image => typeof image !== 'string'))) throw new Error('Invalid runtime image inventory.');
if (inventory.libraries !== undefined && (!Array.isArray(inventory.libraries) || inventory.libraries.length > 2000 || inventory.libraries.some(library =>
!library || typeof library !== 'object' || typeof library.id !== 'string' || !library.id || library.id.length > 200 || !['missing','downloading','verifying','ready','failed','outdated'].includes(library.state)))) throw new Error('Invalid game library inventory.');
return inventory;
}
export function enrollHost(token: unknown, rawInventory: unknown): { hostId: string; credential: string; heartbeatSeconds: number } {
if (typeof token !== 'string' || token.length > 200) throw new Error('Enrollment token is invalid or expired.');
const inventory = validateInventory(rawInventory);
return database().transaction(() => {
const enrollment = database().prepare('SELECT id,host_id FROM ec_host_enrollments WHERE token_hash=? AND consumed_at IS NULL AND expires_at>?').get(digest(token), now()) as { id: string; host_id: string } | undefined;
if (!enrollment) throw new Error('Enrollment token is invalid or expired.');
const changed = database().prepare('UPDATE ec_host_enrollments SET consumed_at=? WHERE id=? AND consumed_at IS NULL').run(now(), enrollment.id);
if (changed.changes !== 1) throw new Error('Enrollment token has already been consumed.');
revokeCredentials(enrollment.host_id);
const credential = issueCredential(enrollment.host_id);
database().prepare('UPDATE ec_hosts SET enrolled_at=?,last_heartbeat=?,hostname=?,inventory=? WHERE id=?').run(now(), now(), inventory.hostname ?? '', JSON.stringify(inventory), enrollment.host_id);
return { hostId: enrollment.host_id, credential, heartbeatSeconds: 15 };
})();
}
export function authenticateCredential(token: string): Host | null {
if (!token || token.length > 200) return null;
const row = database().prepare('SELECT id,host_id FROM ec_host_credentials WHERE token_hash=? AND revoked_at IS NULL').get(digest(token)) as { id: string; host_id: string } | undefined;
if (!row) return null;
database().prepare('UPDATE ec_host_credentials SET last_used_at=? WHERE id=?').run(now(), row.id);
return getHost(row.host_id);
}
export function revokeCredentials(hostId: string): void {
requireHost(hostId);
database().prepare('UPDATE ec_host_credentials SET revoked_at=? WHERE host_id=? AND revoked_at IS NULL').run(now(), hostId);
}
export function rotateCredential(hostId: string, nextCredential?: string): string {
requireHost(hostId);
if (nextCredential !== undefined && !/^ec_host_[A-Za-z0-9_-]{43}$/.test(nextCredential)) throw new Error('New credentials must contain 32 random bytes encoded as base64url.');
return database().transaction(() => {
if (nextCredential) {
const existing = database().prepare('SELECT host_id,revoked_at FROM ec_host_credentials WHERE token_hash=?').get(digest(nextCredential)) as { host_id: string; revoked_at: string | null } | undefined;
if (existing) {
if (existing.host_id !== hostId || existing.revoked_at) throw new Error('This credential cannot be reused.');
return nextCredential;
}
}
revokeCredentials(hostId); return issueCredential(hostId, nextCredential);
})();
}
export function heartbeat(hostId: string, value: unknown): Host {
requireHost(hostId); const inventory = validateInventory(value);
database().prepare('UPDATE ec_hosts SET last_heartbeat=?,hostname=?,inventory=? WHERE id=?').run(now(), inventory.hostname ?? '', JSON.stringify(inventory), hostId);
return requireHost(hostId);
}
export function requireReadyHost(hostId: string, options: { libraryId?: string; requiredBytes?: number; image?: string; port?: number; bindAddress?: string; protocol?: 'udp' | 'tcp' } = {}): Host {
const host = requireHost(hostId); const reasons = [...host.readinessReasons];
if (options.requiredBytes !== undefined && (host.inventory.storageAvailable ?? 0) < options.requiredBytes + host.minimumFreeBytes) reasons.push('Insufficient storage for this deployment.');
if (options.libraryId && !host.inventory.libraries?.some(library => library.id === options.libraryId && library.state === 'ready')) reasons.push('The selected game library is not ready on this host.');
if (options.image && !host.inventory.runtimeImages?.includes(options.image)) reasons.push('The selected runtime image is not cached on this host.');
if (options.port !== undefined && !hostPortAvailable(hostId, options.port, options.bindAddress, options.protocol)) reasons.push('The selected host port is not available.');
if (reasons.length) throw new Error(reasons.join(' '));
return host;
}
type OperationRow = { id: string; host_id: string; kind: string; instance_id: string | null; generation: number | null; payload: string; state: HostOperationState; result: string | null; error: string | null; created_at: string; updated_at: string };
function operationFromRow(row: OperationRow): HostOperation { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, payload: JSON.parse(row.payload), state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; }
export function getHostOperation(id: string): HostOperation | null { const row = database().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(id) as OperationRow | undefined; return row ? operationFromRow(row) : null; }
export function listHostOperations(hostId: string, limit = 100): HostOperation[] { return (database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? ORDER BY created_at DESC,rowid DESC LIMIT ?').all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); }
export function queueHostOperation(hostId: string, kind: string, payload: Record<string, unknown>, options: { instanceId?: string; generation?: number; idempotencyKey?: string; id?: string } = {}): HostOperation {
const host = requireHost(hostId);
if (!/^[a-z][a-z0-9_.-]{0,79}$/.test(kind)) throw new Error('Invalid operation kind.');
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Operation payload must be an object.');
if (options.generation !== undefined && (!Number.isSafeInteger(options.generation) || options.generation < 1)) throw new Error('Instance generation must be a positive integer.');
return database().transaction(() => {
if (options.idempotencyKey) {
const existing = database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? AND idempotency_key=?').get(hostId, options.idempotencyKey) as OperationRow | undefined;
if (existing) {
if (existing.kind !== kind || existing.instance_id !== (options.instanceId ?? null) || existing.generation !== (options.generation ?? null) || existing.payload !== JSON.stringify(payload)) throw new Error('Operation idempotency key is already associated with a different request.');
return operationFromRow(existing);
}
}
if (['instance.create','create'].includes(kind)) requireReadyHost(hostId);
if (host.mode === 'disabled') throw new Error('Host is disabled. Enable it before requesting operations.');
const id = options.id ?? randomUUID(); const timestamp = now();
database().prepare('INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)').run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null, JSON.stringify(payload), timestamp, timestamp);
database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,?,?)').run(id, 'pending', timestamp);
return getHostOperation(id)!;
})();
}
export function pendingHostOperations(hostId: string): HostOperation[] {
const host = requireHost(hostId);
if (host.mode === 'disabled' || !host.connected) return [];
// Accepted/running/unknown commands keep the same immutable ID. The agent
// consults its durable journal before acknowledging or executing a replay.
return database().transaction(() => {
const rows = database().prepare(`SELECT * FROM ec_host_operations WHERE host_id=? AND state IN ('pending','accepted','running','unknown')
AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create'))
ORDER BY COALESCE(last_delivered_at,''),created_at,rowid LIMIT 25`).all(hostId, Number(host.readiness === 'ready')) as OperationRow[];
const deliveredAt = now();
for (const operation of rows) database().prepare('UPDATE ec_host_operations SET last_delivered_at=? WHERE id=?').run(deliveredAt, operation.id);
return rows.map(operationFromRow);
})();
}
export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation {
return database().transaction(() => {
const operation = getHostOperation(id);
if (!operation || operation.hostId !== hostId) throw new Error('Operation was not found for this host.');
if (!['accepted','running','completed','failed','unknown'].includes(input.state as string)) throw new Error('Invalid operation state.');
if (['completed','failed'].includes(operation.state)) return operation;
const state = input.state as HostOperationState;
if (operation.state === 'running' && state === 'accepted') return operation;
const error = input.error === undefined || input.error === null ? null : String(input.error).slice(0, 4000);
const result = input.result === undefined ? null : JSON.stringify(input.result);
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024) throw new Error('Operation result exceeds the limit.');
database().prepare('UPDATE ec_host_operations SET state=?,result=?,error=?,updated_at=? WHERE id=?').run(state, result, error, now(), id);
if (operation.state !== state) database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,?,?,?)').run(id, state, now(), error);
return getHostOperation(id)!;
})();
}
export async function waitHostOperation(id: string, timeoutMs = 30_000): Promise<HostOperation> {
const deadline = Date.now() + timeoutMs;
while (true) {
const operation = getHostOperation(id);
if (!operation) throw new Error('Host operation was not found.');
if (['completed','failed','unknown'].includes(operation.state)) return operation;
if (Date.now() >= deadline) throw new Error(`Host operation ${id} is awaiting acknowledgement; reconcile this operation ID before retrying.`);
await new Promise(resolve => setTimeout(resolve, 200));
}
}
export function hostPortAvailable(hostId: string, candidate: number, bindAddress?: string, protocol: 'udp' | 'tcp' = 'udp', instanceId?: string): boolean {
const host = requireHost(hostId); port(candidate); const bind = bindAddress ?? host.bindAddress;
if (candidate < host.portMin || candidate > host.portMax) return false;
const rows = database().prepare('SELECT instance_id,bind_address FROM ec_host_ports WHERE host_id=? AND protocol=? AND port=?').all(hostId, protocol, candidate) as { instance_id: string; bind_address: string }[];
return !rows.some(row => row.instance_id !== instanceId && (row.bind_address === bind || ['0.0.0.0','::'].includes(bind) || ['0.0.0.0','::'].includes(row.bind_address)));
}
export function nextHostPort(hostId: string, additionalPorts: number[] = [], bindAddress?: string, protocol: 'udp' | 'tcp' = 'udp'): number | null {
const host = requireHost(hostId);
for (let candidate = host.portMin; candidate <= host.portMax; candidate++) if (!additionalPorts.includes(candidate) && hostPortAvailable(hostId, candidate, bindAddress, protocol)) return candidate;
return null;
}
export function reserveHostPort(hostId: string, instanceId: string, options: { port?: number; bindAddress?: string; protocol?: 'udp' | 'tcp' } = {}): number {
const host = requireHost(hostId); const bind = options.bindAddress ?? host.bindAddress; const protocol = options.protocol ?? 'udp';
if (!isIP(bind)) throw new Error('Bind address must be an IP address.');
if (!['udp','tcp'].includes(protocol)) throw new Error('Port protocol must be UDP or TCP.');
if (!instanceId) throw new Error('An immutable instance ID is required.');
return database().transaction(() => {
const existing = database().prepare('SELECT port FROM ec_host_ports WHERE host_id=? AND instance_id=? AND bind_address=? AND protocol=?').get(hostId, instanceId, bind, protocol) as { port: number } | undefined;
if (existing && (options.port === undefined || options.port === existing.port)) return existing.port;
const allocated = options.port ?? nextHostPort(hostId, [], bind, protocol);
if (allocated === null || !hostPortAvailable(hostId, allocated, bind, protocol, instanceId)) throw new Error('No available game port on the selected host.');
database().prepare('INSERT INTO ec_host_ports(host_id,instance_id,bind_address,protocol,port,created_at) VALUES(?,?,?,?,?,?)').run(hostId, instanceId, bind, protocol, allocated, now());
return allocated;
})();
}
export function releaseHostPorts(instanceId: string): void { database().prepare('DELETE FROM ec_host_ports WHERE instance_id=?').run(instanceId); }
+11
View File
@@ -0,0 +1,11 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
/** adapter-node copies public assets to build/client; Vite serves static in development. */
export async function agentReleaseAsset(file: 'install.sh' | 'ec-agent.py' | 'manifest.json'): Promise<string> {
try { return await readFile(resolve('build/client/agent', file), 'utf8'); }
catch (cause) {
if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause;
return readFile(resolve('static/agent', file), 'utf8');
}
}
+47
View File
@@ -0,0 +1,47 @@
import type Database from 'better-sqlite3';
const initialized = new WeakSet<Database.Database>();
export function ensureHostSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
handle.exec(`
CREATE TABLE IF NOT EXISTS ec_hosts (
id TEXT PRIMARY KEY, name TEXT NOT NULL, hostname TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'active' CHECK (mode IN ('active','draining','disabled')),
port_min INTEGER NOT NULL DEFAULT 28960, port_max INTEGER NOT NULL DEFAULT 28980,
bind_address TEXT NOT NULL DEFAULT '0.0.0.0', public_address TEXT NOT NULL DEFAULT '',
minimum_free_bytes INTEGER NOT NULL DEFAULT 1073741824,
created_at TEXT NOT NULL, enrolled_at TEXT, last_heartbeat TEXT, inventory TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS ec_host_enrollments (
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT
);
CREATE TABLE IF NOT EXISTS ec_host_credentials (
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL, revoked_at TEXT, last_used_at TEXT
);
CREATE TABLE IF NOT EXISTS ec_host_operations (
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), kind TEXT NOT NULL,
instance_id TEXT, generation INTEGER, idempotency_key TEXT,
payload TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending','accepted','running','completed','failed','unknown')),
result TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_delivered_at TEXT,
UNIQUE(host_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS ec_host_operations_queue ON ec_host_operations(host_id, state, created_at);
CREATE TABLE IF NOT EXISTS ec_host_operation_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, operation_id TEXT NOT NULL REFERENCES ec_host_operations(id),
state TEXT NOT NULL, created_at TEXT NOT NULL, error TEXT
);
CREATE TABLE IF NOT EXISTS ec_host_ports (
host_id TEXT NOT NULL REFERENCES ec_hosts(id), instance_id TEXT NOT NULL,
bind_address TEXT NOT NULL, protocol TEXT NOT NULL CHECK (protocol IN ('udp','tcp')),
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535), created_at TEXT NOT NULL,
PRIMARY KEY(host_id,bind_address,protocol,port)
);
CREATE INDEX IF NOT EXISTS ec_host_ports_instance ON ec_host_ports(instance_id);
`);
const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[];
if (!columns.some(column => column.name === 'last_delivered_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN last_delivered_at TEXT');
initialized.add(handle);
}
+2
View File
@@ -1,5 +1,6 @@
import { config, komodoConfigured } from '../config.js';
import { getServer } from '../fleet/servers.js';
import { nativeHealth, nativeInstance } from '../backends/native.js';
// Ports `komodo_restart` in push_scripts.sh:622. Three shapes depending on
// the tracked komodo_type: deployment | container | stack.
@@ -353,6 +354,7 @@ async function fetchJsonWithSnippet(
* the container endpoint isn't available.
*/
export async function komodoInspectHealthy(serverName: string): Promise<KomodoHealthResult> {
if (getServer(serverName)?.native_instance_id) return nativeHealth(serverName);
if (!komodoConfigured()) return { ok: false, reason: 'komodo not configured' };
const row = getServer(serverName);
if (!row?.komodo_name) return { ok: false, reason: 'no komodo_name for server' };
+29
View File
@@ -5,6 +5,7 @@ import { getServer, setServerField } from '../fleet/servers.js';
import { latestGameSubmission } from '../fleet/lifecycle-evidence.js';
import { komodoResolveGamePort } from './komodo.js';
import { parsePlutoniumRevision } from '../../plutonium.js';
import { nativeInstance, nativeRequest } from '../backends/native.js';
// Plutonium/CoD RCON is UDP with a 4-byte 0xff prefix. Requests look like
// \xff\xff\xff\xffrcon <password> <command>
@@ -373,6 +374,26 @@ export function formatRawReply(buf: Buffer): string {
* rather than just "unreachable".
*/
export async function rconStatusResult(server: string): Promise<RconStatusResult> {
const native = getServer(server)?.native_instance_id ? nativeInstance(server) : null;
if (native) {
const key = JSON.stringify(['agent', native.id, native.generation, latestGameSubmission(server)]);
const existing = statusInFlight.get(key);
if (existing) return existing;
const endpoint = { host: `agent:${native.host_id}`, port: native.port };
const request = (async (): Promise<RconStatusResult> => {
try {
const reply = await nativeRequest<{ text: string }>(native, 'rcon.execute', { command: 'status' });
const players = parsePlayerCount(reply.text);
if (players === null) return { ok: false, endpoint, reason: 'bad-reply', detail: 'Agent returned an unrecognized RCON status.', rawReply: reply.text.slice(0, 4000) };
const info = parseInfoLine(reply.text); const header = parseStatusHeader(reply.text);
return { ok: true, endpoint, status: { players,
capacity: positiveCapacity(info.sv_maxclients ?? header.sv_maxclients),
map: info.mapname ?? info.sv_mapname ?? header.map ?? header.mapname ?? header.sv_mapname ?? null,
gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null, roster: parsePlayerRoster(reply.text) }, rawReply: reply.text.slice(0, 4000) };
} catch (error) { return { ok: false, endpoint, reason: 'timeout', detail: (error as Error).message }; }
})().finally(() => statusInFlight.delete(key));
statusInFlight.set(key, request); return request;
}
// Snapshot before any network work. A probe begun after a restart or a
// replacement registration must not join one observing the old process.
const createdAt = getServer(server)?.created_at;
@@ -476,6 +497,14 @@ function positiveCapacity(value: string | undefined): number | null {
/** Query the running process, not the updater's on-disk files. */
export async function rconPlutoniumVersion(server: string): Promise<{ revision: number | null; error: string | null }> {
const native = getServer(server)?.native_instance_id ? nativeInstance(server) : null;
if (native) {
try {
const reply = await nativeRequest<{ text: string }>(native, 'rcon.execute', { command: 'version' });
const revision = parsePlutoniumRevision(reply.text);
return { revision, error: revision === null ? 'Agent RCON reply did not identify a runtime revision.' : null };
} catch (error) { return { revision: null, error: (error as Error).message }; }
}
const { endpoint } = await endpointFor(server);
if (!endpoint) return { revision: null, error: 'No RCON endpoint available.' };
const response = await sendRcon(endpoint.host, endpoint.port, 'version');
+2 -1
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { komodoConfigured } from '../config.js';
import { nativeInstance } from '../backends/native.js';
import { getServer, listServers } from '../fleet/servers.js';
import { hasActiveProvisioning } from '../fleet/provisioning.js';
import { komodoInspectHealthy } from '../integrations/komodo.js';
@@ -295,7 +296,7 @@ async function run(forceScan: boolean): Promise<void> {
const lastScan = setting('watchdog_last_scan');
if (!forceScan && lastScan && Date.now() - Date.parse(lastScan) < WATCHDOG_SCAN_MS) return;
setSetting('watchdog_last_scan', iso());
if (!komodoConfigured()) {
if (!komodoConfigured() && !listServers().some(server => nativeInstance(server.name))) {
setSetting('watchdog_scan_error', 'Configure Komodo before automatic recovery. RCON or fresh telemetry verifies game status.'); return;
}
const errors: string[] = [];
+4 -2
View File
@@ -6,12 +6,14 @@ import type { ProvisioningCapacity, ProvisioningDeployment, ProvisioningDetail,
import { fleetSettings } from '../settings.js';
import { activitySummary, deploymentEvents, latestFailureEvent } from './activity.js';
import { readProgress, recordProgress, type ProgressUpdate } from './progress.js';
import { bindNativeOperation } from './native.js';
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
const id = randomUUID();
db().transaction(() => {
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy);
bindNativeOperation(id, server);
recordProgress(id, { phase: 'requested', observedAt: null, checkpoint: { key: 'requested', phase: 'requested',
label: action === 'create' ? 'Server creation requested'
: action === 'destroy' ? 'Server destruction requested' : `${actionLabels[action]} server requested` } });
@@ -243,10 +245,10 @@ export function listDeployments() {
export function interruptDeployments(): void {
db().transaction(() => {
const running = db().prepare("SELECT id FROM server_deployments WHERE state = 'running'").all() as { id: string }[];
const running = db().prepare("SELECT id FROM server_deployments WHERE state = 'running' AND id NOT IN (SELECT deployment_id FROM native_provisioning)").all() as { id: string }[];
const finishedAt = new Date().toISOString();
const error = 'The application restarted before this operation finished. Check the server before retrying.';
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE state = 'running'`).run(finishedAt, error);
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE state = 'running' AND id NOT IN (SELECT deployment_id FROM native_provisioning)`).run(finishedAt, error);
for (const { id } of running) recordProgress(id, { blocker: null, event: { kind: 'outcome',
message: 'Interrupted by application restart. Check the server before retrying.', details: { state: 'interrupted', finishedAt, error } } });
})();
+19 -1
View File
@@ -28,6 +28,8 @@ import { getVersion } from '../fleet/scripts.js';
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
import { recordProgress, type ProgressUpdate } from './progress.js';
import { checkRelease, freshRelease, probeVersion, release } from '../integrations/plutonium.js';
import { nativeInstance, nativeDeployment } from '../backends/native.js';
import { previewNativeCreation, queueNativeCreation, queueNativeRebuild, cancelQueuedNativeCreation } from './native.js';
export type CreateServerFailure =
| 'bad-request'
@@ -49,6 +51,12 @@ export type DestroyServerResult =
| { ok: false; kind: 'not-found' | 'conflict' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
export interface CreateServerInput {
hostId?: string;
libraryId?: string;
image?: string;
bindAddress?: string;
publicAddress?: string;
publicPort?: number;
game?: ZombieGame; // omitted by older clients: T5
gameMode: GameMode; // 'zombie' — only value supported today
mapSlug: string; // 'cosmodrome'
@@ -167,7 +175,8 @@ interface PreviewRow {
}
/** Read-only checks; a preview never reserves a slot or writes credentials. */
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown }): LaunchPreview {
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown; hostId?: unknown; libraryId?: unknown }): LaunchPreview {
if (input.hostId !== undefined || !config.komodo.address) return previewNativeCreation(input);
if (input.blueprintId !== undefined && input.deploymentId !== undefined) throw new Error('Choose one starting point.');
let recipe: LaunchRecipe;
let blueprintName: string | null = null;
@@ -208,6 +217,8 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
// releasingServers is internal to atomic collection queueing; HTTP callers cannot set it.
export function queueCreateServer(input: CreateServerInput, releasingServers: string[] = []): CreateServerResult {
const nativePreview = input.previewId ? db().prepare("SELECT 1 FROM server_launch_previews WHERE id=? AND json_extract(recipe,'$.native.hostId') IS NOT NULL").get(input.previewId) : null;
if (input.hostId || nativePreview || !config.komodo.address) return queueNativeCreation(input);
return db().transaction((): CreateServerResult => {
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined;
if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' };
@@ -243,6 +254,7 @@ export function queueCreateServer(input: CreateServerInput, releasingServers: st
/** Reserve the same identity and port before removing any part of the instance. */
export function queueRebuildServer(name: string, triggeredBy = 'ui'): CreateServerResult {
if (nativeInstance(name)) return queueNativeRebuild(name, triggeredBy);
const blocker = activeBetaRemovalBlocker(name);
if (blocker) return { ok: false, kind: 'conflict', message: blocker };
const server = getServer(name);
@@ -535,6 +547,8 @@ function hasActiveCreation(name: string): boolean {
}
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
const cancelled = cancelQueuedNativeCreation(name, triggeredBy);
if (cancelled) return { ok: true, warnings: [], deploymentId: cancelled };
const blocker = activeBetaRemovalBlocker(name);
if (blocker) return { ok: false, kind: 'conflict', message: blocker, warnings: [] };
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild')
@@ -597,6 +611,7 @@ async function runDestructions(): Promise<void> {
}
async function processDestruction(operation: DestructionJob): Promise<void> {
if (nativeDeployment(operation.id)) return;
if (creating || destroying.has(operation.server)) return;
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) return;
destroying.add(operation.server);
@@ -859,6 +874,7 @@ async function runCreations(): Promise<CreationOutcomes> {
// deployment. A known interrupted startup must retain its readiness gate.
const interrupted = db().prepare("SELECT id FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state = 'interrupted' ORDER BY started_at, rowid").all() as { id: string }[];
for (const { id } of interrupted) {
if (nativeDeployment(id)) continue;
if (resumeVerification(id)) continue;
// A queued removal does not make the interrupted container disappear.
// Keep its slot until removal finishes, without resuming normal work or
@@ -883,6 +899,7 @@ async function runCreations(): Promise<CreationOutcomes> {
WHERE d.server_deleted_at IS NULL AND d.state = 'awaiting-health' ORDER BY d.started_at, d.rowid`).all() as CreationJob[];
for (const job of checking) {
const id = job.deployment_id;
if (nativeDeployment(id)) continue;
if (getServer(job.server)?.game_port !== job.port) {
finishDeployment(id, { ok: false, message: 'Server was removed or replaced before creation health checks completed' });
continue;
@@ -980,6 +997,7 @@ async function runCreations(): Promise<CreationOutcomes> {
const queue = creationQueue();
const available = Math.max(0, fleetSettings().createConcurrency - queue.active.length);
const eligible = queue.queued.filter(({ id }) => {
if (nativeDeployment(id)) return false;
const blocker = creationBlocker(id);
if (blocker) deploymentStage(id, blocker.message, { phase: 'startup-queue', blocker });
else recordProgress(id, { phase: 'startup-queue', blocker: { code: available ? 'startup-queue' : 'startup-capacity',
+404
View File
@@ -0,0 +1,404 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { posix } from 'node:path';
import { db } from '../db/index.js';
import { config, relDestForKind, serverDir } from '../config.js';
import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js';
import { getHost, getHostOperation, listHosts, nextHostPort, queueHostOperation, releaseHostPorts, requireReadyHost, reserveHostPort } from '../hosts/index.js';
import { getLibraryVersion, requireReadyLibrary } from '../game-files/index.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
import { beginDeployment, deploymentStage, finishDeployment, REMOVING_SERVER } from './history.js';
import { recordProgress } from './progress.js';
import { fleetSettings } from '../settings.js';
import { resolveRecoveredHealthOperation } from '../maintenance/recovery.js';
import { probeServerStatus, probePlayerEvidence } from '../fleet/status-evidence.js';
import { checkRelease, freshRelease, probeVersion } from '../integrations/plutonium.js';
import { getServerScript, recordScript, removeScript } from '../fleet/inventory.js';
import { sha256Buffer } from '../fleet/files.js';
import type { StagedFileChange } from '../fleet/staged-files.js';
import type { LaunchPreview } from '../../deployment-plan.js';
import type { ProvisioningAction } from '../../provisioning.js';
export interface NativeSelection { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number }
type NativeRecipe = LaunchRecipe & { native?: NativeSelection };
interface Job { id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; files_json: string | null; applied_at: string | null }
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
export function nativeSelection(input: Record<string, unknown>, game: string): NativeSelection {
if (typeof input.hostId !== 'string' || !input.hostId) throw new Error('Select a ready host.');
if (typeof input.libraryId !== 'string' || !input.libraryId) throw new Error('Select a published game-file version.');
const version = getLibraryVersion(input.libraryId);
if (!version || version.game !== game || version.state !== 'ready') throw new Error('Choose a published game-file version for the selected game.');
const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image;
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.');
const host = requireReadyHost(input.hostId, { image, requiredBytes: 1024 ** 3 });
requireReadyLibrary(host.id, version.id);
const bindAddress = input.bindAddress === undefined || input.bindAddress === '' ? host.bindAddress : input.bindAddress;
if (typeof bindAddress !== 'string') throw new Error('Invalid bind address.');
if (input.publicAddress !== undefined && (typeof input.publicAddress !== 'string' || input.publicAddress.length > 253)) throw new Error('Invalid public address.');
if (input.publicPort !== undefined && (!Number.isInteger(input.publicPort) || Number(input.publicPort) < 1 || Number(input.publicPort) > 65535)) throw new Error('Invalid public port.');
return { hostId: host.id, libraryId: version.id, image, bindAddress, publicAddress: input.publicAddress as string | undefined ?? host.publicAddress,
publicPort: input.publicPort as number | undefined };
}
export function previewNativeCreation(input: Record<string, unknown>): LaunchPreview {
let recipe: NativeRecipe;
let blueprintName: string | null = null;
if (typeof input.deploymentId === 'string') {
const previous = recipeForDeployment(input.deploymentId);
if (!previous) throw new Error('The recorded deployment is unavailable.');
recipe = captureRecipe(previous.recipe.game, previous.recipe.mapSlug);
blueprintName = previous.blueprintName;
} else if (typeof input.blueprintId === 'string') ({ recipe, blueprintName } = blueprintRecipe(input.blueprintId));
else {
if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.');
recipe = captureRecipe(input.game, input.mapSlug);
}
const selection = nativeSelection(input, recipe.game);
recipe.native = selection; recipe.image = selection.image!;
const base = `${recipe.game}_zombie_${recipe.mapSlug}`;
const lobby = nextLobbyNumber(base);
const name = lobby === null ? null : `${base}_${lobby}`;
const port = nextNativePort(selection);
const id = randomUUID(); const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
db().prepare('INSERT INTO server_launch_previews(id,expires_at,server,port,recipe,blueprint_name) VALUES(?,?,?,?,?,?)')
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
const checks: LaunchPreview['checks'] = [
{ label: 'Host readiness', state: 'pass', detail: `${getHost(selection.hostId)!.name}: connected, Docker ready, storage and runtime verified.` },
{ label: 'Game files', state: 'pass', detail: `Immutable library version ${selection.libraryId} is ready on the selected host.` },
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' },
{ label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' },
{ label: 'Configuration', state: 'pass', detail: recipe.configSource }
];
return { id, expiresAt, name, port, hasSavedKey: !!name && !!getServerKey(name), recipe: summarizeRecipe(recipe), blueprintName, checks,
ready: checks.every(check => check.state !== 'blocked') };
}
export function nextNativePort(selection: NativeSelection, taken: number[] = []): number | null {
const excluded = [...taken];
for (;;) {
const port = nextHostPort(selection.hostId, excluded, selection.bindAddress, 'udp');
if (port === null) return null;
// Both protocols are published by Plutainer and reserved together.
const tcp = nextHostPort(selection.hostId, excluded, selection.bindAddress, 'tcp');
if (tcp === port) return port;
if (tcp !== null && tcp < port) { excluded.push(tcp); continue; }
excluded.push(port);
}
}
export function queueNativeCreation(input: CreateServerInput): CreateServerResult {
try {
return db().transaction(() => {
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id=?').get(input.previewId) as
{ id: string; recipe: string; server: string; port: number; expires_at: string; deployment_id: string | null; blueprint_name: string | null } | undefined : undefined;
if (input.previewId && !preview) throw new Error('Review a fresh launch preview.');
if (preview?.deployment_id) return { ok: true as const, name: preview.server, port: preview.port, deploymentId: preview.deployment_id };
if (preview && preview.expires_at <= new Date().toISOString()) throw new Error('Launch preview expired.');
const saved = preview ? JSON.parse(preview.recipe) as NativeRecipe : undefined;
const game = input.game ?? 't5';
if (input.gameMode !== 'zombie' || (saved && (saved.game !== game || saved.mapSlug !== input.mapSlug))) throw new Error('Selection changed. Review a fresh launch preview.');
const selection = nativeSelection((saved?.native ?? input) as unknown as Record<string, unknown>, game);
const recipe: NativeRecipe = { ...captureRecipe(game, input.mapSlug), native: selection, image: selection.image! };
const lobby = nextLobbyNumber(`${game}_zombie_${input.mapSlug}`);
if (lobby === null) throw new Error('All lobby IDs are reserved.');
const name = `${game}_zombie_${input.mapSlug}_${lobby}`;
const port = nextNativePort(selection);
if (!port) throw new Error('No available host port.');
if ((input.expectedName && input.expectedName !== name) || (preview && (preview.server !== name || preview.port !== port))) throw new Error('Available lobby or host port changed. Review a fresh preview.');
const key = input.plutoKey?.trim() || getServerKey(name);
if (!key) throw new Error(`Save a Plutonium server key for ${name}.`);
saveServerKey(name, key);
const instanceId = randomUUID();
reserveHostPort(selection.hostId, instanceId, { port, bindAddress: selection.bindAddress, protocol: 'udp' });
reserveHostPort(selection.hostId, instanceId, { port, bindAddress: selection.bindAddress, protocol: 'tcp' });
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,bind_address,port,public_address,public_port,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`).run(instanceId, name, selection.hostId, selection.libraryId, selection.image, selection.bindAddress, port,
selection.publicAddress ?? null, selection.publicPort ?? null, new Date().toISOString());
const id = beginDeployment(name, 'create', input.createdBy);
db().prepare("UPDATE server_deployments SET state='queued' WHERE id=?").run(id);
db().prepare('INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,?,?,?,?)')
.run(id, name, port, 'zombie', recipe.mapSlug, recipe.expectedMap);
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe,blueprint_name) VALUES(?,?,?)').run(id, JSON.stringify(recipe), preview?.blueprint_name ?? null);
if (preview) db().prepare('UPDATE server_launch_previews SET deployment_id=? WHERE id=?').run(id, preview.id);
deploymentStage(id, 'Queued for native host deployment', { phase: 'startup-queue', recipeStatus: 'queued' });
return { ok: true as const, name, port, deploymentId: id };
})();
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
}
export function bindNativeOperation(id: string, server: string): void {
const instance = nativeInstance(server);
if (instance) db().prepare('INSERT OR IGNORE INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,?)').run(id, instance.id, instance.generation);
}
/** Cancelling an unsubmitted launch releases only its local reservation. */
export function cancelQueuedNativeCreation(server: string, triggeredBy: string): string | null {
const instance = nativeInstance(server);
if (!instance || getServer(server)) return null;
return db().transaction(() => {
const creation = db().prepare(`SELECT d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
WHERE n.instance_id=? AND d.action='create' AND d.state='queued' AND n.operation_id IS NULL`).get(instance.id) as { id: string } | undefined;
if (!creation) return null;
const id = beginDeployment(server, 'destroy', triggeredBy);
const now = new Date().toISOString();
db().prepare("UPDATE server_deployments SET state='cancelled',finished_at=?,stage='Queued native creation cancelled',server_deleted_at=? WHERE id=?")
.run(now, now, creation.id);
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(now, instance.id);
releaseHostPorts(instance.id);
deploymentStage(id, 'Cancelled queued creation; no agent mutation was submitted');
finishDeployment(id, { ok: true });
return id;
})();
}
export function queueNativeRebuild(name: string, triggeredBy: string): CreateServerResult {
try {
const instance = nativeInstance(name)!; const server = getServer(name);
const blocker = activeBetaRemovalBlocker(name);
if (blocker) throw new Error(blocker);
if (!server?.map_slug || !getServerKey(name)) throw new Error('A saved map and server key are required before rebuilding.');
requireReadyLibrary(instance.host_id, instance.library_id);
const recipe: NativeRecipe = { ...captureRecipe(server.game as 't5' | 't6', server.map_slug), image: instance.image,
native: { hostId: instance.host_id, libraryId: instance.library_id, image: instance.image, bindAddress: instance.bind_address } };
return db().transaction(() => {
const id = beginDeployment(name, 'rebuild', triggeredBy);
db().prepare("UPDATE server_deployments SET state='waiting' WHERE id=?").run(id);
db().prepare('INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,?,?,?,?)')
.run(id, name, instance.port, 'zombie', recipe.mapSlug, recipe.expectedMap);
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe) VALUES(?,?)').run(id, JSON.stringify(recipe));
deploymentStage(id, 'Waiting to rebuild when empty', { phase: 'eligibility', recipeStatus: 'queued' });
return { ok: true as const, name, port: instance.port, deploymentId: id };
})();
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
}
function filesFor(job: Job): FilePlan[] {
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as StagedFileChange[];
return rows.map(row => ({ id: row.id, path: posix.join(relDestForKind(row.kind, job.server.startsWith('t6_') ? 't6' : 't5'), row.rel_path),
contentBase64: row.content?.toString('base64') ?? null, sha256: row.content ? sha256Buffer(row.content) : null,
checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
}
/** Agent receipts are the authoritative before images; do not read a controller mount. */
function applyReceipt(job: Job, result: Record<string, unknown>) {
if (job.applied_at || !job.files_json) return;
const plans = JSON.parse(job.files_json) as FilePlan[];
const before = result.before as { id?: number; path: string; contentBase64: string | null }[] | undefined;
db().transaction(() => {
for (const plan of plans) {
const snapshot = before?.find(item => item.id === plan.id || item.path === plan.path);
if (!snapshot) throw new Error('Agent activation receipt is missing a before image.');
const row = db().prepare('SELECT * FROM staged_file_changes WHERE id=?').get(plan.id) as StagedFileChange;
if (!row || row.state === 'applied') continue;
const bytes = snapshot.contentBase64 === null ? null : Buffer.from(snapshot.contentBase64, 'base64');
const prior = getServerScript(job.server, row.rel_path);
if (row.source_kind === 'beta') {
if (bytes !== null) db().prepare(`INSERT OR IGNORE INTO beta_file_snapshots(beta_id,server,rel_path,prior_content,prior_sha256,prior_size) VALUES(?,?,?,?,?,?)`)
.run(row.source_id, job.server, row.rel_path, bytes, sha256Buffer(bytes), bytes.length);
db().prepare(`UPDATE beta_server_scripts SET had_original=?,prior_version_id=? WHERE beta_id=? AND server=?
AND script_id IN (SELECT id FROM scripts WHERE target_rel_path=? AND kind=?)`).run(bytes === null ? 0 : 1, prior?.script_version_id ?? null, row.source_id, job.server, row.rel_path, row.kind);
db().prepare('INSERT OR IGNORE INTO beta_file_changes(beta_id,server,rel_path) VALUES(?,?,?)').run(row.source_id, job.server, row.rel_path);
db().prepare('UPDATE beta_servers SET deployed_at=?,updated_at=? WHERE beta_id=? AND server=?').run(new Date().toISOString(), new Date().toISOString(), row.source_id, job.server);
}
if (row.deployment_file_id && !row.preserve_before_image) db().prepare('UPDATE deployment_files SET verb=?,content_before=?,sha256_before=? WHERE id=?')
.run(row.content === null ? 'removed' : bytes === null ? 'added' : 'updated', bytes, bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id);
if (row.content === null) removeScript(job.server, row.rel_path);
else recordScript(job.server, row.rel_path, row.script_version_id && db().prepare('SELECT 1 FROM script_versions WHERE id=?').get(row.script_version_id) ? row.script_version_id : null,
sha256Buffer(row.content), row.content.length, row.inventory_source);
db().prepare("UPDATE staged_file_changes SET state='applied',applied_at=? WHERE id=?").run(new Date().toISOString(), row.id);
}
db().prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
})();
}
function createPayload(instance: NativeInstance, recipe: LaunchRecipe) {
const key = getServerKey(instance.server);
if (!key) throw new Error('The saved server key was removed.');
return { name: instance.server, game: recipe.game, libraryId: instance.library_id, image: instance.image, port: instance.port, bindAddress: instance.bind_address,
rconPassword: config.rcon.password || randomBytes(32).toString('hex'),
env: { PLUTAINER_SERVER_NAME: instance.server, PLUTAINER_PORT: String(instance.port), PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
PLUTAINER_CONFIG_FILE: recipe.configFile, PLUTAINER_MAP_ROTATE: 'false', PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`, PLUTO_SERVER_KEY: key },
files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path), contentBase64: file.content, sha256: file.sha256 })) };
}
function hold(job: Job, message: string) { deploymentStage(job.id, message, { blocker: { code: 'native-host', message, automatic: true } }); }
function complete(job: Job) {
db().transaction(() => {
db().prepare('UPDATE native_provisioning SET verified_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
db().prepare('UPDATE restart_rollout SET healthy_at=?,error=NULL WHERE operation_id=?').run(new Date().toISOString(), job.id);
finishDeployment(job.id, { ok: true });
})();
}
export async function processNativeProvisioning(eligibility: (operation: { id: string; server: string; action: string; state: string; stage: string }) => string | null): Promise<void> {
// Draining prevents admission immediately and retires existing lobbies only through the normal safety queue.
for (const host of listHosts().filter(host => host.mode === 'draining' && host.connected)) {
const instances = db().prepare('SELECT * FROM native_instances WHERE host_id=? AND retired_at IS NULL').all(host.id) as NativeInstance[];
for (const instance of instances) {
if (!getServer(instance.server) || activeBetaRemovalBlocker(instance.server)) continue;
if (db().prepare("SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health')").get(instance.server)) continue;
const id = beginDeployment(instance.server, 'destroy', 'host-drain');
db().prepare("UPDATE server_deployments SET state='waiting' WHERE id=?").run(id);
deploymentStage(id, 'Host draining; waiting for a fresh empty lobby');
}
}
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.files_json,n.applied_at
FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id
WHERE d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[];
for (const job of jobs) {
try { await processJob(job, eligibility); }
catch (error) { hold(job, (error as Error).message); }
}
}
async function processJob(job: Job, eligibility: (job: Job) => string | null) {
let instance = nativeInstance(job.server);
if (!instance || instance.id !== job.instance_id || (instance.generation !== job.generation && !(job.action === 'rebuild' && instance.generation === job.generation + 1))) {
finishDeployment(job.id, { ok: false, message: 'Original instance was retired or replaced; no command was sent.' }); return;
}
const host = getHost(instance.host_id);
if (!host?.connected || host.mode === 'disabled') {
db().prepare("UPDATE servers SET container_state='unknown',last_players=NULL,last_roster=NULL WHERE name=?").run(job.server);
hold(job, 'Host is unavailable; existing server state is unknown. Waiting for reconnection.'); return;
}
if (job.operation_id) {
const operation = getHostOperation(job.operation_id);
if (!operation || operation.state === 'unknown') { hold(job, 'Agent result is unknown. Reconciling the original operation; no replacement or repeated command will be sent.'); return; }
if (operation.state === 'failed') {
// The agent reports failed only when no mutation began. Ambiguous
// post-mutation failures remain unknown and retain their capacity.
db().transaction(() => {
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
const files = JSON.parse(job.files_json ?? '[]') as FilePlan[];
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='pending' WHERE id=? AND state='applying'").run(file.id);
finishDeployment(job.id, { ok: false, message: operation.error ?? 'Agent operation failed before mutation.' });
})();
return;
}
if (operation.state !== 'completed') {
const blocked = operation.result as { message?: string } | null;
hold(job, blocked?.message ?? `Agent operation ${operation.state}`); return;
}
if (job.action === 'destroy') {
db().transaction(() => {
deploymentStage(job.id, REMOVING_SERVER, { checkpoint: { key: 'delete-deployment', label: 'Agent confirmed instance and container removal' } });
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(new Date().toISOString(), instance!.id);
releaseHostPorts(instance!.id);
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
complete(job);
})();
return;
}
if (job.action === 'rebuild' && instance.generation === job.generation) {
db().prepare('UPDATE native_instances SET generation=? WHERE id=? AND generation=?').run(job.generation + 1, instance.id, job.generation);
instance = nativeInstance(job.server)!;
}
applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>);
if (job.state !== 'awaiting-health') {
db().prepare("UPDATE server_deployments SET state='awaiting-health',error=NULL,finished_at=NULL WHERE id=?").run(job.id);
recordProgress(job.id, { command: { state: 'accepted', message: `Agent confirmed ${job.action}; verifying the resulting instance.` }, phase: 'verify-container' });
}
const health = await nativeRequest<{ healthy: boolean; state: string }>(instance, 'instance.inspect');
if (job.action === 'stop') {
if (!['stopped','exited','created','dead'].includes(health.state)) { hold(job, 'Waiting for the agent to verify a stopped container.'); return; }
db().prepare("UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?").run(health.state, job.server);
complete(job); return;
}
if (!health.healthy) { hold(job, `Waiting for container readiness: ${health.state}`); return; }
const status = await probeServerStatus(job.server, operation.updatedAt);
if (!status.ok) { hold(job, `Waiting for game status: ${status.reason}`); return; }
if (job.action === 'create' || job.action === 'rebuild') {
const recipe = recipeForDeployment(job.id)!.recipe;
if (status.map !== recipe.expectedMap) { hold(job, `Waiting for selected map ${recipe.expectedMap}; observed ${status.map ?? 'unknown'}.`); return; }
await checkRelease();
const revision = await probeVersion(job.server); const target = freshRelease();
if (revision === null || target === null) { hold(job, 'Waiting to verify the Plutonium runtime revision.'); return; }
if (revision < target) { finishDeployment(job.id, { ok: false, message: `Running Plutonium r${revision} is older than production r${target}.` }); return; }
}
db().prepare("UPDATE servers SET container_state='running',container_checked_at=? WHERE name=?").run(new Date().toISOString(), job.server);
recordProgress(job.id, { blocker: null, checks: [{ key: 'container', label: 'Container readiness', source: 'agent', result: 'pass', actual: health.state },
{ key: 'game', label: 'Game status', source: status.source, result: 'pass', actual: status.players }] });
complete(job); return;
}
const reason = eligibility(job);
if (reason) { hold(job, reason); return; }
if (job.action === 'destroy' || job.action === 'rebuild') {
const beta = activeBetaRemovalBlocker(job.server); if (beta) { hold(job, beta); return; }
}
const files = job.files_json ? JSON.parse(job.files_json) as FilePlan[] : filesFor(job);
if (files.length && !job.stage_operation_id) {
const stage = queueHostOperation(instance.host_id, 'files.stage', { stageId: job.id, files }, { instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` });
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?').run(stage.id, JSON.stringify(files), job.id);
hold(job, 'Staging and verifying files on the host.'); return;
}
if (job.stage_operation_id) {
const stage = getHostOperation(job.stage_operation_id);
if (stage?.state === 'failed') { finishDeployment(job.id, { ok: false, message: stage.error ?? 'File staging failed.' }); return; }
if (stage?.state !== 'completed') { hold(job, 'Waiting for verified remote file staging.'); return; }
if (files.some(file => (db().prepare('SELECT state FROM staged_file_changes WHERE id=?').get(file.id) as { state: string } | undefined)?.state !== 'pending')) {
finishDeployment(job.id, { ok: false, message: 'Staged file request was cancelled or changed before activation.' }); return;
}
}
const settings = fleetSettings();
const count = (actions: string[]) => (db().prepare(`SELECT COUNT(*) AS n FROM server_deployments WHERE id != ? AND server_deleted_at IS NULL
AND action IN (${actions.map(() => '?').join(',')}) AND state IN ('running','awaiting-health')`).get(job.id, ...actions) as { n: number }).n;
if ((job.action === 'create' || job.action === 'rebuild') && count(['create','rebuild']) >= settings.createConcurrency) { hold(job, 'Waiting for startup capacity.'); return; }
if (job.action === 'destroy' && count(['destroy']) >= settings.destroyConcurrency) { hold(job, 'Waiting for destruction capacity.'); return; }
if (job.action === 'create') requireReadyHost(instance.host_id, { image: instance.image, requiredBytes: 1024 ** 3 });
if (job.action === 'create' || job.action === 'rebuild') requireReadyLibrary(instance.host_id, instance.library_id);
if (job.action !== 'create' && job.action !== 'start') {
// Fast controller check keeps occupied lobbies out of the operation slots.
// The agent repeats this immediately before its local mutation.
const health = await nativeRequest<{ state: string; healthy?: boolean }>(instance, 'instance.inspect');
if (!['stopped','exited','created','dead','missing'].includes(health.state)) {
const occupancy = await probePlayerEvidence(job.server);
const recoveredWatchdog = health.healthy && occupancy.players !== null && db().prepare(`SELECT 1 FROM provisioning_sources s
JOIN watchdog_incidents i ON i.id=s.source_id JOIN server_deployments d ON d.id=s.operation_id
WHERE s.operation_id=? AND s.kind='watchdog' AND i.kind='health' AND d.triggered_by LIKE 'watchdog:%'`).get(job.id);
if (recoveredWatchdog && occupancy.players !== null) { resolveRecoveredHealthOperation(job.id, occupancy.players, occupancy.source); return; }
if (occupancy.players !== 0) { hold(job, occupancy.players !== null ? `Waiting for ${occupancy.players} player(s) to leave.` : 'Waiting for a fresh local player count.'); return; }
}
}
const current = db().prepare('SELECT state FROM server_deployments WHERE id=? AND server_deleted_at IS NULL').get(job.id) as { state: string } | undefined;
if (!current || !['queued','waiting','interrupted'].includes(current.state) || eligibility(job)) return;
db().transaction(() => {
const live = nativeInstance(job.server);
if (live?.id !== instance!.id || live.generation !== instance!.generation) throw new Error('Instance changed during preflight.');
if (job.action === 'destroy' || job.action === 'rebuild') {
const beta = activeBetaRemovalBlocker(job.server);
if (beta) { hold(job, beta); return; }
}
if (JSON.stringify(filesFor(job)) !== JSON.stringify(files)) {
finishDeployment(job.id, { ok: false, message: 'File requests changed during preflight. Review and submit a fresh activation.' }); return;
}
if (job.action === 'start' || job.action === 'restart' || job.action === 'rebuild') {
db().prepare(`INSERT INTO restart_rollout(server,requested_at,operation_id) VALUES(?,?,?) ON CONFLICT(server)
DO UPDATE SET requested_at=excluded.requested_at,operation_id=excluded.operation_id,healthy_at=NULL,error=NULL`).run(job.server, new Date().toISOString(), job.id);
}
let payload: Record<string, unknown> = files.length ? { stageId: job.id } : {};
if (job.action === 'create' || job.action === 'rebuild') {
const prior = recipeForDeployment(job.id)!.recipe as NativeRecipe;
const recipe: NativeRecipe = { ...captureRecipe(prior.game, prior.mapSlug), native: prior.native, image: instance!.image };
payload = createPayload(instance!, recipe);
if (job.action === 'rebuild') payload.nextGeneration = instance!.generation + 1;
db().prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(recipe), job.id);
if (job.action === 'create') db().prepare(`INSERT INTO servers(name,komodo_type,komodo_name,game,map,game_mode,map_slug,host_dir,config_file,game_id,image,game_port,created_by,container_state)
VALUES(?,'deployment','',?,?,'zombie',?,?,?,?,?,?,'native','starting')`).run(job.server, recipe.game, recipe.expectedMap, recipe.mapSlug, serverDir(job.server),
recipe.configFile, recipe.game === 't6' ? 't6zm' : 't5sp', recipe.image, instance!.port);
for (const file of recipe.files) recordScript(job.server, file.path, file.versionId, file.sha256, file.size, 'native-create');
}
const operation = queueHostOperation(instance!.host_id, `instance.${job.action === 'destroy' ? 'remove' : job.action}`, payload,
{ instanceId: instance!.id, generation: instance!.generation, idempotencyKey: job.id });
db().prepare('UPDATE native_provisioning SET operation_id=? WHERE deployment_id=?').run(operation.id, job.id);
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='applying' WHERE id=? AND state='pending'").run(file.id);
db().prepare("UPDATE server_deployments SET state='running',submitted_at=? WHERE id=?").run(new Date().toISOString(), job.id);
deploymentStage(job.id, `Host agent ${job.action} queued`, { phase: 'container-action', blocker: null,
command: { state: 'attempted', message: `Durable agent operation ${operation.id}; reconciliation always uses this ID.` } });
})();
}
+5 -3
View File
@@ -7,6 +7,7 @@ import { queueRebuildServer } from './instances.js';
import { restartRequestInFlight } from './restarts.js';
import { runtimeActions, type RuntimeAction } from '../../provisioning.js';
import { recordProgress } from './progress.js';
import { nativeInstance } from '../backends/native.js';
export interface LifecycleOperation {
id: string; server: string; action: string; state: string; stage: string;
@@ -56,9 +57,10 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
// stale file preconditions after a beta; its failure must not strand
// the rollback that would restore the server.
const rollback = source.kind === 'campaign' && !!db().prepare("SELECT 1 FROM deployments WHERE id = ? AND action = 'rollback'").get(source.id);
if (source.kind === 'campaign' && !row.restarted_at && !rollback) {
if (source.kind === 'campaign' && !row.restarted_at && !rollback && !nativeInstance(server)) {
id = (db().prepare(`SELECT d.id FROM server_deployments d WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.action = 'restart'
AND d.state = 'waiting' AND d.submitted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM native_provisioning n WHERE n.deployment_id=d.id AND n.stage_operation_id IS NOT NULL)
AND EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'campaign')
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind != 'campaign')
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN deployments c ON c.id = s.source_id
@@ -135,7 +137,7 @@ export function releaseRestartRequest(server: string, source: RestartSource): vo
export function requestLifecycle(server: string, action: RuntimeAction) {
if (!runtimeActions.includes(action)) return { ok: false as const, status: 400, error: 'Choose start, restart, rebuild, or stop.' };
if (!komodoConfigured() || !rconConfigured()) return { ok: false as const, status: 503, error: 'Configure Komodo and RCON before provisioning.' };
if (!nativeInstance(server) && (!komodoConfigured() || !rconConfigured())) return { ok: false as const, status: 503, error: 'Configure a host and RCON before provisioning.' };
return db().transaction(() => {
const blocker = action === 'rebuild' ? activeBetaRemovalBlocker(server) : null;
if (blocker) return { ok: false as const, status: 409, error: blocker };
@@ -184,7 +186,7 @@ function watchdogRequestGuard(server: string, incidentId: string): Extract<Lifec
const row = getServer(server);
if (!row || row.destroyed_at) return { ok: false, status: 404, error: 'Server not found.' };
if (row.desired_state === 'stopped') return { ok: false, status: 409, error: 'This server is intentionally stopped.' };
if (!komodoConfigured()) return { ok: false, status: 503, error: 'Configure Komodo before provisioning.' };
if (!nativeInstance(server) && !komodoConfigured()) return { ok: false, status: 503, error: 'Configure a host before provisioning.' };
return null;
}
+2
View File
@@ -7,6 +7,7 @@ import { applyStagedFilesForOperation } from '../fleet/staged-files.js';
import type { RuntimeAction } from '../../provisioning.js';
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
import { recordProgress, type ProgressUpdate } from './progress.js';
import { nativeDeployment } from '../backends/native.js';
// Komodo queues restart work. Don't mistake the still-running old container
// for a completed restart when an API kick immediately follows the request.
@@ -166,6 +167,7 @@ async function refresh(): Promise<void> {
const slots = db().prepare('SELECT * FROM restart_rollout WHERE healthy_at IS NULL ORDER BY requested_at, server')
.all() as RestartSlot[];
for (const slot of slots) {
if (slot.operation_id && nativeDeployment(slot.operation_id)) continue;
if (requestsInFlight.has(slot.server)) continue;
// Recovery capacity transferred to a rebuild remains reserved until
// teardown. Its health is verified by the creation worker, never here.
+16
View File
@@ -12,6 +12,8 @@ import { cancelProvisioning } from './requests.js';
import type { RuntimeAction } from '../../provisioning.js';
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
import { recordProgress } from './progress.js';
import { nativeInstance, nativeDeployment } from '../backends/native.js';
import { processNativeProvisioning } from './native.js';
const stoppedStates = new Set(['stopped', 'exited', 'created', 'dead']);
export const isStopped = (state?: string) => stoppedStates.has(state?.toLowerCase() ?? '');
@@ -98,6 +100,19 @@ export function processProvisioning(): Promise<void> {
export const processLifecycle = processProvisioning;
async function run(): Promise<void> {
await processNativeProvisioning(operation => {
if (operation.action === 'create') return null;
if (operation.action === 'destroy') {
const other = db().prepare(`SELECT id FROM server_deployments WHERE server=? AND id!=? AND server_deleted_at IS NULL
AND (state='running' OR (state='awaiting-health' AND action NOT IN ('create','rebuild'))) LIMIT 1`).get(operation.server, operation.id);
return other ? 'Waiting for the current server operation.' : null;
}
const typed = operation as Operation;
const eligibility = sourceEligibility(typed);
if (eligibility === 'cancelled') { cancelProvisioning(operation.id, 'Requester cancelled or replaced this operation'); return 'Requester cancelled this operation.'; }
if (eligibility === 'held') return 'Waiting for the requester to resume.';
return blocker(operation.server, operation.id, typed.action)?.message ?? null;
});
await processCreations();
await processDestructions();
await refreshRestartRollout();
@@ -105,6 +120,7 @@ async function run(): Promise<void> {
WHERE server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild') AND (state = 'waiting' OR (action = 'stop' AND state = 'awaiting-health'))
ORDER BY started_at, rowid`).all() as Operation[];
for (const operation of operations) {
if (nativeDeployment(operation.id)) continue;
try { await processOperation(operation); }
catch (err) {
// Observation failures are retried; remote mutations are never replayed.
@@ -6,12 +6,19 @@
import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
import ServerIdentity from './ServerIdentity.svelte';
import type { CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
import NativeHostSelection from './NativeHostSelection.svelte';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import type { BlueprintSummary, CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let preview = $state<CollectionPreview | null>(null);
let mode = $state<CollectionMode>('fill');
let games = $state<ZombieGame[]>([]);
let collectionName = $state('Launch collection');
let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' });
let hostReady = $state(false);
let confirmRemovals = $state(false);
let keys = $state<Record<string, string>>({});
let busy = $state(false);
@@ -28,16 +35,31 @@
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); }
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void initialize(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
});
async function initialize() {
const current = ++session; busy = true; error = ''; games = []; hostReady = false;
hostSelection = { hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' };
try {
const response = await fetch('/api/blueprints'); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
if (current !== session) return;
const blueprint = (body.blueprints as BlueprintSummary[]).find(item => item.id === blueprintId);
if (!blueprint) throw new Error('This collection is no longer available.');
games = [...new Set(blueprint.entries.map(entry => entry.game))]; collectionName = blueprint.name;
} catch (cause) { if (current === session) error = (cause as Error).message; }
finally { if (current === session) busy = false; }
}
async function review() {
if (!hostReady) { error = 'Choose a ready host and cached game files for each game in this collection.'; return; }
const current = ++session;
const previous = preview;
busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
try {
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode }) });
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode, hostId: hostSelection.hostId, libraryIds: hostSelection.libraryIds, image: hostSelection.image }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
if (current === session) {
@@ -73,16 +95,17 @@
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="composer">
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? 'Preparing your collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? collectionName}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
<label class="mode">Apply collection
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; void review(); }}>
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; preview = null; confirmRemovals = false; }}>
<option value="fill">Fill missing servers</option>
<option value="exact">Match collection exactly</option>
</select>
</label>
{#if games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Select a host and game files, then review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
{:else}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
@@ -122,7 +145,7 @@
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
</form>
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server uses the selected host, immutable game-file version, and prepared runtime. Host readiness, cached files, runtime availability, and local port reservations are checked again before deployment.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
{/if}
</div>
</dialog>
@@ -6,6 +6,8 @@
import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
import NativeHostSelection from './NativeHostSelection.svelte';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
@@ -21,6 +23,8 @@
let maps = $derived(catalogs[game]);
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
let preview = $state<LaunchPreview | null>(null);
let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' });
let hostReady = $state(false);
let plutoKey = $state('');
let busy = $state(false);
let loaded = $state(false);
@@ -50,6 +54,7 @@
blueprintId = initialBlueprintId;
game = initialGame;
mapSlug = initialMapSlug;
hostSelection = { hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' }; hostReady = false;
try {
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
@@ -59,8 +64,13 @@
blueprints = blueprintBody.blueprints;
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This collection is no longer available. Close this dialog and choose another starting point.');
if (sourceDeploymentId) {
const source = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`).then(response => response.json());
if (current !== session) return;
if (!source.recipe) throw new Error('The original launch recipe is unavailable.');
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
}
loaded = true;
if (sourceDeploymentId) await review();
} catch (err) { if (current === session) error = (err as Error).message; }
}
@@ -69,12 +79,15 @@
if (!sourceDeploymentId && selectedBlueprint) {
open = false; collectionOpen = true; return;
}
if (!hostReady) { error = 'Choose a ready host, cached game-file version, and prepared runtime.'; return; }
const current = session;
const previousName = preview?.name;
busy = true; error = ''; savedName = ''; keyNotice = '';
try {
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
hostId: hostSelection.hostId, libraryId: hostSelection.libraryIds[game], image: hostSelection.image,
bindAddress: hostSelection.bindAddress || undefined, publicAddress: hostSelection.publicAddress || undefined, publicPort: hostSelection.publicPort }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
if (current === session) {
@@ -137,7 +150,7 @@
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
</form>
@@ -147,14 +160,15 @@
{/if}
</details>
{:else if sourceDeploymentId}
<p class="muted">{busy ? 'Preparing the current promoted setup…' : 'Review the latest promoted setup for this map with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button>
<p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network /><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
{:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
{#if !selectedBlueprint}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network />{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
</form>
{/if}
</div>
@@ -32,7 +32,7 @@
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
<h2 id="destroy-title">Queue server removal?</h2>
<p class="name">{name}</p>
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Provisioning history and the saved server key are retained.</p>
<p>This stops and removes the container, its writable server files, and campaign details. Shared game libraries, provisioning history, and the saved server key are retained.</p>
<div class="notice">
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the provisioning page.
+1 -1
View File
@@ -53,7 +53,7 @@
{#each runtimeActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
<option value="destroy">Destroy</option>
</select></label>
<p>{action === 'destroy' ? 'Destroy the container, delete its Komodo deployment, and remove all server files and registration after the server is empty. Provisioning history and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
<p>{action === 'destroy' ? 'Destroy the container and remove its writable server files and registration after the server is empty. Shared game libraries, provisioning history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
{#if destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that all files and data in this instance will be permanently deleted.</label>{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.js';
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }:
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props();
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
const host = $derived(hosts.find(item => item.id === selection.hostId));
const versions = $derived(libraries.flatMap(library => library.versions.filter(version => version.state === 'ready').map(version => ({ ...version, name: library.name }))));
const imageReady = $derived(!!host?.inventory.runtimeImages?.includes(selection.image));
function cacheReady(versionId: string | undefined) { return !!versionId && versions.some(version => version.id === versionId && version.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status))); }
$effect(() => { ready = !loading && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => cacheReady(selection.libraryIds[game])); });
async function load() {
loading = true; error = '';
try {
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]);
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game libraries.');
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries;
} catch (cause) { error = (cause as Error).message; } finally { loading = false; }
}
onMount(() => { void load(); });
</script>
<section class="selection" aria-label="Deployment host and game files">
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
{#if error}<p role="alert" class="error">{error}</p>{/if}
{#if loading}<p role="status">Loading host readiness and libraries…</p>{/if}
<label>Game host<select bind:value={selection.hostId} disabled={disabled || loading} onchange={() => onchange?.()}><option value="">Choose a host</option>{#each hosts as candidate}<option value={candidate.id} disabled={candidate.readiness !== 'ready'}>{candidate.name} · {candidate.connectionState} · {candidate.readiness}</option>{/each}</select></label>
{#if !loading && !hosts.some(candidate => candidate.readiness === 'ready')}<p>No deployment-ready hosts. <a href="/hosts" target="_blank" rel="noopener">Set up a host</a>, then refresh.</p>{/if}
{#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if}
{#each games as game}<label>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} game-file version<select bind:value={selection.libraryIds[game]} disabled={disabled || loading || !host} onchange={() => onchange?.()}><option value="">Choose an immutable library version</option>{#each versions.filter(version => version.game === game) as version}<option value={version.id} disabled={!cacheReady(version.id)}>{version.name} · {version.version} · {cacheReady(version.id) ? 'Ready on host' : 'Not cached on host'}</option>{/each}</select></label>{/each}
<p><a href="/game-files" target="_blank" rel="noopener">Import or download game files to a host</a>, then refresh. Each launch stays pinned to its selected library version.</p>
<label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label>
{#if host && !imageReady}<p class="error">Prepare this runtime image from <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">host setup</a> before launch.</p>{/if}
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
</section>
<style>
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input,select { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
</style>
@@ -9,7 +9,7 @@
const progress = $derived(detail.progress);
let expanded = $state<Record<string, boolean>>({});
const evidenceLabels = { pass: 'Passed', waiting: 'Waiting', fail: 'Failed', unknown: 'Unknown' };
const sources = { komodo: 'Komodo', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' };
const sources = { komodo: 'Komodo', agent: 'Host agent', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' };
</script>
<section class="execution" aria-labelledby="execution-title">
+1 -1
View File
@@ -9,7 +9,7 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
});
export const fleetError = writable('');
export const fleetRefreshing = writable(false);
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; native?: boolean; hostsReady?: number; hostsTotal?: number; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer, telemetryByServer, uiNow], ([snapshot, live, received, matches, reports, now]) =>
snapshot.servers.map(server => {
+4
View File
@@ -0,0 +1,4 @@
import { setupRequired } from '$lib/server/auth/index.js';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user ?? null, setupRequired: setupRequired() });
+74 -26
View File
@@ -15,17 +15,24 @@
import { resolveConfirmation } from '$ui/confirm.js';
import '$ui/styles/components.css';
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
import type { AuthUser } from '$lib/auth.js';
// Side-effect import — puts @fontsource CSS on Vite's module graph so
// the referenced woff/woff2 files get emitted into the build.
import '$ui/styles/fonts.js';
interface Props { children: import('svelte').Snippet }
let { children }: Props = $props();
interface Props { children: import('svelte').Snippet; data: { user: AuthUser | null; setupRequired: boolean } }
let { children, data }: Props = $props();
const publicPage = $derived(['/login', '/setup'].includes($page.url.pathname));
const sessionReady = $derived(!!data.user && !data.user.mustChangePassword && !publicPage);
let menuOpen = $state(false);
let accountOpen = $state(false);
let signingOut = $state(false);
let signOutError = $state('');
let notificationsOpen = $state(false);
let helpOpen = $state(false);
const unread = $derived($notificationHistory.filter(item => !item.read).length);
let menuButton: HTMLButtonElement;
let menuButton = $state<HTMLButtonElement>();
let accountButton = $state<HTMLButtonElement>();
function openUtility(panel: 'notifications' | 'help') {
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
@@ -33,32 +40,51 @@
else helpOpen = true;
}
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
afterNavigate(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); });
function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
if (event.key === 'Escape' && menuOpen) {
menuOpen = false;
menuButton?.focus();
}
}
onMount(() => {
const stopTimeZone = startTimeZonePreference();
async function signOut() {
if (signingOut) return;
signingOut = true; signOutError = '';
try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); }
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ }
notificationHistory.set([]);
window.location.assign('/login');
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
}
$effect(() => {
if (!sessionReady) return;
const stopNotifications = startNotificationHistory();
const stopPipeline = startPipelineStream();
const stopServers = startServerStream();
const stopFleetMonitor = startFleetMonitor();
const stopFleet = startFleetMonitor();
return () => { stopNotifications(); stopFleet(); stopPipeline?.(); stopServers?.(); };
});
onMount(() => {
const stopTimeZone = startTimeZonePreference();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` provisioning,
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
const shortcuts: Record<string, string> = {
f: '/', d: '/provisioning', a: '/campaigns', w: '/maintenance', b: '/betas',
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics'
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics', o: '/hosts'
};
let armed = false;
let armTimer: ReturnType<typeof setTimeout> | null = null;
const onKey = (e: KeyboardEvent) => {
if (!sessionReady) return;
const target = e.target as HTMLElement;
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -74,6 +100,7 @@
armed = false;
if (armTimer) { clearTimeout(armTimer); armTimer = null; }
const dest = shortcuts[e.key];
if ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
if (dest) {
e.preventDefault();
goto(dest);
@@ -83,10 +110,6 @@
window.addEventListener('keydown', onKey);
return () => {
stopTimeZone();
stopNotifications();
stopFleetMonitor();
stopPipeline?.();
stopServers?.();
window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer);
};
@@ -98,7 +121,8 @@
const tabs = [
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
{ href: '/hosts', label: 'Hosts', match: (p: string) => /^\/hosts(\/|$)/.test(p) },
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins|game-files)(\/|$)/.test(p) },
{ href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|collections|campaigns|maintenance|watchdog|server-keys)(\/|$)/.test(p) },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
@@ -106,29 +130,33 @@
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') },
{ href: '/metrics', label: 'Metrics', match: (p: string) => p === '/metrics' }
];
const secondary = $derived(tabs[1].match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
: tabs[2].match($page.url.pathname)
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, { href: '/server-keys', label: 'Keys' }]
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }] : []);
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
</script>
<svelte:window onkeydown={closeMenu} />
<div class="shell">
<a class="skip-link" href="#main-content">Skip to content</a>
{#if publicPage}
<header class="auth-brand"><BrandMark size="md" /></header>
{:else}
<nav aria-label="Primary">
<a class="brand-link" href="/" aria-label="Electric Cherry home">
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
<BrandMark size="sm" />
</a>
<button class="menu-toggle" bind:this={menuButton} type="button"
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
aria-expanded={menuOpen} aria-controls="primary-links"
onclick={() => { menuOpen = !menuOpen; }}>
{menuOpen ? 'Close' : 'Menu'}
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
</button>
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
{#each tabs as t (t.href)}
{#each visibleTabs as t (t.href)}
{@const on = t.match($page.url.pathname)}
<a
href={t.href}
@@ -138,18 +166,25 @@
<span class="lbl">{t.label}</span>
</a>
{/each}
</div>{/if}
<div class="nav-tools" class:expanded={menuOpen} class:account-only={!sessionReady}>
{#if sessionReady}<button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button>{/if}
{#if data.user}<div class="account-menu"><button bind:this={accountButton} class="utility account-button" aria-expanded={accountOpen} aria-controls="account-links" onclick={() => accountOpen = !accountOpen}>{data.user.displayName || data.user.username}<span class="role">{data.user.role}</span></button>
{#if accountOpen}<div class="account-links" id="account-links"><a href="/account">My account</a>{#if data.user.role === 'admin' && sessionReady}<a href="/settings/users">Manage users</a>{/if}<button disabled={signingOut} onclick={signOut}>{signingOut ? 'Signing out…' : 'Sign out'}</button>{#if signOutError}<p role="alert">{signOutError}</p>{/if}</div>{/if}
</div>{/if}
</div>
<div class="nav-tools" class:expanded={menuOpen}><button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button></div>
</nav>
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
<main id="main-content" tabindex="-1">
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={item.href === '/settings' ? $page.url.pathname === item.href ? 'page' : undefined : $page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
{/if}
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
{@render children()}
</main>
<Toaster />
<Confirmation />
<NotificationHistory bind:open={notificationsOpen} />
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],['g h','Settings'],['g k','Keys'],['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
<dl class="shortcuts">{#each [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
</Dialog>
</div>
@@ -193,6 +228,16 @@
}
.shell { min-height: 100vh; }
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
main.auth-main { max-width:520px; padding-top:16px; }
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); } .viewer-note strong { color:var(--text-primary); }
.account-menu { position:relative; }
.account-button { display:flex; align-items:center; gap:8px; max-width:220px; overflow:hidden; text-overflow:ellipsis; }
.role { text-transform:capitalize; color:var(--text-tertiary); font-size:var(--text-xs); }
.account-links { position:absolute; right:0; top:calc(100% + 8px); min-width:200px; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); }
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
nav {
position: sticky;
@@ -272,7 +317,7 @@
}
/* The full navigation needs more room than a phone or portrait tablet. */
@media (max-width: 1200px) {
@media (max-width: 1500px) {
nav {
flex-wrap: wrap;
justify-content: space-between;
@@ -303,6 +348,9 @@
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
.nav-tools { display: none; }
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
.nav-tools.account-only { display:flex; }
.account-menu { margin-left:auto; }
.account-links { position:static; margin-top:8px; }
}
@media (max-width: 720px) {
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
+8 -5
View File
@@ -31,6 +31,7 @@
let deployOpen = $state(false);
let initialGame = $state<ZombieGame>('t5');
let initialMapSlug = $state('');
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const filters: { id: FleetFilter; label: string }[] = [
{ id: 'all', label: 'All' }, { id: 'occupied', label: 'Occupied' }, { id: 'empty', label: 'Empty' },
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
@@ -128,10 +129,10 @@
<SectionHeader title="Fleet" subtitle="Live health, players, and server operations.">
{#snippet actions()}
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
<Button variant="primary" onclick={() => launch()}>Create server</Button>
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
{/snippet}
</SectionHeader>
<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />
{#if canOperate}<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />{/if}
{#if $fleetError}
<div class="error" role="alert"><strong>{$fleet.receivedAt ? 'Showing the last available snapshot.' : 'Fleet is unavailable.'}</strong> {$fleetError}
@@ -148,7 +149,9 @@
<a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
</div>{/if}
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
{#if $fleetIntegrations?.native && !$fleetIntegrations.hostsReady}
<div class="setup-note"><span><strong>Prepare a game host</strong> · {$fleetIntegrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div>
{:else if $page.data.user?.role === 'admin' && $fleetIntegrations && !$fleetIntegrations.native && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
{/if}
@@ -176,7 +179,7 @@
{#if servers.length === 0}
<EmptyState title="No servers deployed">
{#snippet actions()}
<Button variant="primary" onclick={() => launch()}>Create a server</Button>
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create a server</Button>{/if}
<Button href="/collections" variant="ghost">Browse collections</Button>
{/snippet}
</EmptyState>
@@ -205,7 +208,7 @@
{#each groups as [key, group] (key)}
<section class="map-group" aria-label={group.title}>
{#if grouping !== 'none'}<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
{#if canOperate && group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
</div>{/if}
{#if view === 'cards'}
<div class="cards">{#each group.servers as server (server.name)}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let currentPassword = $state(''); let newPassword = $state(''); let confirmPassword = $state('');
let busy = $state(false); let signingOut = $state(false); let error = $state(''); let notice = $state('');
const user = $derived($page.data.user);
async function changePassword(event: SubmitEvent) {
event.preventDefault(); if (busy || signingOut) return; error = ''; notice = '';
if (newPassword !== confirmPassword) { error = 'The new passwords do not match.'; return; }
const required = user?.mustChangePassword;
busy = true;
try {
const response = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
currentPassword = ''; newPassword = ''; confirmPassword = '';
if (required) { window.location.assign('/'); return; }
await invalidateAll(); notice = 'Your password has been changed.';
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
async function logout() {
if (busy || signingOut) return; signingOut = true; error = '';
try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); }
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional history. */ }
window.location.assign('/login');
} catch (cause) { error = (cause as Error).message; signingOut = false; }
}
</script>
<svelte:head><title>My account · Electric Cherry</title></svelte:head>
<section class="page"><SectionHeader title="My account" subtitle="Manage your password and current session." />
{#if user}
<section class="panel identity"><div><h2>{user.displayName}</h2><p>@{user.username} · <span class="role">{user.role}</span></p></div><Button disabled={busy || signingOut} loading={signingOut} onclick={logout}>Sign out</Button></section>
{#if user.mustChangePassword}<p class="required" role="status">Your account uses a temporary password. Choose a new password before continuing to Electric Cherry.</p>{/if}
<section class="panel"><h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p class="notice" role="status">{notice}</p>{/if}
<form onsubmit={changePassword} aria-busy={busy}><fieldset disabled={busy || signingOut}>
<input type="text" class="password-username" value={user.username} autocomplete="username" aria-label="Username" readonly tabindex="-1" />
<label for="current-password">{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input id="current-password" name="currentPassword" type="password" autocomplete="current-password" required maxlength="128" bind:value={currentPassword} /></label>
<label for="new-password">New password<input id="new-password" name="newPassword" type="password" autocomplete="new-password" required minlength="15" maxlength="128" bind:value={newPassword} aria-describedby="new-password-help" /></label>
<p id="new-password-help" class="hint">Use 15–128 characters and a password you do not use elsewhere.</p>
<label for="confirm-password">Confirm new password<input id="confirm-password" name="confirmPassword" type="password" autocomplete="new-password" required minlength="15" maxlength="128" bind:value={confirmPassword} /></label>
<div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div>
</fieldset></form>
</section>
{/if}
</section>
<style>
.page { max-width:760px; display:grid; gap:24px; } .panel { padding:24px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); } h2 { font-size:var(--text-md); margin:0; } .identity { display:flex; align-items:center; justify-content:space-between; gap:20px; } .identity p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-sm); } .role { text-transform:capitalize; }
.required { padding:16px; margin:0; color:var(--text-primary); background:var(--surface-raised); border-left:3px solid var(--accent-primary); font-size:var(--text-sm); line-height:1.6; }
form { margin-top:24px; max-width:460px; } fieldset { border:0; margin:0; padding:0; display:grid; gap:18px; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .password-username { position:absolute; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; padding:0; border:0; } .hint { margin:-8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.6; } .error { color:var(--state-danger); font-size:var(--text-sm); } .notice { color:var(--state-online); font-size:var(--text-sm); }
@media(max-width:540px) { .identity { align-items:flex-start; flex-direction:column; } }
</style>
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { rotateCredential } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
const host = authenticateAgent(request);
try {
const body = await objectBody(request);
if (typeof body.credential !== 'string') throw new Error('Persist a randomly generated replacement credential before requesting rotation.');
rotateCredential(host.id, body.credential);
return json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
}
+8
View File
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { enrollHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
requireAgentTransport(request);
try { const body = await objectBody(request); return json(enrollHost(body.token, body.inventory), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
}
+16
View File
@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import { heartbeat } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
const authenticated = authenticateAgent(request);
try {
const body = await objectBody(request);
const host = heartbeat(authenticated.id, body.inventory);
const inventory = body.inventory as Record<string, unknown>;
if (Array.isArray(inventory.libraryCaches)) {
const { updateHostLibraryCaches } = await import('$lib/server/game-files/index.js');
updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true });
}
return json({ host, heartbeatSeconds: 15, serverTime: new Date().toISOString() });
} catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { requireImportOwner, setImportManifest } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(requireImportOwner(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
}
export async function PUT({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request);
const body = await readGameJson(request, 64 * 1024 * 1024);
return json(setImportManifest(host.id, params.id, body.files));
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { importPublicationStatus, requestImportPublication } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const result = requestImportPublication(authenticateAgent(request).id, params.id);
return json(result, { status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200 });
}
catch (cause) { return gameFileError(cause); }
}
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(importPublicationStatus(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { uploadImportFile } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
try {
const host = authenticateAgent(request);
await uploadImportFile(host.id, params.id, params.path, request.body);
return json({ uploaded: true });
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,2 @@
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET() { return new Response(await agentReleaseAsset('install.sh'), { headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' } }); }
@@ -0,0 +1,7 @@
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { gameFileError, serveGameFile } from '$lib/server/game-files/http.js';
export async function GET({ request, params }: { request: Request; params: { id: string; path: string } }) {
try { authenticateAgent(request); return await serveGameFile(request, params.id, params.path); }
catch (cause) { return gameFileError(cause); }
}
export const HEAD = GET;
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { getLibraryVersion } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try {
authenticateAgent(request);
const version = getLibraryVersion(params.id);
if (!version || version.state !== 'ready') return json({ error: 'Published library not found.' }, { status: 404 });
return json({ id: version.id, game: version.game, files: version.files, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 });
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { updateHostLibraryCaches } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request); const body = await readGameJson(request);
updateHostLibraryCaches(host.id, [{ ...body, libraryVersionId: params.id }]);
return json({ ok: true });
} catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { pendingHostOperations } from '$lib/server/hosts/index.js';
import { authenticateAgent } from '$lib/server/hosts/http.js';
export function GET({ request }: { request: Request }) {
const host = authenticateAgent(request);
return json({ operations: pendingHostOperations(host.id), serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } });
}
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { reportHostOperation } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
const host = authenticateAgent(request);
try { const body = await objectBody(request, 128 * 1024 * 1024); const operation = reportHostOperation(host.id, params.id, { state: body.state, result: body.result, error: body.error }); return json({ operation: { id: operation.id, state: operation.state } }); }
catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,6 @@
import { error } from '@sveltejs/kit';
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET({ params }: { params: { file: string } }) {
if (!['ec-agent.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
}
+10
View File
@@ -0,0 +1,10 @@
import type { RequestHandler } from './$types';
import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try {
const result = await authenticatePassword(await authBody(request),getClientAddress());
revokeSession(cookies.get(sessionCookie(url)));
return sessionResponse(result,cookies,url);
} catch (cause) { return authError(cause); }
};
+9
View File
@@ -0,0 +1,9 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { recordAudit, revokeSession } from '$lib/server/auth/index.js';
import { sessionCookie } from '$lib/server/auth/http.js';
export const POST: RequestHandler = ({ cookies, url, locals }) => {
revokeSession(cookies.get(sessionCookie(url)));
cookies.delete(sessionCookie(url),{path:'/'});
recordAudit(locals.user?.id??null,'logout',locals.user?.id??null);
return json({ ok:true });
};
+2
View File
@@ -0,0 +1,2 @@
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = ({ locals }) => json({ user:locals.user },{headers:{'Cache-Control':'no-store'}});
+11
View File
@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';
import { changePassword, consumeAuthAttempt } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, locals, url, getClientAddress }) => {
try {
const user = requireUser(locals.user);
consumeAuthAttempt(getClientAddress(),user.id,'password');
const body = await authBody(request);
return sessionResponse(await changePassword(user.id,body.currentPassword,body.newPassword),cookies,url);
} catch (cause) { return authError(cause); }
};
+7
View File
@@ -0,0 +1,7 @@
import type { RequestHandler } from './$types';
import { bootstrapAdmin } from '$lib/server/auth/index.js';
import { authBody, authError, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try { return sessionResponse(await bootstrapAdmin(await authBody(request),getClientAddress()),cookies,url); }
catch (cause) { return authError(cause); }
};
@@ -4,6 +4,6 @@ import { previewCollection } from '$lib/server/fleet/collections.js';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
return json(previewCollection(body?.blueprintId, body?.mode));
return json(previewCollection(body?.blueprintId, body?.mode, body));
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
}
+10
View File
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { createLibraryImport, libraryOverview } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
export function GET() { return json(libraryOverview()); }
export async function POST({ request }: { request: Request }) {
try { requireManagementOrigin(request); return json({ version: createLibraryImport(await readGameJson(request)) }, { status: 202 }); }
catch (cause) { return gameFileError(cause); }
}
@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import { getLibraryVersion } from '$lib/server/game-files/index.js';
export function GET({ params }: { params: { id: string } }) {
const version = getLibraryVersion(params.id);
return version ? json({ version }) : json({ error: 'Library version not found.' }, { status: 404 });
}
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { queueLibraryDownload } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
requireManagementOrigin(request);
const body = await readGameJson(request);
if (typeof body.hostId !== 'string') throw new Error('Choose a target host.');
return json({ operation: queueLibraryDownload(body.hostId, params.id) }, { status: 202 });
} catch (cause) { return gameFileError(cause); }
}
+13
View File
@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { createEnrollment, createHost, listHosts } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts() }); }
export async function POST({ request }: { request: Request }) {
requireManagementOrigin(request);
try {
const host = createHost(await objectBody(request));
const enrollment = createEnrollment(host.id);
return json({ host, enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { status: 201, headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
}
+14
View File
@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { getHost, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
type Event = { request: Request; params: { id: string } };
export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
// Payloads and results can contain RCON keys or staged credentials. History
// exposes metadata only; agent APIs remain the only command delivery path.
return json({ host, operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
}
export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request);
try { return json({ host: updateHost(params.id, await objectBody(request)) }); } catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { queueHostOperation, revokeCredentials } from '$lib/server/hosts/index.js';
import { hostApiError, requireManagementOrigin } from '$lib/server/hosts/http.js';
type Event = { request: Request; params: { id: string } };
export function DELETE({ request, params }: Event) {
requireManagementOrigin(request);
try { revokeCredentials(params.id); return json({ ok: true }); } catch (cause) { return hostApiError(cause); }
}
export function POST({ request, params }: Event) {
requireManagementOrigin(request);
try { const operation = queueHostOperation(params.id, 'credentials.rotate', {}); return json({ operationId: operation.id }, { status: 202 }); } catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { createEnrollment } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, requireManagementOrigin } from '$lib/server/hosts/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
requireManagementOrigin(request);
try { const enrollment = createEnrollment(params.id); return json({ enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
}
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { queueHostOperation } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
requireManagementOrigin(request);
try {
const body = await objectBody(request);
if (body.kind !== 'runtime.pull' || typeof body.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)) throw new Error('Provide a runtime.pull request and a valid container image.');
const operation = queueHostOperation(params.id, 'runtime.pull', { image: body.image });
return json({ operationId: operation.id }, { status: 202 });
} catch (cause) { return hostApiError(cause); }
}
+13 -2
View File
@@ -7,17 +7,24 @@ import { queueCreateServer } from '$lib/server/provisioning/index.js';
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
import { runningVersions } from '$lib/server/integrations/plutonium.js';
import { listHosts } from '$lib/server/hosts/index.js';
import { nativeInstance } from '$lib/server/backends/native.js';
export async function GET() {
const rconHost = config.rcon.host;
const hosts = listHosts();
const hostsById = new Map(hosts.map(host => [host.id, host]));
const versions = new Map(runningVersions().map(version => [version.server, version]));
const servers = listServers().map((s) => {
const instance = s.native_instance_id ? nativeInstance(s.name) : null;
return {
...serverObservation(s),
host: s.host_id ? hostsById.get(s.host_id) ?? null : null,
network: instance ? { bindAddress: instance.bind_address, localPort: instance.port, publicAddress: instance.public_address, publicPort: instance.public_port } : null,
plutonium: versions.get(s.name),
// Derived endpoint — same logic as endpointFor() but without asking
// Komodo. If we don't have a port yet, endpoint is null.
endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
endpoint: instance ? { host: `agent:${instance.host_id}`, port: instance.port } : s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
held: [...heldByBeta(s.name).entries()].map(([target, betaId]) => ({ target, betaId }))
};
});
@@ -26,6 +33,9 @@ export async function GET() {
serverNow: Date.now(),
reservedNames: (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server),
integrations: {
native: hosts.length > 0 || !komodoConfigured(),
hostsReady: hosts.filter(host => host.readiness === 'ready').length,
hostsTotal: hosts.length,
rcon: rconConfigured(),
komodo: komodoConfigured(),
rconTimeoutMs: config.rcon.timeoutMs,
@@ -36,6 +46,7 @@ export async function GET() {
export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as {
hostId?: string; libraryId?: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number;
game?: string;
gameMode?: string;
mapSlug?: string;
@@ -56,7 +67,7 @@ export async function POST({ request }: { request: Request }) {
plutoKey: body.plutoKey,
expectedName: body.expectedName,
previewId: body.previewId,
createdBy: 'ui'
createdBy: 'ui', hostId: body.hostId, libraryId: body.libraryId, image: body.image, bindAddress: body.bindAddress, publicAddress: body.publicAddress, publicPort: body.publicPort
});
if (!result.ok) {
const status = result.kind === 'bad-request' ? 400 : result.kind === 'unconfigured' ? 503
+19
View File
@@ -6,12 +6,31 @@ import { serverObservation } from '$lib/server/fleet/status.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { tcpProbe } from '$lib/server/integrations/netProbe.js';
import type { SetupCheck, SystemStatus } from '$lib/system-status.js';
import { listHosts } from '$lib/server/hosts/index.js';
import { listLibraries } from '$lib/server/game-files/index.js';
export async function GET({ url }: { url: URL }) {
const servers = listServers();
const readyServers = servers.filter(server => serverObservation(server).health === 'online').length;
const rconServers = servers.filter(server => !server.last_error && freshStatusTimestamp(server.last_seen_at, Date.now()) && server.container_state === 'running').length;
const savedKeys = listServerKeys().length;
const hosts = listHosts();
if (hosts.length || !komodoConfigured()) {
const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected);
const versions = listLibraries().flatMap(library => library.versions).filter(version => version.state === 'ready');
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Prepare a Plutainer image on the host before creating a server.', href: '/hosts', action: 'Prepare runtime' },
{ id: 'game-files', title: 'Verified game-file libraries', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} published versions; ${cached.length} have a verified cache on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a ready host and a game library when creating your first server.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
];
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
}
const missing = [!config.komodo.address && 'KOMODO_ADDRESS', !config.komodo.apiKey && 'KOMODO_API_KEY', !config.komodo.apiSecret && 'KOMODO_API_SECRET'].filter(Boolean);
const connection: SetupCheck = { id: 'komodo', title: 'Komodo connection', state: komodoConfigured() ? 'unverified' : 'unconfigured', detail: komodoConfigured() ? 'Credentials configured. Run connection check to verify host reachability.' : `Set ${missing.join(', ')} in the app environment.`, href: '#setup-guide', action: 'Setup instructions' };
if (komodoConfigured() && url.searchParams.get('probe') === '1') {
+10
View File
@@ -6,8 +6,18 @@ import { recordTelemetryRequest } from '$lib/server/fleet/telemetry-requests.js'
import { recordGameEvent } from '$lib/server/fleet/beta-tests.js';
import { db } from '$lib/server/db/index.js';
import { emit } from '$lib/server/pipeline/bus.js';
import { authenticateTelemetry } from '$lib/server/auth/telemetry.js';
export async function POST({ request }: { request: Request }) {
const authentication = authenticateTelemetry(request);
if (!authentication.ok) {
// Unauthenticated bodies are never parsed, attributed to a server, or
// retained in telemetry diagnostics; they cannot affect game evidence.
return json({ ok: false, error: authentication.error }, {
status: authentication.status,
headers: { 'Cache-Control': 'no-store' }
});
}
const receivedAt = new Date().toISOString();
const contentType = request.headers.get('content-type');
let value: unknown;
+8
View File
@@ -0,0 +1,8 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { createUser, listUsers } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const GET: RequestHandler = () => json({ users:listUsers() });
export const POST: RequestHandler = async ({ request, locals }) => {
try { return json({ user:await createUser(requireUser(locals.user).id,await authBody(request)) },{status:201}); }
catch (cause) { return authError(cause); }
};
+8
View File
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { updateUser } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const PATCH: RequestHandler = async ({ request, params, locals }) => {
try { return json({ user:await updateUser(requireUser(locals.user).id,params.id,await authBody(request)) }); }
catch (cause) { return authError(cause); }
};
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { revokeUserSessions } from '$lib/server/auth/index.js';
import { authError, requireUser } from '$lib/server/auth/http.js';
export const POST: RequestHandler = ({ params, locals }) => {
try { revokeUserSessions(requireUser(locals.user).id,params.id); return json({ok:true}); }
catch (cause) { return authError(cause); }
};

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