20 Commits

Author SHA1 Message Date
Vantz Stockwell
702de24e28 fix(ci): fetch minisign static binary (not in bullseye apt); bump alpha.7
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 43s
Build Host Agent (Rust) / build (push) Failing after 1m33s
CI / integration (push) Successful in 22s
alpha.6 signing failed: 'E: Unable to locate package minisign' —
minisign isn't packaged for node:20-bullseye. Download the official
static linux binary instead. Forward to alpha.7 (alpha.6 published
nothing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:18:08 -04:00
Vantz Stockwell
6b3e805ac2 feat(host-agent): Phase 3a signed self-update (minisign) + CI signing gate
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m27s
CI / integration (push) Successful in 21s
Build Host Agent (Rust) / build (push) Failing after 1m33s
Agent only ever runs a binary whose minisign signature verifies against
the EMBEDDED public key. NATS host.cmd func 'update' {url}: download
binary + .minisig from the CDN -> verify against embedded pubkey ->
atomic swap (.old rollback) -> relaunch. URL allowlist (https + cdn.
corrosionmgmt.com only, rejects userinfo-bypass), 100MiB cap. Closes the
supply-chain hole: even a malicious CDN upload can't run unsigned.

CI: build-host-agent.yml signs every artifact with MINISIGN_SECRET_KEY
(Gitea secret) and publishes .minisig alongside; the step FAILS the
build if the secret is absent (refuses to ship unsigned). Bumped to
alpha.6.

6 deterministic tests (accept valid / reject tampered+garbage+empty sig,
URL allowlist incl userinfo-bypass, atomic swap+rollback). Fixtures
signed with the real release key so tests need no key at runtime. Full
suite 50/50 green; musl + native build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:00:36 -04:00
Vantz Stockwell
7c84912ff5 chore(frontend): bump version 1.0.0 -> 1.0.1
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 50s
CI / integration (push) Successful in 28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:38:52 -04:00
Vantz Stockwell
355a53f6e3 feat(files): native instance-scoped file browser (replaces broken VueFinder)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 22s
FileManagerView rewritten as a native DS browser on the per-instance
file bridge: instance selector, breadcrumb nav, dir-first listing
(name/size/modified), folder drill-down, inline file editor (read/save),
toolbar (new folder/file/refresh), per-row rename + delete-confirm.
New files store wraps the /instances/:id/files* endpoints. VueFinder
import + RemoteDriver fully removed — no more retired-protocol /api/files.
Honest empty (no instance -> Server page) + error (retry) states, never
the global error boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:31:01 -04:00
Vantz Stockwell
589516a021 feat(api): complete per-instance file op-set (delete/rename/mkdir/mkfile/move/copy)
All checks were successful
CI / backend-types (push) Successful in 8s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 54s
CI / integration (push) Successful in 25s
Rounds out the per-instance file bridge to the agent's full jailed file
manager so a real file browser can be built on it: POST :id/files/
{delete,rename,mkdir,mkfile,move,copy}, all via requestScoped (license-
scoped reply) on the new agent {op,path} protocol. files.manage. The
broken legacy VueFinder /api/files (retired Go fm_* protocol, wrong
subject, default _INBOX) is superseded by this — frontend rewrite next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:24:31 -04:00
Vantz Stockwell
f60e6abd33 feat(server): config file editor — read/edit/save a host config file per instance
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 21s
The Server page's config-honesty note now leads somewhere real: a
Configuration file panel that loads a config file from the instance
(prefilled with the game's primaryConfigFile hint — server.cfg,
ServerSettings.ini, GameXishu.json), edits it in a mono textarea, and
saves it straight to the host through the jailed agent file bridge.
Not-found is handled gracefully (empty editor to create). Works across
games; gameProfiles gains primaryConfigFile per game.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:07:59 -04:00
Vantz Stockwell
877fadcb6c feat(api): per-instance file bridge — list/read/write via the new agent file manager
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 21s
GET /api/instances/:id/files (list) + /file (read), PUT /file (write) —
tenant-guarded, routed through requestScoped to the per-instance
corrosion.{license}.{instance}.files.cmd using the new agent's {op,path}
protocol (jailed to the instance root, symlink-safe). files.view /
files.manage perms. Foundation for the per-game config editor and for
fixing the legacy VueFinder File Manager (which still speaks the retired
Go fm_* protocol on the wrong subject and is broken under per-license
auth — separate reconciliation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:00:28 -04:00
Vantz Stockwell
e897a4802f fix(server): apply lifecycle reply state optimistically (heartbeat lag)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 21s
The agent reply is authoritative for the action just taken; the fleet
DB only updates on the next heartbeat (~10s), so the immediate refetch
read a stale state and reverted the UI (Start -> still Stopped). Now
apply the reply's state/uptime directly to the instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:41:19 -04:00
Vantz Stockwell
c0b20f2f78 feat(server): instance-centric controls — real per-instance state + lifecycle
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 55s
CI / integration (push) Successful in 22s
The Server page now manages the selected GAME INSTANCE, not the legacy
host connection. New instances store flattens the fleet and drives the
command bridge. New 'Game instance' panel: real state badge
(running/stopped/crashed/configured), uptime, host, and an instance
selector when >1. Start/Stop/Restart/Refresh wired to POST
/api/instances/:id/lifecycle — gated on the actual instance state (not
host connectivity), with telemetry-only instances flagged. Works across
all four games (state + lifecycle are game-agnostic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:37:53 -04:00
Vantz Stockwell
06e832fca1 feat(fleet): remove host — DELETE /api/fleet/hosts/:id + Fleet card action
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 36s
CI / integration (push) Successful in 21s
Self-service host removal. DELETE /api/fleet/hosts/:id (server.manage,
tenant-guarded): refuses while the host is 'connected' (409 — a live
agent re-registers on its next heartbeat, stop it first), deletes the
host's game_instances explicitly (FK is SET NULL, would otherwise
orphan them; instance_stats cascade), and clears the legacy
server_connections row if it was the license's last host. Fleet view:
offline host cards get a Remove button with inline confirm + toast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:21:04 -04:00
Vantz Stockwell
009ceb86ad feat(server): real agent credentials + agent.toml setup; per-game config honesty
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 22s
Server page Host-agent panel now fetches GET /api/servers/agent-
credentials and renders the real agent.toml (license UUID, nats_user,
nats_password) instead of the broken LICENSE_ID=license_key env
commands that would never connect. Password masked by default with a
reveal toggle; copy-to-clipboard uses the real value. Setup commands
point at --config /etc/corrosion/agent.toml.

Configuration panel: World size / Current seed (Rust-only Facepunch
concepts) gated behind isRust; Conan/Soulmask/Dune get an honest note
pointing to the File Manager for their real config files instead of
fake Rust fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:23:47 -04:00
Vantz Stockwell
6f31c41dc3 feat(api): instance command bridge + agent credentials endpoint
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 21s
Backend layer wiring the panel to the host agent's per-instance command
channel (the unblocker for the Server-page rework):
- NatsService.requestScoped(): request-reply with a LICENSE-SCOPED reply
  subject (corrosion.{license}.reply.<id>) so per-license-scoped agents
  (no _INBOX permission) can actually reply — the design from the NATS
  auth work, now exercised.
- InstancesModule: POST /api/instances/:id/lifecycle {action} (start/
  stop/restart/status/steam_update, server.manage) and POST :id/rcon
  {command} (server.console). Tenant-guarded via game_instances.
- GET /api/servers/agent-credentials: derives the agent's NATS user/
  password (HMAC) so a customer can configure their agent — closes the
  post-auth setup gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:05:22 -04:00
Vantz Stockwell
99433a09d1 docs(claude): Lesson 27 — lint infra config before deploy; compose up -d recreates changed deps
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:53:06 -04:00
Vantz Stockwell
b442ef4102 fix(api): consumer rejects malformed heartbeats with no host block (no phantom hosts)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 41s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:49:53 -04:00
Vantz Stockwell
856106174a fix(nats): no_auth_user is top-level, not inside authorization{} — broke broker startup
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 22s
Caught during the live cutover: nats-server rejects 'unknown field
no_auth_user' when it is nested in the authorization block, taking the
whole broker down. Both the generator (open stage) and the committed
bootstrap default emitted it nested. Moved to top level. Enforce-stage
output was unaffected (no no_auth_user), which is what the live broker
now runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:47:14 -04:00
Vantz Stockwell
463908b18e fix(nats): security review — secure-by-default + per-tenant inbox isolation
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 23s
Two HIGH findings from automated review on the generator, both fixed:
1. Cross-tenant inbox access: per-license users were granted _INBOX.>,
   letting license A subscribe to license B's request-reply responses.
   Now scoped to corrosion.{license}.> ONLY; replies must ride the
   license namespace (corrosion.{license}.reply.<id>) — documented in
   PROTOCOL.md. Agent unchanged (responds to msg.reply); constraint is
   on the requester (internal user has full >).
2. Default-open auth bypass: generator defaulted to stage=open with a
   full-access anonymous user — a stale regen left the broker wide open.
   Now defaults to enforce (secure by default); the explicit 'open'
   migration stage maps anonymous to a harmless corrosion.unclaimed.>
   namespace, never real tenant subjects. Committed bootstrap default
   hardened the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:39:31 -04:00
Vantz Stockwell
00cff51ce5 feat(nats): per-license auth mechanism — agent user/password, scoped broker, generator (non-breaking)
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 1m23s
Build Host Agent (Rust) / build (push) Successful in 1m38s
CI / integration (push) Successful in 23s
Closes the open broker (anonymous publish to any tenant's corrosion.*).
Per-license isolation via NATS user/password + subject permissions:
each license -> user=license_id, password=HMAC-SHA256(license_id,
NATS_TOKEN_SECRET), scoped to corrosion.{license_id}.> + _INBOX. Backend
uses a privileged internal user.

- Agent (alpha.5): nats_user/nats_password config + env, user_and_password
  auth; falls back to token/anonymous (transition-safe)
- Backend: connects with NATS_INTERNAL_USER/PASSWORD when set, else anon
- scripts/generate-nats-auth.mjs: regenerates nats-auth.conf from the
  licenses table; NATS_AUTH_STAGE=open keeps a no_auth_user fallback
  (verify creds first), =enforce rejects anonymous
- committed nats-auth.conf is the SAFE OPEN default (no secrets); the
  host copy carries real users and is not committed
- compose: NATS_INTERNAL_USER/PASSWORD/NATS_TOKEN_SECRET, mount nats-auth.conf

Entirely non-breaking until secrets+config deployed; staged cutover next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:33:27 -04:00
Vantz Stockwell
7a07d600e7 feat(fleet): Phase B — fleet overview UI + GET /api/fleet read endpoint
Tenant-scoped fleet read: GET /api/fleet returns agent_hosts (host
metrics) each with their game_instances, plus a summary
(host/instance/online counts). FleetView lists host cards (status, CPU/
mem/disk/uptime/last-heartbeat) with their instances (game, state badge,
uptime); honest empty state -> Server page when no hosts. New 'Fleet'
sidebar nav item across all four game profiles, /fleet route. Store
follows the no-throw-on-fetch pattern (error state, never bricks). The
marketing hero made real from the live fleet tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:32:55 -04:00
Vantz Stockwell
4a4ae7a5d4 docs(claude): Lesson 26 — jail-at-entry doesn't jail the recursive walk (security review caught what my review missed)
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 41s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:04:23 -04:00
Vantz Stockwell
930f655bf5 feat(api): fleet data model Phase A — License -> Host -> Instance
All checks were successful
CI / backend-types (push) Successful in 14s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 22s
Migration 022 adds agent_hosts / game_instances / instance_clusters /
instance_stats (named agent_hosts to avoid the existing B2B hosts
table). HostAgentConsumerService now parses the full v2 heartbeat and
upserts an agent_hosts row (host metrics: cpu/mem/disk/agent version,
keyed by license_id+hostname until enrollment) plus one game_instances
row per heartbeat instance entry (state + uptime, the billing unit).
Legacy server_connections write retained so the current panel keeps
working — additive migration, nothing breaks. Staleness sweep + offline
beacon now flip agent_hosts too. cluster_id FK reserved for Soulmask/
Dune. Migration applied to live DB; tsc green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:00:52 -04:00
45 changed files with 3800 additions and 163 deletions

View File

@@ -67,6 +67,29 @@ jobs:
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
cat checksums.txt
- name: Sign artifacts (minisign)
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
run: |
if [ -z "$MINISIGN_SECRET_KEY" ]; then
echo "::error::MINISIGN_SECRET_KEY secret is not set — refusing to publish unsigned agent artifacts."
exit 1
fi
# minisign isn't packaged for bullseye — fetch the official static binary.
curl -sSL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz -o /tmp/minisign.tgz
tar -xzf /tmp/minisign.tgz -C /tmp
MINISIGN="$(find /tmp -type f -name minisign -path '*linux*' | head -1)"
chmod +x "$MINISIGN"
"$MINISIGN" -v
printf '%s\n' "$MINISIGN_SECRET_KEY" > /tmp/sign.key
cd corrosion-host-agent/bin
# Passwordless key (-W generated); feed empty stdin so it never blocks.
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
"$MINISIGN" -S -s /tmp/sign.key -m "$f" -x "$f.minisig" < /dev/null
done
rm -f /tmp/sign.key
echo "signed: $(ls *.minisig)"
- name: Create Release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
@@ -82,7 +105,9 @@ jobs:
"${API_URL}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
checksums.txt checksums.txt.minisig; do
curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
@@ -95,7 +120,9 @@ jobs:
CDN_URL="https://cdn.corrosionmgmt.com"
VERSION="${{ steps.version.outputs.VERSION }}"
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
checksums.txt checksums.txt.minisig; do
curl -s -X POST \
-F "file=@corrosion-host-agent/bin/$f" \
"${CDN_URL}/host-agent/alpha/$f"

View File

@@ -447,3 +447,7 @@ Things I discovered about myself building a sister platform across multiple sess
24. **`onModuleInit` runs before async `onModuleInit` of dependencies completes — register NATS/external subscriptions in `onApplicationBootstrap`.** `NatsService.onModuleInit` connects to NATS (async); `NatsBridgeService`/`HostAgentConsumerService` registered their subscriptions in their own `onModuleInit`, which fired while the connection was still null — so every `subscribe()` hit the `[OFFLINE]` no-op path and the WS bridge was dead-on-boot in *every* production build, silently. Nest guarantees `onApplicationBootstrap` runs only after all module init (including the awaited connect) finishes. Anything that depends on another provider's async startup belongs in bootstrap, not init. The tell: a subscription that "should be there" but the handler never fires and there's no error — trace the *startup ordering*, not the handler.
25. **Fixing a dead code path detonates the live code behind it — budget for the second bug.** The moment Lesson 24's fix made the NATS→WS bridge actually deliver events, the API crashed on the first forwarded heartbeat: `WebSocket.OPEN` was `undefined` at runtime because `esModuleInterop` is off, so `import WebSocket from 'ws'` compiled to `ws_1.default` (undefined). That crash had sat behind the dead bridge since the gateway was written — never hit because no event ever reached it. When you resurrect a path that was silently no-op, everything downstream of it is effectively *untested code running for the first time in production*. Verify the whole chain end-to-end (I watched the DB row appear, then flip offline), don't stop at "the subscription fires now." This is Lesson 10 with a fuse on it. Import-runtime gotcha worth remembering: when `esModuleInterop` is off, prefer instance constants (`client.OPEN`) over class statics (`WebSocket.OPEN`) for `ws`.
26. **A jail check at the entry point does not jail the recursive walk behind it — and my own "line-by-line" review missed it; the automated security review didn't.** The file manager's `jail()` correctly canonicalized and prefix-checked the top-level path, and I traced every escape vector through it and signed off. But `copy_recursive` then walked the directory tree with `fs::metadata` (which *follows* symlinks). A symlink planted inside the jail pointing at `/etc`, then a `copy` of its parent, would dereference it and pull external content *into* the jail to be read — a jail escape the entry check never sees, because the escape is reintroduced by a descendant during traversal. Fix: `symlink_metadata` (lstat) everywhere you recurse, and refuse/never-follow symlinks across the boundary. The transferable rule: **validate at the boundary AND at every step that re-derives a path** (recursion, `read_dir`, glob, archive extraction). And the humbling part — I was confident after reviewing the jail function; the security-review pass caught the HIGH I'd waved through. Trust adversarial verification over your own once-over on security-critical code, especially path/traversal logic.
27. **Validate infra config BEFORE it reaches a deploy — and know that `docker compose up -d <service>` will recreate other services whose definitions changed.** During the NATS auth cutover I ran `docker compose up -d api` to pick up new env. Because the *nats* service definition had also changed (a new volume mount), compose recreated **corrosion-nats too** — and it failed to start on a config error (`no_auth_user` nested inside `authorization{}` instead of at top level), taking the broker down for ~3 minutes with the backend in offline mode. Two lessons: (a) a broker/proxy/DB config file is code — lint it before it can reach a restart (`nats-server -t -c cfg` to test-parse, `nginx -t`, etc.), don't let the first validation be the production container's startup; (b) `compose up -d <one-service>` is not surgical — it reconciles that service's **dependencies** too, so a stale edit to a depended-on service ships when you didn't mean it to. When touching shared-infra config, restart that service explicitly and watch it come up before moving on. Recovery also surfaced a third gotcha: recreating a client (api) while its server (nats) is down leaves the client stuck on a cached DNS failure (`EAI_AGAIN`) — restart the client once the server is healthy.

View File

@@ -45,6 +45,8 @@ import { BetterChatModule } from './modules/betterchat/betterchat.module';
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
import { EarlyAccessModule } from './modules/early-access/early-access.module';
import { FleetModule } from './modules/fleet/fleet.module';
import { InstancesModule } from './modules/instances/instances.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -52,6 +54,8 @@ import { NatsBridgeService } from './services/nats-bridge.service';
import { HostAgentConsumerService } from './services/host-agent-consumer.service';
import { ServerConnection } from './entities/server-connection.entity';
import { License } from './entities/license.entity';
import { AgentHost } from './entities/agent-host.entity';
import { GameInstance } from './entities/game-instance.entity';
import { SteamService } from './services/steam.service';
// Gateway
@@ -95,7 +99,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
ScheduleModule.forRoot(),
// Repositories for app-level shared services (host-agent consumer)
TypeOrmModule.forFeature([ServerConnection, License]),
TypeOrmModule.forFeature([ServerConnection, License, AgentHost, GameInstance]),
// Feature Modules
AuthModule,
@@ -131,6 +135,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
FleetModule,
InstancesModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -6,6 +6,15 @@ export default () => ({
},
nats: {
url: process.env.NATS_URL || 'nats://localhost:4222',
// Public broker address shown to agents in setup instructions.
publicUrl: process.env.NATS_PUBLIC_URL || 'nats://nats.corrosionmgmt.com:4222',
// Privileged internal credentials for the backend's own NATS connection
// (full corrosion.> access). Empty = anonymous (transition period).
internalUser: process.env.NATS_INTERNAL_USER || '',
internalPassword: process.env.NATS_INTERNAL_PASSWORD || '',
// Secret used to derive a per-license agent password:
// HMAC-SHA256(license_id, secret). Shared with the nats.conf generator.
tokenSecret: process.env.NATS_TOKEN_SECRET || '',
},
jwt: {
secret: process.env.JWT_SECRET || 'change-me',

View File

@@ -0,0 +1,74 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check, Unique } from 'typeorm';
import { License } from './license.entity';
export interface AgentHostDisk {
mount: string;
total_mb: number;
free_mb: number;
}
/**
* One Corrosion host agent / one machine. Owns the machine-level facts.
*
* NOTE: distinct from the B2B `hosts` table (hosting-partner companies). This
* is `agent_hosts` — the physical/virtual box a customer runs the agent on.
*/
@Entity('agent_hosts')
@Unique(['license_id', 'hostname'])
@Check(`"status" IN ('connected', 'degraded', 'offline')`)
export class AgentHost {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 255, default: '' })
hostname: string;
@Column({ type: 'varchar', length: 64, nullable: true })
agent_version: string | null;
@Column({ type: 'varchar', length: 64, nullable: true })
agent_commit: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
os: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
arch: string | null;
@Column({ type: 'varchar', length: 20, default: 'offline' })
status: string;
@Column({ type: 'timestamptz', nullable: true })
last_heartbeat_at: Date | null;
@Column({ type: 'double precision', nullable: true })
cpu_percent: number | null;
@Column({ type: 'integer', nullable: true })
cpu_cores: number | null;
@Column({ type: 'bigint', nullable: true })
mem_total_mb: number | null;
@Column({ type: 'bigint', nullable: true })
mem_used_mb: number | null;
@Column({ type: 'bigint', nullable: true })
uptime_seconds: number | null;
@Column({ type: 'jsonb', nullable: true })
disks: AgentHostDisk[] | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,59 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { License } from './license.entity';
import { AgentHost } from './agent-host.entity';
/**
* One game server process / orchestrated unit (a Rust server, a Conan world,
* a Dune battlegroup). The billing unit — plans count instances.
* `agent_instance_id` is the agent's slug and the NATS subject segment.
*/
@Entity('game_instances')
@Unique(['license_id', 'agent_instance_id'])
export class GameInstance {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid', nullable: true })
host_id: string | null;
@Column({ type: 'uuid', nullable: true })
cluster_id: string | null;
@Column({ type: 'varchar', length: 64 })
agent_instance_id: string;
@Column({ type: 'varchar', length: 32 })
game: string;
@Column({ type: 'varchar', length: 255, nullable: true })
label: string | null;
@Column({ type: 'varchar', length: 32, default: 'unknown' })
state: string;
@Column({ type: 'text', nullable: true })
root_path: string | null;
@Column({ type: 'bigint', default: 0 })
uptime_seconds: number;
@Column({ type: 'timestamptz', nullable: true })
last_seen_at: Date | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => AgentHost, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'host_id' })
host: AgentHost | null;
}

View File

@@ -0,0 +1,38 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
/**
* Optional grouping of instances for games with linked topologies:
* Soulmask main/child clusters, Dune BattleGroup → Sietches. Reserved now;
* cluster orchestration ships with those game adapters.
*/
@Entity('instance_clusters')
export class InstanceCluster {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 32 })
game: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'varchar', length: 32, nullable: true })
topology: string | null;
@Column({ type: 'jsonb', nullable: true })
config: Record<string, unknown> | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,38 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { GameInstance } from './game-instance.entity';
/**
* Per-instance time-series game metrics (player count, FPS, …). Populated once
* game-level telemetry is collected via RCON/plugin — the host heartbeat
* carries host metrics, not game metrics, so this stays empty in Phase A.
*/
@Entity('instance_stats')
export class InstanceStats {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
instance_id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'integer', default: 0 })
player_count: number;
@Column({ type: 'integer', default: 0 })
max_players: number;
@Column({ type: 'double precision', default: 0 })
fps: number;
@Column({ type: 'integer', default: 0 })
memory_usage_mb: number;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
recorded_at: Date;
@ManyToOne(() => GameInstance, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'instance_id' })
instance: GameInstance;
}

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Delete, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('fleet')
@ApiBearerAuth()
@Controller('fleet')
export class FleetController {
constructor(private readonly fleetService: FleetService) {}
@Get()
@RequirePermission('server.view')
@ApiOperation({ summary: 'Get fleet overview — hosts and game instances for this license' })
async getFleet(@CurrentTenant() licenseId: string) {
return this.fleetService.getFleet(licenseId);
}
@Delete('hosts/:id')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Remove a host and its instances (host must be offline)' })
async deleteHost(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.fleetService.deleteHost(licenseId, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FleetController } from './fleet.controller';
import { FleetService } from './fleet.service';
import { AgentHost } from '../../entities/agent-host.entity';
import { GameInstance } from '../../entities/game-instance.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
@Module({
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance, ServerConnection])],
controllers: [FleetController],
providers: [FleetService],
exports: [FleetService],
})
export class FleetModule {}

View File

@@ -0,0 +1,170 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AgentHost } from '../../entities/agent-host.entity';
import { GameInstance } from '../../entities/game-instance.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
export interface FleetInstanceDto {
id: string;
agent_instance_id: string;
game: string;
label: string | null;
state: string;
uptime_seconds: number;
last_seen_at: string | null;
}
export interface FleetHostDto {
id: string;
hostname: string;
status: string;
agent_version: string | null;
os: string | null;
arch: string | null;
cpu_percent: number | null;
cpu_cores: number | null;
mem_total_mb: number | null;
mem_used_mb: number | null;
uptime_seconds: number | null;
disks: AgentHost['disks'];
last_heartbeat_at: string | null;
instances: FleetInstanceDto[];
}
export interface FleetSummaryDto {
host_count: number;
instance_count: number;
online_host_count: number;
}
export interface FleetResponseDto {
hosts: FleetHostDto[];
summary: FleetSummaryDto;
}
@Injectable()
export class FleetService {
constructor(
@InjectRepository(AgentHost)
private readonly hostRepo: Repository<AgentHost>,
@InjectRepository(GameInstance)
private readonly instanceRepo: Repository<GameInstance>,
@InjectRepository(ServerConnection)
private readonly connectionRepo: Repository<ServerConnection>,
) {}
/**
* Remove a host and its game instances from the fleet.
*
* Refuses while the host is `connected` — a live agent re-registers on its
* next heartbeat, so the operator must stop the agent first. Deletes the
* host's instances explicitly (the FK is SET NULL, which would otherwise
* orphan them); instance_stats cascade. If this was the license's last host,
* the legacy single-server connection row is cleared too so the old
* Dashboard doesn't show a stale server.
*/
async deleteHost(
licenseId: string,
hostId: string,
): Promise<{ deleted: true; instances_removed: number }> {
const host = await this.hostRepo.findOne({ where: { id: hostId, license_id: licenseId } });
if (!host) throw new NotFoundException('Host not found');
if (host.status === 'connected') {
throw new ConflictException(
'Host is online — stop the agent first, or it will re-register on its next heartbeat',
);
}
const del = await this.instanceRepo.delete({ license_id: licenseId, host_id: hostId });
await this.hostRepo.delete({ id: hostId, license_id: licenseId });
const remaining = await this.hostRepo.count({ where: { license_id: licenseId } });
if (remaining === 0) {
await this.connectionRepo.delete({ license_id: licenseId });
}
return { deleted: true, instances_removed: del.affected ?? 0 };
}
async getFleet(licenseId: string): Promise<FleetResponseDto> {
const [hosts, instances] = await Promise.all([
this.hostRepo.find({
where: { license_id: licenseId },
order: { hostname: 'ASC' },
}),
this.instanceRepo.find({
where: { license_id: licenseId },
order: { game: 'ASC', label: 'ASC' },
}),
]);
// Group instances by host_id. Bigint columns come back as strings from pg — coerce.
const instancesByHost = new Map<string | null, FleetInstanceDto[]>();
for (const inst of instances) {
const key = inst.host_id ?? null;
if (!instancesByHost.has(key)) {
instancesByHost.set(key, []);
}
instancesByHost.get(key)!.push({
id: inst.id,
agent_instance_id: inst.agent_instance_id,
game: inst.game,
label: inst.label,
state: inst.state,
uptime_seconds: Number(inst.uptime_seconds),
last_seen_at: inst.last_seen_at ? inst.last_seen_at.toISOString() : null,
});
}
const hostDtos: FleetHostDto[] = hosts.map((h) => ({
id: h.id,
hostname: h.hostname,
status: h.status,
agent_version: h.agent_version,
os: h.os,
arch: h.arch,
cpu_percent: h.cpu_percent !== null && h.cpu_percent !== undefined ? Number(h.cpu_percent) : null,
cpu_cores: h.cpu_cores !== null && h.cpu_cores !== undefined ? Number(h.cpu_cores) : null,
mem_total_mb: h.mem_total_mb !== null && h.mem_total_mb !== undefined ? Number(h.mem_total_mb) : null,
mem_used_mb: h.mem_used_mb !== null && h.mem_used_mb !== undefined ? Number(h.mem_used_mb) : null,
uptime_seconds: h.uptime_seconds !== null && h.uptime_seconds !== undefined ? Number(h.uptime_seconds) : null,
disks: h.disks,
last_heartbeat_at: h.last_heartbeat_at ? h.last_heartbeat_at.toISOString() : null,
instances: instancesByHost.get(h.id) ?? [],
}));
// Append synthetic "unassigned" bucket only if orphaned instances exist
const unassigned = instancesByHost.get(null) ?? [];
if (unassigned.length > 0) {
hostDtos.push({
id: '__unassigned__',
hostname: 'Unassigned',
status: 'offline',
agent_version: null,
os: null,
arch: null,
cpu_percent: null,
cpu_cores: null,
mem_total_mb: null,
mem_used_mb: null,
uptime_seconds: null,
disks: null,
last_heartbeat_at: null,
instances: unassigned,
});
}
const online_host_count = hosts.filter((h) => h.status === 'connected').length;
const instance_count = instances.length;
return {
hosts: hostDtos,
summary: {
host_count: hosts.length,
instance_count,
online_host_count,
},
};
}
}

View File

@@ -0,0 +1,133 @@
import { Controller, Post, Get, Put, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { InstancesService, LifecycleFunc } from './instances.service';
@ApiTags('instances')
@ApiBearerAuth()
@Controller('instances')
export class InstancesController {
constructor(private readonly instances: InstancesService) {}
@Post(':id/lifecycle')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Send a lifecycle command to a game instance (start/stop/restart/status/steam_update)' })
async lifecycle(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { action: LifecycleFunc },
) {
return this.instances.lifecycle(licenseId, id, body.action);
}
@Post(':id/rcon')
@RequirePermission('server.console')
@ApiOperation({ summary: 'Send an RCON/console command to a game instance' })
async rcon(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { command: string },
) {
return this.instances.rcon(licenseId, id, body.command);
}
@Get(':id/files')
@RequirePermission('files.view')
@ApiOperation({ summary: 'List a directory in the instance (jailed to its root)' })
async listFiles(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Query('path') path?: string,
) {
return this.instances.listFiles(licenseId, id, path ?? '');
}
@Get(':id/file')
@RequirePermission('files.view')
@ApiOperation({ summary: 'Read a text file from the instance (jailed, 5 MiB cap)' })
async readFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Query('path') path: string,
) {
return this.instances.readFile(licenseId, id, path);
}
@Put(':id/file')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Write a text file in the instance (jailed)' })
async writeFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; content: string },
) {
return this.instances.writeFile(licenseId, id, body.path, body.content ?? '');
}
@Post(':id/files/delete')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Delete a file or directory (jailed)' })
async deleteFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string },
) {
return this.instances.deleteFile(licenseId, id, body.path);
}
@Post(':id/files/rename')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Rename a file/directory within its parent (jailed)' })
async renameFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; name: string },
) {
return this.instances.renameFile(licenseId, id, body.path, body.name);
}
@Post(':id/files/mkdir')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Create a directory (jailed)' })
async mkdir(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string },
) {
return this.instances.mkdir(licenseId, id, body.path);
}
@Post(':id/files/mkfile')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Create an empty file (jailed)' })
async mkfile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string },
) {
return this.instances.mkfile(licenseId, id, body.path);
}
@Post(':id/files/move')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Move a file/directory (jailed)' })
async moveFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; dest: string },
) {
return this.instances.moveFile(licenseId, id, body.path, body.dest);
}
@Post(':id/files/copy')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Copy a file/directory (jailed)' })
async copyFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; dest: string },
) {
return this.instances.copyFile(licenseId, id, body.path, body.dest);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { InstancesController } from './instances.controller';
import { InstancesService } from './instances.service';
import { GameInstance } from '../../entities/game-instance.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([GameInstance])],
controllers: [InstancesController],
providers: [InstancesService, NatsService],
})
export class InstancesModule {}

View File

@@ -0,0 +1,145 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NatsService } from '../../services/nats.service';
import { GameInstance } from '../../entities/game-instance.entity';
/** Lifecycle funcs the agent's {instance}.cmd handler accepts. */
const LIFECYCLE_FUNCS = ['start', 'stop', 'restart', 'status', 'steam_update'] as const;
export type LifecycleFunc = (typeof LIFECYCLE_FUNCS)[number];
@Injectable()
export class InstancesService {
private readonly logger = new Logger(InstancesService.name);
constructor(
private readonly nats: NatsService,
@InjectRepository(GameInstance)
private readonly instanceRepo: Repository<GameInstance>,
) {}
/** Resolve an instance the caller's license actually owns (tenant guard). */
private async resolveInstance(licenseId: string, instanceId: string): Promise<GameInstance> {
const inst = await this.instanceRepo.findOne({
where: { id: instanceId, license_id: licenseId },
});
if (!inst) throw new NotFoundException('Instance not found');
return inst;
}
async lifecycle(licenseId: string, instanceId: string, func: LifecycleFunc): Promise<unknown> {
if (!LIFECYCLE_FUNCS.includes(func)) {
throw new BadRequestException(`Unsupported action '${func}'`);
}
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
this.logger.log(`instance ${inst.agent_instance_id}: ${func}`);
return this.nats.requestScoped(licenseId, subject, { func });
}
async rcon(licenseId: string, instanceId: string, command: string): Promise<unknown> {
if (!command || !command.trim()) {
throw new BadRequestException('command is required');
}
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
// RCON can take longer than a lifecycle ack — give it more headroom.
return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000);
}
// -------------------------------------------------------------------------
// File access — jailed to the instance root by the agent's file manager.
// The agent protocol (corrosion-host-agent/src/filemanager.rs):
// { op: list|read|write|delete|rename|mkdir|mkfile|move|copy, path, ... }
// reply: { status: 'success'|'error', data?, message? }
// -------------------------------------------------------------------------
private filesSubject(inst: GameInstance, licenseId: string): string {
return `corrosion.${licenseId}.${inst.agent_instance_id}.files.cmd`;
}
private async fileOp(
licenseId: string,
instanceId: string,
payload: Record<string, unknown>,
): Promise<{ status: string; data?: unknown; message?: string }> {
const inst = await this.resolveInstance(licenseId, instanceId);
const res = await this.nats.requestScoped<{ status: string; data?: unknown; message?: string }>(
licenseId,
this.filesSubject(inst, licenseId),
payload,
12_000,
);
if (res?.status === 'error') {
throw new BadRequestException(res.message ?? 'File operation failed');
}
return res;
}
async listFiles(licenseId: string, instanceId: string, path = ''): Promise<unknown> {
const res = await this.fileOp(licenseId, instanceId, { op: 'list', path });
return res.data;
}
async readFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
const res = await this.fileOp(licenseId, instanceId, { op: 'read', path });
return res.data;
}
async writeFile(
licenseId: string,
instanceId: string,
path: string,
content: string,
): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
const res = await this.fileOp(licenseId, instanceId, { op: 'write', path, content });
return res.data ?? { status: 'success' };
}
async deleteFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
return (await this.fileOp(licenseId, instanceId, { op: 'delete', path })).data ?? { ok: true };
}
async renameFile(
licenseId: string,
instanceId: string,
path: string,
name: string,
): Promise<unknown> {
if (!path || !name) throw new BadRequestException('path and name are required');
return (await this.fileOp(licenseId, instanceId, { op: 'rename', path, name })).data ?? { ok: true };
}
async mkdir(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
return (await this.fileOp(licenseId, instanceId, { op: 'mkdir', path })).data ?? { ok: true };
}
async mkfile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
return (await this.fileOp(licenseId, instanceId, { op: 'mkfile', path })).data ?? { ok: true };
}
async moveFile(
licenseId: string,
instanceId: string,
path: string,
dest: string,
): Promise<unknown> {
if (!path || !dest) throw new BadRequestException('path and dest are required');
return (await this.fileOp(licenseId, instanceId, { op: 'move', path, dest })).data ?? { ok: true };
}
async copyFile(
licenseId: string,
instanceId: string,
path: string,
dest: string,
): Promise<unknown> {
if (!path || !dest) throw new BadRequestException('path and dest are required');
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
}
}

View File

@@ -23,6 +23,13 @@ export class ServersController {
return await this.serversService.getServer(licenseId);
}
@Get('agent-credentials')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'NATS credentials for this license\'s host agent' })
async getAgentCredentials(@CurrentTenant() licenseId: string) {
return await this.serversService.getAgentCredentials(licenseId);
}
@Put('config')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Update server configuration' })

View File

@@ -19,6 +19,15 @@ export class ServersService {
private readonly natsService: NatsService,
) {}
/**
* NATS credentials the customer puts in their host agent's config so it can
* authenticate to the per-license-scoped broker. Returns null if the broker
* isn't enforcing auth yet (NATS_TOKEN_SECRET unset).
*/
async getAgentCredentials(licenseId: string) {
return this.natsService.getAgentCredentials(licenseId);
}
/**
* Get server connection and config for a license.
* Returns null fields if no server has been set up yet.

View File

@@ -5,30 +5,53 @@ import { Repository } from 'typeorm';
import { NatsService } from './nats.service';
import { ServerConnection } from '../entities/server-connection.entity';
import { License } from '../entities/license.entity';
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
import { GameInstance } from '../entities/game-instance.entity';
/**
* Consumes Corrosion wire protocol v2 host-agent subjects
* (corrosion-host-agent/PROTOCOL.md) and keeps server_connections truthful.
* (corrosion-host-agent/PROTOCOL.md) and keeps the fleet model truthful.
*
* Before this service existed, NOTHING persisted agent heartbeats:
* companion_last_seen was written once at setup and connection_status stayed
* 'connected' forever. Now: heartbeat -> last_seen + connected (row
* auto-created on first contact), going_offline beacon -> offline, and a
* staleness sweep marks hosts offline when heartbeats stop arriving.
* Writes the License → Host → Instance model (hosts + game_instances) from
* each heartbeat, AND maintains the legacy single-server `server_connections`
* row so the current panel keeps working during the fleet UI transition.
*
* Host identity: until enrollment issues a stable host id, a host is keyed by
* (license_id, hostname). One agent = one host today; the schema is already
* multi-host-ready.
*/
interface HeartbeatPayload {
schema?: number;
timestamp?: string;
agent?: { version?: string; commit?: string; os?: string; arch?: string };
host?: {
hostname?: string | null;
cpu_percent?: number;
cpu_cores?: number;
mem_total_mb?: number;
mem_used_mb?: number;
uptime_seconds?: number;
disks?: AgentHostDisk[];
};
instances?: Array<{
id: string;
game: string;
label?: string | null;
state?: string;
uptime_seconds?: number;
}>;
}
@Injectable()
export class HostAgentConsumerService implements OnApplicationBootstrap {
private readonly logger = new Logger(HostAgentConsumerService.name);
/** licenseId -> cache expiry epoch-ms. Positive = exists, absent = unknown. */
private knownLicenses = new Map<string, number>();
/** Unknown/garbage license ids we already warned about (anti log-spam). */
private warnedUnknown = new Set<string>();
private static readonly UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
private static readonly LICENSE_CACHE_TTL_MS = 5 * 60_000;
/** 3x the agent's default 60s heartbeat (which jitters to max 72s). */
private static readonly OFFLINE_AFTER_MS = 180_000;
constructor(
@@ -37,6 +60,10 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
private readonly connectionRepository: Repository<ServerConnection>,
@InjectRepository(License)
private readonly licenseRepository: Repository<License>,
@InjectRepository(AgentHost)
private readonly hostRepository: Repository<AgentHost>,
@InjectRepository(GameInstance)
private readonly instanceRepository: Repository<GameInstance>,
) {}
// Bootstrap, not module-init: subscriptions registered before NatsService
@@ -44,10 +71,9 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
onApplicationBootstrap() {
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
void this.onHeartbeat(licenseId).catch((err) =>
void this.onHeartbeat(licenseId, data as HeartbeatPayload).catch((err) =>
this.logger.error(`heartbeat handling failed for ${licenseId}: ${err.message}`, err.stack),
);
void data; // payload telemetry is bridged to the browser; persistence here is liveness only
});
this.nats.subscribe('corrosion.*.host.going_offline', (_data, subject) => {
@@ -60,25 +86,30 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
this.logger.log('Host agent (protocol v2) consumer subscriptions initialized');
}
private async onHeartbeat(licenseId: string): Promise<void> {
private async onHeartbeat(licenseId: string, payload: HeartbeatPayload): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
// A well-formed v2 heartbeat always carries a host block. Reject malformed
// payloads so a stray/empty publish can't create a phantom host row.
if (!payload || typeof payload.host !== 'object' || payload.host === null) {
this.logger.warn(`ignoring malformed heartbeat for license ${licenseId} (no host block)`);
return;
}
const now = new Date();
const existing = await this.connectionRepository.findOne({
where: { license_id: licenseId },
});
await this.updateLegacyConnection(licenseId, now);
const host = await this.upsertHost(licenseId, payload, now);
await this.upsertInstances(licenseId, host, payload, now);
}
/** Legacy single-server row — keeps the current panel working. */
private async updateLegacyConnection(licenseId: string, now: Date): Promise<void> {
const existing = await this.connectionRepository.findOne({ where: { license_id: licenseId } });
if (existing) {
await this.connectionRepository.update(
{ id: existing.id },
{ companion_last_seen: now, connection_status: 'connected', updated_at: now },
);
if (existing.connection_status !== 'connected') {
this.logger.log(`host agent for license ${licenseId} is back online`);
}
} else {
// First contact from a host agent: auto-register the connection so the
// panel lights up without a manual setup step.
await this.connectionRepository.save(
this.connectionRepository.create({
license_id: licenseId,
@@ -87,28 +118,102 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
companion_last_seen: now,
}),
);
this.logger.log(`host agent registered for license ${licenseId} (first heartbeat)`);
}
}
/** Upsert the fleet host row, keyed by (license_id, hostname). */
private async upsertHost(licenseId: string, payload: HeartbeatPayload, now: Date): Promise<AgentHost> {
const hostname = payload.host?.hostname ?? '';
const fields = {
agent_version: payload.agent?.version ?? null,
agent_commit: payload.agent?.commit ?? null,
os: payload.agent?.os ?? null,
arch: payload.agent?.arch ?? null,
status: 'connected',
last_heartbeat_at: now,
cpu_percent: payload.host?.cpu_percent ?? null,
cpu_cores: payload.host?.cpu_cores ?? null,
mem_total_mb: payload.host?.mem_total_mb ?? null,
mem_used_mb: payload.host?.mem_used_mb ?? null,
uptime_seconds: payload.host?.uptime_seconds ?? null,
disks: payload.host?.disks ?? null,
updated_at: now,
};
const existing = await this.hostRepository.findOne({
where: { license_id: licenseId, hostname },
});
if (existing) {
await this.hostRepository.update({ id: existing.id }, fields);
return { ...existing, ...fields } as AgentHost;
}
const created = await this.hostRepository.save(
this.hostRepository.create({ license_id: licenseId, hostname, ...fields }),
);
this.logger.log(`host registered for license ${licenseId} (hostname '${hostname || 'unknown'}')`);
return created;
}
/** Upsert one game_instances row per heartbeat instance entry. */
private async upsertInstances(
licenseId: string,
host: AgentHost,
payload: HeartbeatPayload,
now: Date,
): Promise<void> {
for (const inst of payload.instances ?? []) {
if (!inst?.id || !inst?.game) continue;
const fields = {
host_id: host.id,
game: inst.game,
label: inst.label ?? null,
state: inst.state ?? 'unknown',
uptime_seconds: inst.uptime_seconds ?? 0,
last_seen_at: now,
updated_at: now,
};
const existing = await this.instanceRepository.findOne({
where: { license_id: licenseId, agent_instance_id: inst.id },
});
if (existing) {
await this.instanceRepository.update({ id: existing.id }, fields);
} else {
await this.instanceRepository.save(
this.instanceRepository.create({
license_id: licenseId,
agent_instance_id: inst.id,
...fields,
}),
);
this.logger.log(`instance '${inst.id}' (${inst.game}) registered for license ${licenseId}`);
}
}
}
private async onGoingOffline(licenseId: string): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
const now = new Date();
await this.connectionRepository.update(
{ license_id: licenseId },
{ connection_status: 'offline', updated_at: new Date() },
{ connection_status: 'offline', updated_at: now },
);
this.logger.log(`host agent for license ${licenseId} went offline (graceful beacon)`);
await this.hostRepository.update(
{ license_id: licenseId },
{ status: 'offline', updated_at: now },
);
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
}
/**
* Heartbeats stopping must flip the panel to offline — an agent that
* crashes or loses network never sends the goodbye beacon.
* crashes or loses network never sends the goodbye beacon. Sweeps both the
* legacy connection and fleet hosts.
*/
@Interval(60_000)
async sweepStaleConnections(): Promise<void> {
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
const result = await this.connectionRepository
const conn = await this.connectionRepository
.createQueryBuilder()
.update(ServerConnection)
.set({ connection_status: 'offline', updated_at: () => 'NOW()' })
@@ -117,8 +222,18 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
.andWhere('companion_last_seen < :threshold', { threshold })
.execute();
if (result.affected) {
this.logger.warn(`marked ${result.affected} stale host connection(s) offline`);
const hosts = await this.hostRepository
.createQueryBuilder()
.update(AgentHost)
.set({ status: 'offline', updated_at: () => 'NOW()' })
.where('status = :connected', { connected: 'connected' })
.andWhere('last_heartbeat_at IS NOT NULL')
.andWhere('last_heartbeat_at < :threshold', { threshold })
.execute();
const affected = (conn.affected ?? 0) + (hosts.affected ?? 0);
if (affected) {
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
}
}
@@ -132,7 +247,6 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
this.warnUnknownOnce(licenseId, 'not a UUID');
return false;
}
const cachedUntil = this.knownLicenses.get(licenseId);
if (cachedUntil && cachedUntil > Date.now()) return true;
@@ -141,7 +255,6 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
this.warnUnknownOnce(licenseId, 'no such license');
return false;
}
this.knownLicenses.set(licenseId, Date.now() + HostAgentConsumerService.LICENSE_CACHE_TTL_MS);
return true;
}

View File

@@ -1,6 +1,14 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { connect, NatsConnection, StringCodec, Subscription } from 'nats';
import { createHmac, randomUUID } from 'crypto';
export interface AgentCredentials {
license_id: string;
nats_user: string;
nats_password: string;
nats_url: string;
}
@Injectable()
export class NatsService implements OnModuleInit, OnModuleDestroy {
@@ -13,8 +21,13 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
try {
const url = this.config.get<string>('nats.url') || 'nats://localhost:4222';
this.nc = await connect({ servers: url });
this.logger.log(`Connected to NATS at ${url}`);
const user = this.config.get<string>('nats.internalUser');
const pass = this.config.get<string>('nats.internalPassword');
// Authenticate with the privileged internal user when configured;
// otherwise connect anonymously (broker hasn't enforced auth yet).
const opts = user && pass ? { servers: url, user, pass } : { servers: url };
this.nc = await connect(opts);
this.logger.log(`Connected to NATS at ${url}${user ? ` as ${user}` : ' (anonymous)'}`);
} catch (err) {
this.logger.warn(`NATS connection failed — running in offline mode: ${(err as Error).message}`);
}
@@ -62,6 +75,64 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
return sub;
}
/**
* Request-reply to a host-agent subject with a LICENSE-SCOPED reply subject.
*
* Per-license agent users are confined to corrosion.{license}.> and have no
* _INBOX permission, so the agent cannot publish a reply to the default
* global inbox. The reply must live inside the license namespace
* (corrosion.{license}.reply.<id>); the privileged backend subscribes there.
* See corrosion-host-agent/PROTOCOL.md ("Reply-subject rule").
*/
async requestScoped<T = unknown>(
licenseId: string,
subject: string,
payload: Record<string, unknown>,
timeoutMs = 8000,
): Promise<T> {
if (!this.nc) {
throw new Error('NATS unavailable — agent is not reachable');
}
const replySubject = `corrosion.${licenseId}.reply.${randomUUID()}`;
const nc = this.nc;
return new Promise<T>((resolve, reject) => {
nc.subscribe(replySubject, {
max: 1,
timeout: timeoutMs,
callback: (err, msg) => {
if (err) {
reject(new Error(`agent did not respond within ${timeoutMs}ms`));
return;
}
try {
resolve(JSON.parse(this.sc.decode(msg.data)) as T);
} catch {
resolve(this.sc.decode(msg.data) as unknown as T);
}
},
});
nc.publish(subject, this.sc.encode(JSON.stringify(payload)), { reply: replySubject });
});
}
/**
* Derive a license's agent NATS credentials. Password is
* HMAC-SHA256(license_id, NATS_TOKEN_SECRET) — must match the broker config
* generated by scripts/generate-nats-auth.mjs. Returns null if the secret
* isn't configured (broker not yet enforcing auth).
*/
getAgentCredentials(licenseId: string): AgentCredentials | null {
const secret = this.config.get<string>('nats.tokenSecret');
if (!secret) return null;
const password = createHmac('sha256', secret).update(licenseId).digest('hex');
return {
license_id: licenseId,
nats_user: licenseId,
nats_password: password,
nats_url: this.config.get<string>('nats.publicUrl') || 'nats://nats.corrosionmgmt.com:4222',
};
}
/** Publish a command to a specific license's server */
async sendServerCommand(licenseId: string, action: string, payload: Record<string, unknown> = {}): Promise<void> {
await this.publish(`corrosion.${licenseId}.cmd.server`, {

View File

@@ -0,0 +1,102 @@
-- Fleet data model — License → Host → Instance (with optional Cluster)
--
-- ADDITIVE: existing server_connections / server_config / server_stats are
-- left untouched so the current single-server panel keeps working. The
-- host-agent consumer writes BOTH the legacy connection row and these fleet
-- tables during the transition; the panel migrates to the fleet tables in a
-- later phase.
--
-- Shape mirrors the host agent's wire protocol v2 heartbeat:
-- host{} block → agent_hosts
-- instances[] entries → game_instances
-- Host metrics (CPU/RAM/disk) live on the HOST, not duplicated per instance.
--
-- Named `agent_hosts` (not `hosts`) to avoid collision with the existing B2B
-- `hosts` table (hosting-partner companies) — different concept entirely.
-----------------------------------------------------------
-- AGENT_HOSTS — one Corrosion host agent / one machine
-----------------------------------------------------------
CREATE TABLE IF NOT EXISTS agent_hosts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
-- Natural key until enrollment issues a stable host identity.
hostname VARCHAR(255) NOT NULL DEFAULT '',
agent_version VARCHAR(64),
agent_commit VARCHAR(64),
os VARCHAR(32),
arch VARCHAR(32),
status VARCHAR(20) NOT NULL DEFAULT 'offline'
CHECK (status IN ('connected', 'degraded', 'offline')),
last_heartbeat_at TIMESTAMPTZ,
cpu_percent DOUBLE PRECISION,
cpu_cores INTEGER,
mem_total_mb BIGINT,
mem_used_mb BIGINT,
uptime_seconds BIGINT,
disks JSONB, -- [{ "mount": "/", "total_mb": n, "free_mb": n }]
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (license_id, hostname)
);
CREATE INDEX IF NOT EXISTS idx_agent_hosts_license ON agent_hosts(license_id);
-----------------------------------------------------------
-- INSTANCE CLUSTERS — optional grouping (Soulmask main/child, Dune battlegroup)
-- Reserved now; cluster logic ships with those game adapters.
-----------------------------------------------------------
CREATE TABLE IF NOT EXISTS instance_clusters (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
game VARCHAR(32) NOT NULL,
name VARCHAR(255) NOT NULL,
topology VARCHAR(32), -- main_client | battlegroup
config JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_clusters_license ON instance_clusters(license_id);
-----------------------------------------------------------
-- GAME INSTANCES — one game server process / orchestrated unit.
-- The billing unit (plans count instances).
-----------------------------------------------------------
CREATE TABLE IF NOT EXISTS game_instances (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
host_id UUID REFERENCES agent_hosts(id) ON DELETE SET NULL,
cluster_id UUID REFERENCES instance_clusters(id) ON DELETE SET NULL,
-- The agent's instance slug; the NATS subject segment.
agent_instance_id VARCHAR(64) NOT NULL,
game VARCHAR(32) NOT NULL,
label VARCHAR(255),
-- running | stopped | starting | stopping | crashed
-- | configured | missing_root | unmanaged | unknown
state VARCHAR(32) NOT NULL DEFAULT 'unknown',
root_path TEXT,
uptime_seconds BIGINT NOT NULL DEFAULT 0,
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (license_id, agent_instance_id)
);
CREATE INDEX IF NOT EXISTS idx_instances_license ON game_instances(license_id);
CREATE INDEX IF NOT EXISTS idx_instances_host ON game_instances(host_id);
-----------------------------------------------------------
-- INSTANCE STATS — per-instance time series (game metrics).
-- Populated once game-level telemetry (player count/FPS via RCON/plugin) is
-- collected; the host heartbeat carries host metrics, not game metrics.
-----------------------------------------------------------
CREATE TABLE IF NOT EXISTS instance_stats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
instance_id UUID NOT NULL REFERENCES game_instances(id) ON DELETE CASCADE,
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
player_count INTEGER NOT NULL DEFAULT 0,
max_players INTEGER NOT NULL DEFAULT 0,
fps DOUBLE PRECISION NOT NULL DEFAULT 0,
memory_usage_mb INTEGER NOT NULL DEFAULT 0,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_instance_stats_instance
ON instance_stats(instance_id, recorded_at DESC);

View File

@@ -90,7 +90,7 @@ dependencies = [
"nuid",
"once_cell",
"portable-atomic",
"rand",
"rand 0.8.6",
"regex",
"ring",
"rustls-native-certs",
@@ -100,7 +100,7 @@ dependencies = [
"serde_json",
"serde_nanos",
"serde_repr",
"thiserror",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-rustls",
@@ -110,6 +110,12 @@ dependencies = [
"url",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -180,6 +186,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@@ -264,7 +276,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "corrosion-host-agent"
version = "2.0.0-alpha.4"
version = "2.0.0-alpha.6"
dependencies = [
"anyhow",
"async-nats",
@@ -272,7 +284,9 @@ dependencies = [
"clap",
"futures",
"libc",
"rand",
"minisign-verify",
"rand 0.8.6",
"reqwest",
"serde",
"serde_json",
"sysinfo",
@@ -585,8 +599,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -597,7 +627,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -633,12 +663,94 @@ dependencies = [
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -784,6 +896,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -852,6 +970,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -867,6 +991,12 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "mio"
version = "1.2.1"
@@ -889,7 +1019,7 @@ dependencies = [
"ed25519-dalek",
"getrandom 0.2.17",
"log",
"rand",
"rand 0.8.6",
"signatory",
]
@@ -917,7 +1047,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
dependencies = [
"rand",
"rand 0.8.6",
]
[[package]]
@@ -1056,6 +1186,61 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -1065,6 +1250,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -1078,8 +1269,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -1089,7 +1290,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -1101,6 +1312,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rayon"
version = "1.12.0"
@@ -1159,6 +1379,47 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -1173,6 +1434,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -1237,6 +1504,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -1268,6 +1536,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schannel"
version = "0.1.29"
@@ -1384,6 +1658,18 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -1438,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
dependencies = [
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"zeroize",
]
@@ -1450,7 +1736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -1514,6 +1800,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -1558,7 +1853,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
@@ -1572,6 +1876,17 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@@ -1622,6 +1937,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
@@ -1727,6 +2057,51 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -1788,6 +2163,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tryhard"
version = "0.5.2"
@@ -1810,9 +2191,9 @@ dependencies = [
"http",
"httparse",
"log",
"rand",
"rand 0.8.6",
"sha1",
"thiserror",
"thiserror 1.0.69",
"utf-8",
]
@@ -1882,6 +2263,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -1919,6 +2309,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.123"
@@ -1973,6 +2373,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -1985,6 +2398,35 @@ dependencies = [
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"

View File

@@ -1,6 +1,6 @@
[package]
name = "corrosion-host-agent"
version = "2.0.0-alpha.4"
version = "2.0.0-alpha.7"
edition = "2021"
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
license = "UNLICENSED"
@@ -26,6 +26,8 @@ anyhow = "1"
clap = { version = "4.5", features = ["derive"] }
rand = "0.8"
tokio-tungstenite = "0.24"
minisign-verify = "0.2.5"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View File

@@ -85,6 +85,7 @@ Request: `{ "func": "<name>" }`. Reply: `{ "status": "success" | "error", ... }`
| `ping` | `version`, `commit`, `uptime_seconds` |
| `probe` | `report` — fresh ProbeReport (also cached for heartbeat) |
| `sysinfo` | `snapshot` — full heartbeat payload, collected on demand |
| `update` | `{ "func": "update", "url": "https://cdn.corrosionmgmt.com/host-agent/.../corrosion-host-agent-<plat>" }` → downloads the binary + `<url>.minisig`, verifies the minisign signature against the agent's EMBEDDED public key, atomically swaps (with `.old` rollback), replies `{ status: success, message: "...relaunching" }`, then relaunches the new binary. Rejects anything not signed by the release key and any URL that isn't `https://cdn.corrosionmgmt.com`. |
Unknown funcs return `status: "error"` with a message listing supported funcs.
@@ -179,6 +180,23 @@ service that attempts connections to the customer's public IP/ports on
request; that is specified as a Phase 1+ feature and will reuse this report
format with `direction: "inbound"`.
## Authentication & tenant isolation
The broker enforces per-license auth: an agent connects with `user = license_id`,
`password = HMAC-SHA256(license_id, NATS_TOKEN_SECRET)` (shown on the panel
Server page), and is scoped to `corrosion.{license_id}.>` only. The backend uses
a privileged internal user. This makes cross-tenant access impossible at the
broker, not just by convention.
**Reply-subject rule:** per-license users have NO `_INBOX` permission (granting
it would let one license read another's request-reply traffic). Therefore any
backend→agent request-reply MUST use a reply subject inside the license
namespace — e.g. `corrosion.{license_id}.reply.<id>` — never the client's
default global `_INBOX`. The agent is unaffected: it responds to whatever
`msg.reply` it receives. The constraint is on the requester (the internal user
has full access). The contract/CI tests run against an unauthenticated broker
and use the default inbox; production request-reply must follow this rule.
## Versioning
- The agent embeds semver + git hash + build timestamp (`--version`,

View File

@@ -21,7 +21,8 @@ instance on that host — Rust, Conan Exiles, Soulmask, Dune: Awakening.
(integration-tested with real processes + live-NATS contract test)
- [ ] Phase 1b: RCON trait (WebRCON rust / TCP conan+soulmask), SteamCMD, jailed file manager
- [ ] Phase 2: Dune Docker adapter (compose lifecycle, RabbitMQ bus, Postgres admin)
- [ ] Phase 3: signed self-update (enforced ed25519 — release gate), service install, supervisor split
- [x] Phase 3a: SIGNED self-update — minisign-verified download+swap+relaunch (NATS `update` func); embedded public key; CI signs releases
- [ ] Phase 3b: service install (systemd/SCM), PID adoption
## Build

View File

@@ -9,7 +9,11 @@
[agent]
license_id = "your-license-uuid"
nats_url = "nats://nats.corrosionmgmt.com:4222"
# nats_token = "set-me-or-use-CORROSION_NATS_TOKEN"
# Per-license auth (preferred): user = license id, password = the token shown
# on the panel Server page. The broker scopes you to corrosion.{license}.>
# nats_user = "your-license-uuid" # defaults to license_id if omitted
# nats_password = "set-me-or-use-CORROSION_NATS_PASSWORD"
# nats_token = "legacy token-only auth; use nats_password instead"
heartbeat_seconds = 60
log_level = "info"

View File

@@ -33,7 +33,15 @@ pub async fn connect(cfg: &Settings) -> Result<async_nats::Client> {
if force_tls {
opts = opts.require_tls(true);
}
if let Some(token) = &cfg.nats_token {
// Per-license auth: the broker maps user=license_id, password=derived
// token to permissions scoped to corrosion.{license_id}.>. Falls back to
// token-only or anonymous so the agent still works against a broker that
// hasn't enforced auth yet (transition period).
if let Some(password) = &cfg.nats_password {
let user = cfg.nats_user.clone().unwrap_or_else(|| cfg.license_id.clone());
opts = opts.user_and_password(user, password.clone());
} else if let Some(token) = &cfg.nats_token {
opts = opts.token(token.clone());
}

View File

@@ -34,6 +34,12 @@ pub struct AgentSection {
pub license_id: Option<String>,
pub nats_url: Option<String>,
pub nats_token: Option<String>,
/// NATS username for per-license auth. Defaults to license_id when a
/// password is set but no user is given.
pub nats_user: Option<String>,
/// NATS password (the per-license token). When set, the agent authenticates
/// with user+password instead of a bare token.
pub nats_password: Option<String>,
#[serde(default = "default_heartbeat_seconds")]
pub heartbeat_seconds: u64,
#[serde(default = "default_log_level")]
@@ -122,6 +128,8 @@ pub struct Settings {
pub license_id: String,
pub nats_url: String,
pub nats_token: Option<String>,
pub nats_user: Option<String>,
pub nats_password: Option<String>,
pub heartbeat_seconds: u64,
pub log_level: String,
pub instances: Vec<InstanceConfig>,
@@ -167,6 +175,16 @@ fn resolve(file: ConfigFile) -> Result<Settings> {
.filter(|v| !v.is_empty())
.or(file.agent.nats_token);
let nats_user = std::env::var("CORROSION_NATS_USER")
.ok()
.filter(|v| !v.is_empty())
.or(file.agent.nats_user);
let nats_password = std::env::var("CORROSION_NATS_PASSWORD")
.ok()
.filter(|v| !v.is_empty())
.or(file.agent.nats_password);
validate_subject_segment("license_id", &license_id)?;
let mut seen: HashSet<&str> = HashSet::new();
@@ -196,6 +214,8 @@ fn resolve(file: ConfigFile) -> Result<Settings> {
license_id,
nats_url,
nats_token,
nats_user,
nats_password,
heartbeat_seconds: file.agent.heartbeat_seconds,
log_level: file.agent.log_level,
instances: file.instances,

View File

@@ -13,11 +13,15 @@ use crate::agent::Agent;
use crate::prober;
use crate::subjects;
use crate::telemetry;
use crate::update;
use crate::version;
#[derive(Debug, Deserialize)]
struct HostCommand {
func: String,
/// Signed-update artifact URL (for func = "update").
#[serde(default)]
url: Option<String>,
}
pub async fn run(agent: Arc<Agent>) -> anyhow::Result<()> {
@@ -55,20 +59,46 @@ async fn handle(agent: Arc<Agent>, msg: async_nats::Message) {
return;
};
let response = match serde_json::from_slice::<HostCommand>(&msg.payload) {
Ok(cmd) => dispatch(&agent, &cmd.func).await,
Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }),
};
let bytes = match serde_json::to_vec(&response) {
Ok(b) => b,
let cmd = match serde_json::from_slice::<HostCommand>(&msg.payload) {
Ok(cmd) => cmd,
Err(e) => {
tracing::error!("response serialize failed: {e}");
publish(&agent, &reply, json!({ "status": "error", "message": format!("invalid command payload: {e}") })).await;
return;
}
};
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
tracing::warn!("response publish failed: {e}");
// Self-update is special: it must reply BEFORE relaunching, because the
// relaunch replaces this process and nothing after it would run.
if cmd.func == "update" {
let Some(url) = cmd.url else {
publish(&agent, &reply, json!({ "status": "error", "message": "update requires a 'url'" })).await;
return;
};
match update::download_verify_swap(&url).await {
Ok(_) => {
publish(&agent, &reply, json!({ "status": "success", "func": "update", "message": "verified and swapped; relaunching" })).await;
let _ = agent.nats.flush().await;
update::relaunch_and_exit();
}
Err(e) => {
publish(&agent, &reply, json!({ "status": "error", "func": "update", "message": format!("{e:#}") })).await;
}
}
return;
}
let response = dispatch(&agent, &cmd.func).await;
publish(&agent, &reply, response).await;
}
async fn publish(agent: &Arc<Agent>, reply: &async_nats::Subject, value: serde_json::Value) {
match serde_json::to_vec(&value) {
Ok(bytes) => {
if let Err(e) = agent.nats.publish(reply.clone(), bytes.into()).await {
tracing::warn!("response publish failed: {e}");
}
}
Err(e) => tracing::error!("response serialize failed: {e}"),
}
}

View File

@@ -13,4 +13,5 @@ pub mod rcon;
pub mod steamcmd;
pub mod subjects;
pub mod telemetry;
pub mod update;
pub mod version;

View File

@@ -0,0 +1,154 @@
//! Signed self-update.
//!
//! The agent only ever runs a binary whose minisign signature verifies against
//! the EMBEDDED public key below. Even if the CDN (which currently accepts
//! unauthenticated uploads) served a malicious binary, the agent refuses it
//! without a valid signature from the release private key (a CI secret).
//!
//! Flow: download binary + `.minisig` from the CDN → verify signature →
//! atomic swap (current → `.old`, new → current, rollback on failure) →
//! relaunch the new binary. Defence in depth mirrors the Vigilance updater:
//! a real URL parse rejecting credential-in-URL bypasses, an https + host
//! allowlist, and a size cap.
use anyhow::{bail, Context, Result};
use minisign_verify::{PublicKey, Signature};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// minisign public key. The matching private key signs releases in CI
/// (Gitea Actions secret MINISIGN_SECRET_KEY). Rotating it means re-signing
/// every published artifact and shipping an agent build with the new key.
const PUBLIC_KEY: &str = "RWQKhJptuiwIkp31cZdz10z/R72UPZkl7/VtnZJ2Vfbe0dQfDlXHZYFC";
const ALLOWED_HOST: &str = "cdn.corrosionmgmt.com";
const MAX_BINARY_BYTES: usize = 100 * 1024 * 1024; // 100 MiB sanity cap
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
/// Verify a binary against the embedded public key + a minisign signature blob.
/// The security core of self-update — tampered or unsigned content is rejected.
pub fn verify_signature(binary: &[u8], signature_blob: &str) -> Result<()> {
let pk = PublicKey::from_base64(PUBLIC_KEY).context("embedded public key is invalid")?;
let sig = Signature::decode(signature_blob).context("malformed minisign signature")?;
pk.verify(binary, &sig, false)
.map_err(|e| anyhow::anyhow!("signature verification failed: {e}"))?;
Ok(())
}
/// Reject anything but `https://cdn.corrosionmgmt.com/...` with no embedded
/// credentials (the userinfo-bypass class).
pub fn assert_url_allowed(url: &str) -> Result<()> {
let parsed = reqwest::Url::parse(url).context("invalid update URL")?;
if parsed.scheme() != "https" {
bail!("update URL must be https");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!("update URL must not contain credentials");
}
if parsed.host_str() != Some(ALLOWED_HOST) {
bail!("update URL host not allowed: {:?}", parsed.host_str());
}
Ok(())
}
/// Download, verify, and atomically swap in a new agent binary. Does NOT
/// restart — the caller decides when to relaunch (after replying on NATS).
/// Returns the path of the now-current (new) binary.
pub async fn download_verify_swap(url: &str) -> Result<PathBuf> {
assert_url_allowed(url)?;
let sig_url = format!("{url}.minisig");
assert_url_allowed(&sig_url)?;
let client = reqwest::Client::builder()
.timeout(DOWNLOAD_TIMEOUT)
.build()
.context("building HTTP client")?;
let binary = client
.get(url)
.send()
.await
.with_context(|| format!("downloading {url}"))?
.error_for_status()
.context("update binary download failed")?
.bytes()
.await
.context("reading update binary")?;
if binary.len() > MAX_BINARY_BYTES {
bail!("update binary is {} bytes, exceeds the {MAX_BINARY_BYTES} cap", binary.len());
}
let signature = client
.get(&sig_url)
.send()
.await
.with_context(|| format!("downloading {sig_url}"))?
.error_for_status()
.context("signature download failed")?
.text()
.await
.context("reading signature")?;
verify_signature(&binary, &signature).context("refusing unsigned/tampered update")?;
tracing::info!("update signature verified ({} bytes)", binary.len());
let current = std::env::current_exe().context("resolving current executable")?;
swap_binary(&current, &binary)?;
tracing::info!("update swapped in at {}", current.display());
Ok(current)
}
/// Atomically replace `current` with `new_bytes`, keeping a `.old` backup and
/// rolling back if the rename fails.
pub fn swap_binary(current: &Path, new_bytes: &[u8]) -> Result<()> {
let dir = current.parent().unwrap_or_else(|| Path::new("."));
let stem = current.file_name().and_then(|s| s.to_str()).unwrap_or("corrosion-host-agent");
let new_path = dir.join(format!("{stem}.new"));
let backup = dir.join(format!("{stem}.old"));
std::fs::write(&new_path, new_bytes)
.with_context(|| format!("writing {}", new_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&new_path, std::fs::Permissions::from_mode(0o755))
.context("chmod +x on new binary")?;
}
let _ = std::fs::remove_file(&backup);
std::fs::rename(current, &backup)
.with_context(|| format!("backing up current binary to {}", backup.display()))?;
if let Err(e) = std::fs::rename(&new_path, current) {
// Roll back: restore the backup so the agent stays runnable.
let _ = std::fs::rename(&backup, current);
return Err(anyhow::anyhow!(e).context("installing new binary (rolled back)"));
}
Ok(())
}
/// Relaunch the (already-swapped) binary with the same args, then exit. No
/// service manager is required — the new process reconnects on its own. There
/// is a sub-second window with no agent; acceptable for an update.
pub fn relaunch_and_exit() -> ! {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("corrosion-host-agent"));
let args: Vec<String> = std::env::args().skip(1).collect();
tracing::info!("relaunching {} after update", exe.display());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// exec replaces this process image with the new binary — cleanest,
// no gap. Only returns on failure.
let err = std::process::Command::new(&exe).args(&args).exec();
tracing::error!("exec after update failed: {err}; exiting for service restart");
std::process::exit(70);
}
#[cfg(not(unix))]
{
let _ = std::process::Command::new(&exe).args(&args).spawn();
std::process::exit(0);
}
}

View File

@@ -0,0 +1,2 @@
corrosion-host-agent signed-update test fixture
version 2.0.0-test

View File

@@ -0,0 +1,4 @@
untrusted comment: signature from minisign secret key
RUQKhJptuiwIkp378Z59BTwosDycAhmlhrdZZVwk1Vdb293OgcsXx0S3W0XezMtOXIXdgvQtW/DpDKlb1gdW4elQXLG5KFUgawI=
trusted comment: timestamp:1781222247 file:sample.bin hashed
QtUiOfJqRKYJZTL6QV93xeLVnODr8HXWvZIR3Q1AG0yqmqesZPyiKpVa9kD34Mwp1fQ76nx1Z7c6CB1v5KHQAw==

View File

@@ -0,0 +1,63 @@
//! Signed self-update tests — the security-critical part is signature
//! verification: a valid signature is accepted, anything tampered is rejected.
//! Fixtures (tests/fixtures/sample.bin + .minisig) were signed with the real
//! release private key, so these run with no key present (as in CI).
use corrosion_host_agent::update;
const SAMPLE: &[u8] = include_bytes!("fixtures/sample.bin");
const SAMPLE_SIG: &str = include_str!("fixtures/sample.bin.minisig");
#[test]
fn accepts_a_validly_signed_binary() {
update::verify_signature(SAMPLE, SAMPLE_SIG).expect("valid signature must verify");
}
#[test]
fn rejects_a_tampered_binary() {
let mut tampered = SAMPLE.to_vec();
tampered[0] ^= 0xFF; // flip a byte
let err = update::verify_signature(&tampered, SAMPLE_SIG)
.expect_err("tampered binary must be rejected");
assert!(err.to_string().contains("verification failed"), "got: {err}");
}
#[test]
fn rejects_a_garbage_signature() {
assert!(update::verify_signature(SAMPLE, "not a real minisig blob").is_err());
}
#[test]
fn rejects_empty_binary_against_real_sig() {
assert!(update::verify_signature(b"", SAMPLE_SIG).is_err());
}
#[test]
fn url_allowlist_enforced() {
// Allowed.
update::assert_url_allowed("https://cdn.corrosionmgmt.com/host-agent/alpha/corrosion-host-agent-linux-amd64")
.expect("the real CDN host must be allowed");
// http rejected.
assert!(update::assert_url_allowed("http://cdn.corrosionmgmt.com/x").is_err());
// wrong host rejected.
assert!(update::assert_url_allowed("https://evil.example.com/x").is_err());
// credential-in-URL (userinfo bypass) rejected.
assert!(update::assert_url_allowed("https://cdn.corrosionmgmt.com:[email protected]/x").is_err());
// host as userinfo trick rejected (real host is evil.com).
assert!(update::assert_url_allowed("https://[email protected]/x").is_err());
}
#[test]
fn swap_binary_replaces_and_backs_up() {
let dir = tempfile::tempdir().expect("tempdir");
let current = dir.path().join("corrosion-host-agent");
std::fs::write(&current, b"OLD BINARY").unwrap();
update::swap_binary(&current, b"NEW BINARY").expect("swap should succeed");
assert_eq!(std::fs::read(&current).unwrap(), b"NEW BINARY", "current is the new binary");
let backup = dir.path().join("corrosion-host-agent.old");
assert_eq!(std::fs::read(&backup).unwrap(), b"OLD BINARY", ".old holds the previous binary");
// the .new scratch file is consumed by the rename
assert!(!dir.path().join("corrosion-host-agent.new").exists());
}

View File

@@ -31,6 +31,9 @@ services:
volumes:
- nats_data:/data
- ./nats.conf:/etc/nats/nats.conf:ro
# Per-license authorization (generated on the host; carries secrets, not
# committed with real users — see scripts/generate-nats-auth.mjs).
- ./nats-auth.conf:/etc/nats/nats-auth.conf:ro
ports:
- "8089:4222" # Client connections
@@ -43,6 +46,12 @@ services:
DATABASE_URL: postgres://corrosion:${DB_PASSWORD:-corrosion_dev}@postgres:5432/corrosion
DATABASE_MAX_CONNECTIONS: "20"
NATS_URL: nats://nats:4222
# Privileged internal NATS user (full corrosion.> access). Empty = anonymous.
NATS_INTERNAL_USER: ${NATS_INTERNAL_USER:-}
NATS_INTERNAL_PASSWORD: ${NATS_INTERNAL_PASSWORD:-}
# Secret for deriving per-license agent passwords (shared with the
# nats-auth generator). HMAC-SHA256(license_id, secret).
NATS_TOKEN_SECRET: ${NATS_TOKEN_SECRET:-}
JWT_SECRET: ${JWT_SECRET}
JWT_ACCESS_EXPIRY_SECONDS: "14400"
JWT_REFRESH_EXPIRY_SECONDS: "604800"

18
docker/nats-auth.conf Normal file
View File

@@ -0,0 +1,18 @@
# BOOTSTRAP DEFAULT — no secrets, safe to commit.
#
# Anonymous is mapped to a HARMLESS namespace (corrosion.unclaimed.>), never to
# real tenant subjects (corrosion.{uuid}.>) — so a fresh/stale deploy running
# this default cannot read or forge any tenant's traffic. The REST API still
# works; agent telemetry just won't flow until the real config is generated.
#
# On every real deploy, scripts/generate-nats-auth.mjs OVERWRITES this file
# (on the host, not in git) with the privileged internal user + per-license
# scoped users. NATS_AUTH_STAGE defaults to "enforce" (anonymous rejected).
#
# NOTE: no_auth_user is a TOP-LEVEL field, NOT inside authorization { }.
authorization {
users: [
{ user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }
]
}
no_auth_user: "anonymous"

View File

@@ -28,8 +28,11 @@ logtime: true
max_payload: 8MB # Support map file transfer metadata
max_connections: 10000
# Authorization — tokens validated per-connection
# Plugin and companion agents authenticate with license-specific tokens
authorization {
timeout: 5
}
# Authorization — per-license isolation.
# The committed nats-auth.conf is the SAFE OPEN default (anonymous full access,
# no secrets — same as before). On deploy, scripts/generate-nats-auth.mjs
# regenerates this file from the licenses table with the privileged internal
# user + per-license scoped users; flip NATS_AUTH_STAGE=enforce to reject
# anonymous. The host copy carries secrets and is NOT committed
# (git update-index --assume-unchanged docker/nats-auth.conf).
include "nats-auth.conf"

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -102,6 +102,12 @@ export interface GameProfile {
terminology: GameTerminology
/** Notable game-specific mechanics that affect server administration. */
special?: string[]
/**
* Primary editable config file, relative to the instance root — prefilled in
* the Server-page config editor as a hint (operator can change it). null if
* the game has no single primary config file.
*/
primaryConfigFile?: string | null
/**
* Stat field labels shown on server cards and the dashboard.
* First entry is always Players; subsequent entries are game-specific.
@@ -124,6 +130,7 @@ export interface GameProfile {
// ---------------------------------------------------------------------------
const NAV_DASHBOARD: NavItemDef = { label: 'Dashboard', route: '/', icon: 'layout-dashboard', permission: null }
const NAV_FLEET: NavItemDef = { label: 'Fleet', route: '/fleet', icon: 'server-cog', permission: 'server.view' }
const NAV_SERVER: NavItemDef = { label: 'Server', route: '/server', icon: 'server', permission: 'server.view' }
const NAV_CONSOLE: NavItemDef = { label: 'Console', route: '/console', icon: 'terminal', permission: 'console.view' }
const NAV_PLAYERS: NavItemDef = { label: 'Players', route: '/players', icon: 'users', permission: 'players.view' }
@@ -147,7 +154,7 @@ const RUST_NAV: NavSection[] = [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES],
items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES],
},
{ label: 'Plugin configs', items: [NAV_PLUGIN_CONFIGS] },
{
@@ -184,6 +191,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
group: 'Team',
},
statFields: ['Players', 'uMod', 'Wipe'],
primaryConfigFile: 'server/cfg/server.cfg',
nav: RUST_NAV,
},
@@ -206,12 +214,13 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
},
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
statFields: ['Players', 'Clans', 'Purge'],
primaryConfigFile: 'ConanSandbox/Saved/Config/LinuxServer/ServerSettings.ini',
nav: [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
// Conan: no uMod/Oxide; has RCON console, maps, players, files
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
},
{
label: 'Operations',
@@ -251,12 +260,13 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
},
special: ['Cluster', 'Tribes'],
statFields: ['Players', 'Tribe', 'Mask'],
primaryConfigFile: 'WS/Saved/GameplaySettings/GameXishu.json',
nav: [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
// Soulmask: no uMod/Oxide; has RCON+GM console, players, files
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
},
{
label: 'Operations',
@@ -293,12 +303,14 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
},
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
statFields: ['Players', 'Sietches', 'Control'],
primaryConfigFile: null,
nav: [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
// Dune: no RCON (uses RabbitMQ); label console "Broadcast"; no maps route; no plugins
items: [
NAV_FLEET,
NAV_SERVER,
{ label: 'Broadcast', route: '/console', icon: 'radio', permission: 'console.view' },
NAV_PLAYERS,

View File

@@ -343,6 +343,12 @@ const panelRoutes: RouteRecordRaw[] = [
component: () => import('@/views/admin/AlertsView.vue'),
meta: { title: 'Alerts — Corrosion' },
},
{
path: 'fleet',
name: 'fleet',
component: () => import('@/views/admin/FleetView.vue'),
meta: { title: 'Fleet — Corrosion', requiresAuth: true },
},
// Platform Admin views (super-admin only)
{
path: 'admin',

View File

@@ -0,0 +1,136 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useInstancesStore } from '@/stores/instances'
export interface FileEntry {
name: string
path: string
is_dir: boolean
size: number
modified: string
}
/**
* Per-instance file browser store.
* All operations target `/api/instances/{id}/...` — jailed to instance root.
* Guard: if no current instance, list() sets error and bails out early.
*/
export const useFilesStore = defineStore('files', () => {
const api = useApi()
const cwd = ref<string>('')
const entries = ref<FileEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
/** Join two relative path segments with a single forward slash. */
function joinPath(base: string, name: string): string {
if (!base) return name
return `${base}/${name}`
}
function currentId(): string | null {
// Retrieve fresh from the store each call — avoids stale closure.
return useInstancesStore().currentId
}
/** List a directory. Sets cwd + entries. Does NOT throw — sets error. */
async function list(path: string): Promise<void> {
const id = currentId()
if (!id) {
error.value = 'No instance — connect the host agent'
entries.value = []
return
}
loading.value = true
error.value = null
try {
const data = await api.get<{ entries: FileEntry[] }>(
`/instances/${id}/files?path=${encodeURIComponent(path)}`,
)
cwd.value = path
entries.value = data.entries ?? []
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load directory'
} finally {
loading.value = false
}
}
/** Read a text file. Returns content string. Throws on error (binary/too big/not found). */
async function readFile(path: string): Promise<string> {
const id = currentId()
if (!id) throw new Error('No instance selected')
const data = await api.get<{ content: string }>(
`/instances/${id}/file?path=${encodeURIComponent(path)}`,
)
return data.content ?? ''
}
/** Write / overwrite a text file. Throws on error. */
async function writeFile(path: string, content: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.put(`/instances/${id}/file`, { path, content })
}
/** Delete a file or directory (recursive). Throws on error. */
async function del(path: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/delete`, { path })
}
/** Rename within the same parent. `name` is the bare new filename. Throws on error. */
async function rename(path: string, name: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/rename`, { path, name })
}
/** Create a directory (and all missing ancestors). Throws on error. */
async function mkdir(path: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/mkdir`, { path })
}
/** Create an empty file. Throws on error. */
async function mkfile(path: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/mkfile`, { path })
}
/** Move a file or directory. Both paths are relative to the instance root. Throws on error. */
async function move(path: string, dest: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/move`, { path, dest })
}
/** Copy a file or directory. Both paths are relative to the instance root. Throws on error. */
async function copy(path: string, dest: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/copy`, { path, dest })
}
return {
cwd,
entries,
loading,
error,
joinPath,
list,
readFile,
writeFile,
del,
rename,
mkdir,
mkfile,
move,
copy,
}
})

View File

@@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
// ---------------------------------------------------------------------------
// Types — mirrors the FleetResponseDto from the backend
// ---------------------------------------------------------------------------
export interface FleetDisk {
mount: string
total_mb: number
free_mb: number
}
export interface FleetInstance {
id: string
agent_instance_id: string
game: string
label: string | null
state: string
uptime_seconds: number
last_seen_at: string | null
}
export interface FleetHost {
id: string
hostname: string
status: string
agent_version: string | null
os: string | null
arch: string | null
cpu_percent: number | null
cpu_cores: number | null
mem_total_mb: number | null
mem_used_mb: number | null
uptime_seconds: number | null
disks: FleetDisk[] | null
last_heartbeat_at: string | null
instances: FleetInstance[]
}
export interface FleetSummary {
host_count: number
instance_count: number
online_host_count: number
}
export interface FleetData {
hosts: FleetHost[]
summary: FleetSummary
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useFleetStore = defineStore('fleet', () => {
const hosts = ref<FleetHost[]>([])
const summary = ref<FleetSummary>({ host_count: 0, instance_count: 0, online_host_count: 0 })
const loading = ref(false)
const error = ref<string | null>(null)
const api = useApi()
async function fetchFleet() {
loading.value = true
error.value = null
try {
const data = await api.get<FleetData>('/fleet')
hosts.value = data.hosts
summary.value = data.summary
} catch (e) {
console.error('Failed to fetch fleet:', e)
error.value = e instanceof Error ? e.message : 'Failed to load fleet data'
} finally {
loading.value = false
}
}
/**
* Remove a host and its instances. Throws on failure (e.g. 409 when the host
* is still online) so the caller can surface the message; refetches on
* success.
*/
async function removeHost(hostId: string): Promise<void> {
await api.del(`/fleet/hosts/${hostId}`)
await fetchFleet()
}
return {
hosts,
summary,
loading,
error,
fetchFleet,
removeHost,
}
})

View File

@@ -0,0 +1,140 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import type { FleetData, FleetInstance } from '@/stores/fleet'
/** A game instance enriched with its host context, flattened from the fleet. */
export interface ManagedInstance extends FleetInstance {
host_id: string
host_hostname: string
host_status: string
}
type LifecycleAction = 'start' | 'stop' | 'restart' | 'status' | 'steam_update'
/**
* Instance management — the Server page operates on a selected game instance
* (not the legacy single-server connection). Reads the fleet to enumerate
* instances and drives the per-instance command bridge
* (POST /api/instances/:id/lifecycle | /rcon).
*/
export const useInstancesStore = defineStore('instances', () => {
const instances = ref<ManagedInstance[]>([])
const currentId = ref<string | null>(null)
const loading = ref(false)
const acting = ref<LifecycleAction | null>(null)
const error = ref<string | null>(null)
const api = useApi()
const current = computed<ManagedInstance | null>(
() => instances.value.find((i) => i.id === currentId.value) ?? null,
)
/** Fetch the fleet and flatten its instances. Optionally prefer a game. */
async function fetchInstances(preferGame?: string): Promise<void> {
loading.value = true
error.value = null
try {
const data = await api.get<FleetData>('/fleet')
const flat: ManagedInstance[] = []
for (const host of data.hosts) {
for (const inst of host.instances) {
flat.push({
...inst,
host_id: host.id,
host_hostname: host.hostname,
host_status: host.status,
})
}
}
instances.value = flat
// Keep the current selection if it still exists; else prefer the active
// game, else the first instance.
if (!flat.some((i) => i.id === currentId.value)) {
const preferred = preferGame ? flat.find((i) => i.game === preferGame) : undefined
currentId.value = (preferred ?? flat[0])?.id ?? null
}
} catch (e) {
console.error('Failed to fetch instances:', e)
error.value = e instanceof Error ? e.message : 'Failed to load instances'
} finally {
loading.value = false
}
}
function select(id: string): void {
currentId.value = id
}
/**
* Send a lifecycle command to the current instance and apply the agent's
* reply state OPTIMISTICALLY. The reply is authoritative for the action just
* taken; the fleet DB only catches up on the next heartbeat (~10s), so an
* immediate refetch would read a stale state and clobber the result.
* Throws on failure so the view can toast.
*/
async function lifecycle(action: LifecycleAction): Promise<Record<string, unknown>> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
acting.value = action
try {
const res = await api.post<Record<string, unknown>>(`/instances/${id}/lifecycle`, { action })
applyReplyState(id, res)
return res
} finally {
acting.value = null
}
}
/** Update an instance's state/uptime from a lifecycle/status reply. */
function applyReplyState(id: string, res: Record<string, unknown>): void {
if ((res as { status?: string }).status !== 'success') return
const stateObj = (res as { state?: { state?: string } }).state
const newState = stateObj?.state
const inst = instances.value.find((i) => i.id === id)
if (inst && typeof newState === 'string') {
inst.state = newState
const up = (res as { uptime_seconds?: number }).uptime_seconds
inst.uptime_seconds = typeof up === 'number' ? up : newState === 'running' ? inst.uptime_seconds : 0
}
}
async function rcon(command: string): Promise<Record<string, unknown>> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
return api.post<Record<string, unknown>>(`/instances/${id}/rcon`, { command })
}
/** Read a config/text file from the current instance (jailed to its root). */
async function readFile(path: string): Promise<string> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
const res = await api.get<{ content?: string }>(
`/instances/${id}/file?path=${encodeURIComponent(path)}`,
)
return res?.content ?? ''
}
/** Write a config/text file to the current instance. */
async function writeFile(path: string, content: string): Promise<void> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
await api.put(`/instances/${id}/file`, { path, content })
}
return {
instances,
currentId,
current,
loading,
acting,
error,
fetchInstances,
select,
lifecycle,
rcon,
readFile,
writeFile,
}
})

View File

@@ -1,28 +1,176 @@
<script setup lang="ts">
import { computed } from 'vue'
import { VueFinder, RemoteDriver } from 'vuefinder'
import { useAuthStore } from '@/stores/auth'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useInstancesStore } from '@/stores/instances'
import { useFilesStore } from '@/stores/files'
import { useToastStore } from '@/stores/toast'
import { safeDate, safeFileSize } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const auth = useAuthStore()
const router = useRouter()
const instancesStore = useInstancesStore()
const files = useFilesStore()
const toast = useToastStore()
// Recreate the RemoteDriver reactively so the token stays current across
// automatic refresh cycles (useApi composable silently rotates accessToken).
const driver = computed(
() =>
new RemoteDriver({
baseURL: '/api/files',
token: auth.accessToken ?? undefined,
})
)
// ---- Editor state ----
const editorPath = ref<string | null>(null)
const editorContent = ref('')
const editorLoading = ref(false)
const editorSaving = ref(false)
// Non-persistent config passed to VueFinder per session.
// maxFileSize in bytes — 10 MB limit matches the backend upload ceiling.
const finderConfig = {
theme: 'midnight',
maxFileSize: 10 * 1024 * 1024,
showMenuBar: true,
showToolbar: true,
// ---- Inline confirm-delete ----
const pendingDelete = ref<string | null>(null)
// ---- Sorted entries: dirs first, then alpha ----
const sortedEntries = computed(() => {
return [...files.entries].sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1
return a.name.localeCompare(b.name)
})
})
// ---- Breadcrumbs from cwd ----
const breadcrumbs = computed<{ label: string; path: string }[]>(() => {
const crumbs: { label: string; path: string }[] = [{ label: 'Root', path: '' }]
if (!files.cwd) return crumbs
const parts = files.cwd.split('/').filter(Boolean)
let acc = ''
for (const p of parts) {
acc = acc ? `${acc}/${p}` : p
crumbs.push({ label: p, path: acc })
}
return crumbs
})
// ---- Parent path for "Up" button ----
const parentPath = computed<string | null>(() => {
if (!files.cwd) return null
const idx = files.cwd.lastIndexOf('/')
return idx < 0 ? '' : files.cwd.slice(0, idx)
})
// ---- Lifecycle ----
onMounted(async () => {
await instancesStore.fetchInstances()
await files.list('')
})
// ---- Instance switch ----
async function onInstanceChange(e: Event) {
const id = (e.target as HTMLSelectElement).value
instancesStore.select(id)
editorPath.value = null
await files.list('')
}
// ---- Navigation ----
async function navigate(path: string) {
editorPath.value = null
pendingDelete.value = null
await files.list(path)
}
// ---- Open a file in the editor ----
async function openFile(path: string) {
editorLoading.value = true
try {
const content = await files.readFile(path)
editorPath.value = path
editorContent.value = content
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Cannot open file (binary or too large)')
} finally {
editorLoading.value = false
}
}
function closeEditor() {
editorPath.value = null
editorContent.value = ''
}
async function saveFile() {
if (!editorPath.value) return
editorSaving.value = true
try {
await files.writeFile(editorPath.value, editorContent.value)
toast.success('File saved')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to save file')
} finally {
editorSaving.value = false
}
}
// ---- Toolbar: New folder ----
async function newFolder() {
const name = window.prompt('Folder name:')
if (!name || !name.trim()) return
if (name.includes('/') || name.includes('\\')) {
toast.error('Folder name cannot contain path separators')
return
}
try {
await files.mkdir(files.joinPath(files.cwd, name.trim()))
toast.success('Folder created')
await files.list(files.cwd)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create folder')
}
}
// ---- Toolbar: New file ----
async function newFile() {
const name = window.prompt('File name:')
if (!name || !name.trim()) return
if (name.includes('/') || name.includes('\\')) {
toast.error('File name cannot contain path separators')
return
}
try {
await files.mkfile(files.joinPath(files.cwd, name.trim()))
toast.success('File created')
await files.list(files.cwd)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create file')
}
}
// ---- Row: Rename ----
async function renameEntry(path: string, isDir: boolean) {
const current = path.split('/').pop() ?? path
const name = window.prompt('New name:', current)
if (!name || !name.trim() || name.trim() === current) return
if (name.includes('/') || name.includes('\\')) {
toast.error('Name cannot contain path separators')
return
}
try {
await files.rename(path, name.trim())
toast.success(`${isDir ? 'Folder' : 'File'} renamed`)
await files.list(files.cwd)
// If currently editing the renamed file, close editor
if (editorPath.value === path) closeEditor()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to rename')
}
}
// ---- Row: Delete ----
async function confirmDelete(path: string) {
try {
await files.del(path)
toast.success('Deleted')
pendingDelete.value = null
await files.list(files.cwd)
if (editorPath.value === path) closeEditor()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to delete')
}
}
</script>
@@ -41,15 +189,232 @@ const finderConfig = {
</div>
</div>
<!-- VueFinder wrapper only the outer chrome is re-skinned; internals untouched -->
<div class="fm__finder">
<VueFinder
id="corrosion-filemanager"
:driver="driver"
:config="finderConfig"
locale="en"
/>
</div>
<!-- No instances at all -->
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
<EmptyState
icon="server"
title="No host agent connected"
description="Install the host agent from the Server page to manage files on your game server."
>
<template #action>
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
Go to Server page
</Button>
</template>
</EmptyState>
</Panel>
<template v-else>
<!-- Instance selector -->
<div v-if="instancesStore.instances.length > 1" class="fm__instance-pick">
<span class="fm__field-label">Instance</span>
<select
class="fm__select"
:value="instancesStore.currentId ?? ''"
@change="onInstanceChange"
>
<option v-for="inst in instancesStore.instances" :key="inst.id" :value="inst.id">
{{ inst.label || inst.agent_instance_id }} ({{ inst.game }}) · {{ inst.host_hostname }}
</option>
</select>
</div>
<!-- File browser panel -->
<Panel :flush-body="true">
<template #title>
<!-- Breadcrumb -->
<div class="fm__breadcrumb">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="fm__crumb"
:class="{ 'fm__crumb--active': i === breadcrumbs.length - 1 }"
:disabled="i === breadcrumbs.length - 1"
@click="navigate(crumb.path)"
>{{ crumb.label }}</button>
</div>
</template>
<template #actions>
<!-- Up button -->
<Button
v-if="parentPath !== null"
variant="ghost"
size="sm"
icon="chevron-left"
:disabled="files.loading"
@click="navigate(parentPath!)"
>
Up
</Button>
<Button
variant="ghost"
size="sm"
icon="folder-open"
:disabled="files.loading"
@click="newFolder"
>
New folder
</Button>
<Button
variant="ghost"
size="sm"
icon="file-text"
:disabled="files.loading"
@click="newFile"
>
New file
</Button>
<Button
variant="ghost"
size="sm"
icon="refresh-cw"
:disabled="files.loading"
:loading="files.loading"
@click="files.list(files.cwd)"
/>
</template>
<!-- Error state -->
<div v-if="files.error && !files.loading" class="fm__padded">
<Alert tone="danger" :title="files.error">
<template #actions>
<Button variant="danger-soft" size="sm" icon="refresh-cw" @click="files.list(files.cwd)">
Retry
</Button>
</template>
</Alert>
</div>
<!-- Loading skeleton -->
<div v-else-if="files.loading" class="fm__padded fm__loading">
<Icon name="loader" :size="18" :stroke-width="2" class="fm__spinner" />
<span class="fm__loading-text">Loading</span>
</div>
<!-- Empty directory -->
<EmptyState
v-else-if="sortedEntries.length === 0"
icon="folder-open"
title="Empty directory"
description="This directory contains no files or folders."
/>
<!-- Entry table -->
<table v-else class="fm__table">
<thead>
<tr>
<th class="fm__th fm__th--name">Name</th>
<th class="fm__th fm__th--size">Size</th>
<th class="fm__th fm__th--date">Modified</th>
<th class="fm__th fm__th--actions"></th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in sortedEntries"
:key="entry.path"
class="fm__row"
:class="{ 'fm__row--active': editorPath === entry.path }"
>
<!-- Name -->
<td class="fm__td fm__td--name">
<button
class="fm__entry-btn"
@click="entry.is_dir ? navigate(entry.path) : openFile(entry.path)"
>
<Icon
:name="entry.is_dir ? 'folder-open' : 'file-text'"
:size="15"
:stroke-width="1.75"
class="fm__entry-icon"
:class="entry.is_dir ? 'fm__entry-icon--dir' : 'fm__entry-icon--file'"
/>
<span class="fm__entry-name">{{ entry.name }}</span>
<Icon v-if="entry.is_dir" name="chevron-right" :size="13" :stroke-width="2" class="fm__entry-chevron" />
</button>
</td>
<!-- Size -->
<td class="fm__td fm__td--size">
{{ entry.is_dir ? '—' : safeFileSize(entry.size, '0 B') }}
</td>
<!-- Modified -->
<td class="fm__td fm__td--date">{{ safeDate(entry.modified) }}</td>
<!-- Row actions -->
<td class="fm__td fm__td--actions">
<!-- Pending delete confirm -->
<template v-if="pendingDelete === entry.path">
<span class="fm__del-confirm-label">Delete?</span>
<Button
variant="danger"
size="sm"
@click="confirmDelete(entry.path)"
>
Yes
</Button>
<Button
variant="ghost"
size="sm"
@click="pendingDelete = null"
>
Cancel
</Button>
</template>
<template v-else>
<Button
variant="ghost"
size="sm"
icon="pencil"
:title="`Rename ${entry.name}`"
@click="renameEntry(entry.path, entry.is_dir)"
/>
<Button
variant="ghost"
size="sm"
icon="trash-2"
:title="`Delete ${entry.name}`"
@click="pendingDelete = entry.path"
/>
</template>
</td>
</tr>
</tbody>
</table>
</Panel>
<!-- File editor panel -->
<Panel v-if="editorPath !== null" :title="editorPath">
<template #actions>
<Button
variant="primary"
size="sm"
icon="save"
:loading="editorSaving"
@click="saveFile"
>
Save
</Button>
<Button variant="ghost" size="sm" icon="x" @click="closeEditor">Close</Button>
</template>
<div v-if="editorLoading" class="fm__padded fm__loading">
<Icon name="loader" :size="18" :stroke-width="2" class="fm__spinner" />
<span class="fm__loading-text">Loading file</span>
</div>
<textarea
v-else
v-model="editorContent"
class="fm__editor"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</Panel>
</template>
</div>
</template>
@@ -76,12 +441,113 @@ const finderConfig = {
color: var(--text-primary); margin-top: 3px;
}
/* Finder container — surface panel chrome, VueFinder renders inside */
.fm__finder {
/* Instance selector */
.fm__instance-pick { display: flex; align-items: center; gap: 12px; }
.fm__field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
.fm__select {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
overflow: hidden;
min-height: 640px;
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 8px 12px;
font-size: var(--text-sm);
font-family: var(--font-mono);
min-width: 280px;
}
/* Breadcrumb */
.fm__breadcrumb { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.fm__crumb {
background: none; border: none; cursor: pointer; padding: 0 4px;
font-size: var(--text-sm); font-weight: 500; color: var(--text-secondary);
border-radius: var(--radius-sm); transition: var(--transition-colors);
}
.fm__crumb:hover:not(:disabled) { color: var(--text-primary); background: var(--surface-hover); }
.fm__crumb:disabled { cursor: default; }
.fm__crumb--active { color: var(--text-primary); font-weight: 600; }
.fm__crumb:not(:last-child)::after { content: '/'; margin-left: 4px; color: var(--text-muted); }
/* Loading */
.fm__padded { padding: 24px 16px; }
.fm__loading { display: flex; align-items: center; gap: 10px; }
.fm__spinner { animation: fm-spin 0.75s linear infinite; color: var(--text-tertiary); }
@keyframes fm-spin { to { transform: rotate(360deg); } }
.fm__loading-text { font-size: var(--text-sm); color: var(--text-tertiary); }
/* Table */
.fm__table {
width: 100%; border-collapse: collapse;
font-size: var(--text-sm); table-layout: fixed;
}
.fm__th {
padding: 9px 14px; font-size: var(--text-xs); font-weight: 600;
color: var(--text-tertiary); text-align: left;
border-bottom: 1px solid var(--border-subtle);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
}
.fm__th--name { width: auto; }
.fm__th--size { width: 90px; }
.fm__th--date { width: 180px; }
.fm__th--actions { width: 130px; }
.fm__row { transition: background var(--dur-fast) var(--ease-standard); }
.fm__row:hover { background: var(--surface-hover); }
.fm__row--active { background: var(--accent-soft); }
.fm__row + .fm__row { border-top: 1px solid var(--border-subtle); }
.fm__td {
padding: 8px 14px; color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.fm__td--name { width: auto; }
.fm__td--size { text-align: right; font-family: var(--font-mono); font-size: var(--text-xs); }
.fm__td--date { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); }
.fm__td--actions {
text-align: right;
display: flex; align-items: center; justify-content: flex-end; gap: 2px;
padding-top: 6px; padding-bottom: 6px;
}
/* Entry button */
.fm__entry-btn {
display: inline-flex; align-items: center; gap: 8px;
background: none; border: none; cursor: pointer; padding: 0;
color: var(--text-primary); font-size: var(--text-sm); font-weight: 500;
max-width: 100%; text-align: left;
}
.fm__entry-btn:hover .fm__entry-name { text-decoration: underline; text-decoration-color: var(--border-subtle); }
.fm__entry-icon--dir { color: var(--accent); }
.fm__entry-icon--file { color: var(--text-tertiary); }
.fm__entry-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--font-mono); font-size: var(--text-xs);
}
.fm__entry-chevron { color: var(--text-muted); flex: none; }
/* Inline delete confirm */
.fm__del-confirm-label {
font-size: var(--text-xs); font-weight: 600; color: var(--danger);
margin-right: 6px;
}
/* File editor textarea */
.fm__editor {
width: 100%;
min-height: 400px;
background: var(--surface-base);
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 14px 16px;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
resize: vertical;
outline: none;
display: block;
}
.fm__editor:focus { box-shadow: var(--focus-ring); }
</style>

View File

@@ -0,0 +1,506 @@
<script setup lang="ts">
/**
* FleetView — Read-only fleet overview: hosts and game instances for this license.
*
* Data flow: useFleetStore → GET /api/fleet → tenant-scoped AgentHost + GameInstance rows.
*
* Render states:
* - loading → shows skeleton / loading text
* - error → shows error panel (fetch failed / 401 → error state, NOT global error boundary)
* - empty → honest empty state with CTA to /server
* - populated → summary strip + one card per host + instance list under each
*
* No fabricated data. All nulls render as '—' via safeFixed/safeDate.
*/
import { onMounted, computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useFleetStore } from '@/stores/fleet'
import { useToastStore } from '@/stores/toast'
import type { FleetHost } from '@/stores/fleet'
import { safeFixed, safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Badge from '@/components/ds/core/Badge.vue'
import StatusDot from '@/components/ds/core/StatusDot.vue'
import Button from '@/components/ds/core/Button.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Icon from '@/components/ds/core/Icon.vue'
// ---------------------------------------------------------------------------
// Store / router
// ---------------------------------------------------------------------------
const fleet = useFleetStore()
const router = useRouter()
const toast = useToastStore()
onMounted(() => {
fleet.fetchFleet()
})
// ---------------------------------------------------------------------------
// Derived state
// ---------------------------------------------------------------------------
const hasHosts = computed(() => fleet.hosts.length > 0)
// ---------------------------------------------------------------------------
// Remove host (offline only — a live agent re-registers)
// ---------------------------------------------------------------------------
const confirmHostId = ref<string | null>(null)
const removingHostId = ref<string | null>(null)
async function removeHost(host: FleetHost) {
removingHostId.value = host.id
try {
await fleet.removeHost(host.id)
toast.success(`Removed ${host.hostname}`)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to remove host')
} finally {
removingHostId.value = null
confirmHostId.value = null
}
}
/** Map host status → Badge tone */
function hostTone(status: string): 'online' | 'offline' | 'warn' {
if (status === 'connected') return 'online'
if (status === 'degraded') return 'warn'
return 'offline'
}
function hostStatusLabel(status: string): string {
if (status === 'connected') return 'Online'
if (status === 'degraded') return 'Degraded'
return 'Offline'
}
/** Map game instance state → Badge tone */
function instanceTone(state: string): 'online' | 'offline' | 'warn' | 'neutral' {
if (state === 'running') return 'online'
if (state === 'crashed') return 'offline'
if (state === 'stopped') return 'warn'
return 'neutral'
}
/** Format uptime seconds → human-readable "Xd Xh Xm" */
function formatUptime(seconds: number | null): string {
if (seconds == null || seconds < 0) return '—'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
/** Format memory used/total as "Xm / Xm" or "—" if null. */
function formatMem(used: number | null, total: number | null): string {
if (used == null && total == null) return '—'
const u = used != null ? `${Math.round(used)}MB` : '—'
const t = total != null ? `${Math.round(total)}MB` : '—'
return `${u} / ${t}`
}
/** Pick primary disk (first entry) for display. */
function primaryDisk(host: FleetHost): string {
if (!host.disks || host.disks.length === 0) return '—'
const d = host.disks[0]
if (d == null) return '—'
const freePct = d.total_mb > 0 ? Math.round((d.free_mb / d.total_mb) * 100) : 0
return `${d.mount} · ${freePct}% free`
}
/** Last heartbeat relative time — use safeDate, then strip full timestamp for brevity. */
function relativeHeartbeat(iso: string | null): string {
if (!iso) return 'Never'
return safeDate(iso)
}
</script>
<template>
<div class="fleet-view">
<!-- Page header -->
<div class="fleet-view__header">
<div>
<h1 class="fleet-view__title">Fleet</h1>
<p class="fleet-view__sub">Hosts and game instances connected to this license.</p>
</div>
<Button variant="ghost" icon="refresh-cw" :disabled="fleet.loading" @click="fleet.fetchFleet()">
Refresh
</Button>
</div>
<!-- Loading state -->
<div v-if="fleet.loading && !hasHosts" class="fleet-view__loading">
<Icon name="loader" :size="18" class="fleet-loading-icon" />
<span>Loading fleet data</span>
</div>
<!-- Error state (API failed / 401 / network error) honest, not global error boundary -->
<Panel v-else-if="fleet.error && !hasHosts" title="Could not load fleet data">
<EmptyState
icon="wifi-off"
title="Fleet data unavailable"
:description="fleet.error"
>
<template #action>
<Button variant="primary" @click="fleet.fetchFleet()">Try again</Button>
</template>
</EmptyState>
</Panel>
<!-- Empty state no hosts returned -->
<Panel v-else-if="!fleet.loading && !fleet.error && !hasHosts">
<EmptyState
icon="server"
title="No hosts connected yet"
description="Install the Corrosion host agent on your server machine to see it here."
>
<template #action>
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>
</template>
</EmptyState>
</Panel>
<!-- Populated fleet -->
<template v-else>
<!-- Summary strip -->
<div class="fleet-view__summary">
<StatCard
label="Total hosts"
:value="fleet.summary.host_count"
icon="server"
/>
<StatCard
label="Online hosts"
:value="fleet.summary.online_host_count"
icon="activity"
/>
<StatCard
label="Game instances"
:value="fleet.summary.instance_count"
icon="layers"
/>
</div>
<!-- Host cards -->
<div class="fleet-view__hosts">
<Panel
v-for="host in fleet.hosts"
:key="host.id"
class="fleet-host"
>
<!-- Host header -->
<template #default>
<div class="fleet-host__head">
<div class="fleet-host__identity">
<StatusDot :tone="hostTone(host.status)" :pulse="host.status === 'connected'" :size="9" />
<span class="fleet-host__name">{{ host.hostname }}</span>
<Badge :tone="hostTone(host.status)" :dot="false">{{ hostStatusLabel(host.status) }}</Badge>
</div>
<div class="fleet-host__meta">
<span class="fleet-host__meta-item" v-if="host.agent_version">
<Icon name="zap" :size="12" />v{{ host.agent_version }}
</span>
<span class="fleet-host__meta-item" v-if="host.os || host.arch">
<Icon name="cpu" :size="12" />{{ [host.os, host.arch].filter(Boolean).join(' / ') }}
</span>
<!-- Remove host offline only; a live agent re-registers -->
<template v-if="confirmHostId === host.id">
<span class="fleet-host__confirm">Remove host &amp; its instances?</span>
<Button
variant="danger-soft"
size="sm"
:loading="removingHostId === host.id"
@click="removeHost(host)"
>Remove</Button>
<Button variant="ghost" size="sm" :disabled="removingHostId === host.id" @click="confirmHostId = null">Cancel</Button>
</template>
<Button
v-else-if="host.status !== 'connected'"
variant="ghost"
size="sm"
icon="trash-2"
@click="confirmHostId = host.id"
>Remove</Button>
</div>
</div>
<!-- Host metrics row -->
<div class="fleet-host__metrics">
<div class="fleet-metric">
<span class="fleet-metric__label">CPU</span>
<span class="fleet-metric__value">
{{ host.cpu_percent != null ? safeFixed(host.cpu_percent, 1) + '%' : '—' }}
<span v-if="host.cpu_cores" class="fleet-metric__sub">{{ host.cpu_cores }} cores</span>
</span>
</div>
<div class="fleet-metric">
<span class="fleet-metric__label">Memory</span>
<span class="fleet-metric__value">{{ formatMem(host.mem_used_mb, host.mem_total_mb) }}</span>
</div>
<div class="fleet-metric">
<span class="fleet-metric__label">Disk</span>
<span class="fleet-metric__value">{{ primaryDisk(host) }}</span>
</div>
<div class="fleet-metric">
<span class="fleet-metric__label">Uptime</span>
<span class="fleet-metric__value">{{ formatUptime(host.uptime_seconds) }}</span>
</div>
<div class="fleet-metric">
<span class="fleet-metric__label">Last heartbeat</span>
<span class="fleet-metric__value fleet-metric__value--sm">{{ relativeHeartbeat(host.last_heartbeat_at) }}</span>
</div>
</div>
<!-- Instance list -->
<div v-if="host.instances.length > 0" class="fleet-host__instances">
<div class="fleet-instances__label t-eyebrow">Game instances ({{ host.instances.length }})</div>
<div class="fleet-instances__list">
<div
v-for="inst in host.instances"
:key="inst.id"
class="fleet-instance"
>
<StatusDot :tone="instanceTone(inst.state)" :size="7" />
<span class="fleet-instance__game">{{ inst.game }}</span>
<span v-if="inst.label" class="fleet-instance__label">{{ inst.label }}</span>
<Badge :tone="instanceTone(inst.state)" class="fleet-instance__badge">
{{ inst.state }}
</Badge>
<span class="fleet-instance__uptime">{{ formatUptime(inst.uptime_seconds) }}</span>
<span class="fleet-instance__seen">{{ safeDate(inst.last_seen_at) }}</span>
</div>
</div>
</div>
<!-- No instances under this host -->
<div v-else class="fleet-host__no-instances">
<Icon name="layers" :size="13" />
<span>No game instances reported</span>
</div>
</template>
</Panel>
</div>
</template>
</div>
</template>
<style scoped>
/* ---- Page shell ---- */
.fleet-view {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
max-width: 1100px;
}
.fleet-view__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.fleet-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.fleet-view__sub {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin: 2px 0 0;
}
.fleet-view__loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-tertiary);
font-size: var(--text-sm);
padding: 32px 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.fleet-loading-icon {
animation: spin 1s linear infinite;
color: var(--accent);
}
/* ---- Summary strip ---- */
.fleet-view__summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 640px) {
.fleet-view__summary { grid-template-columns: 1fr; }
}
/* ---- Host list ---- */
.fleet-view__hosts {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ---- Host card internals ---- */
.fleet-host__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px 12px;
}
.fleet-host__identity {
display: flex;
align-items: center;
gap: 9px;
}
.fleet-host__name {
font-weight: 600;
font-size: var(--text-base);
color: var(--text-primary);
font-family: var(--font-mono);
}
.fleet-host__meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.fleet-host__meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
/* ---- Metrics row ---- */
.fleet-host__metrics {
display: flex;
flex-wrap: wrap;
gap: 0;
border-top: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.fleet-metric {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 16px;
border-right: 1px solid var(--border-subtle);
flex: 1;
min-width: 110px;
}
.fleet-metric:last-child { border-right: none; }
.fleet-metric__label {
font-size: var(--text-xs);
color: var(--text-tertiary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fleet-metric__value {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: baseline;
gap: 5px;
}
.fleet-metric__value--sm {
font-size: 11px;
font-weight: 400;
}
.fleet-metric__sub {
font-size: var(--text-xs);
color: var(--text-muted);
font-weight: 400;
}
/* ---- Instance list ---- */
.fleet-host__instances {
padding: 12px 16px 14px;
}
.fleet-instances__label {
margin-bottom: 8px;
}
.fleet-instances__list {
display: flex;
flex-direction: column;
gap: 6px;
}
.fleet-instance {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
background: var(--surface-raised-2);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
}
.fleet-instance__game {
font-weight: 600;
color: var(--text-primary);
min-width: 60px;
}
.fleet-instance__label {
color: var(--text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fleet-instance__badge {
flex-shrink: 0;
}
.fleet-instance__uptime {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
min-width: 48px;
}
.fleet-instance__seen {
font-size: var(--text-xs);
color: var(--text-muted);
margin-left: auto;
}
/* ---- No instances ---- */
.fleet-host__no-instances {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px 14px;
font-size: var(--text-sm);
color: var(--text-muted);
}
</style>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAuthStore } from '@/stores/auth'
import { useInstancesStore } from '@/stores/instances'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
import { useApi } from '@/composables/useApi'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
@@ -19,10 +20,38 @@ import Switch from '@/components/ds/forms/Switch.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore()
const auth = useAuthStore()
const instancesStore = useInstancesStore()
const toast = useToastStore()
const { activeGame } = useThemeGame()
// ---- Current game instance (the thing this page actually manages) ----
const currentInstance = computed(() => instancesStore.current)
const instanceState = computed(() => currentInstance.value?.state ?? null)
const instanceRunning = computed(() => instanceState.value === 'running')
const instanceManaged = computed(() =>
!!instanceState.value && !['unmanaged', 'configured', 'missing_root'].includes(instanceState.value),
)
const instanceStateTone = computed<'online' | 'offline' | 'warn'>(() => {
const s = instanceState.value
if (s === 'running') return 'online'
if (s === 'crashed') return 'warn'
return 'offline'
})
const instanceStateLabel = computed(() => {
const s = instanceState.value
if (!s) return 'No instance'
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')
})
function fmtUptime(secs: number | undefined): string {
if (!secs || secs <= 0) return '—'
const d = Math.floor(secs / 86400)
const h = Math.floor((secs % 86400) / 3600)
const m = Math.floor((secs % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
// Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
const profile = computed(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
@@ -66,6 +95,18 @@ const deployLoading = ref(false)
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
const isInstallingOxide = ref(false)
// Agent credentials (fetched from /api/servers/agent-credentials on mount)
interface AgentCreds {
license_id: string
nats_user: string
nats_password: string
nats_url: string
}
const agentCreds = ref<AgentCreds | null>(null)
const showCreds = ref(false)
// Ref for the TOML block copy button
const tomlCopied = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
max_players: 100,
@@ -97,25 +138,62 @@ const agentLastSeenLabel = computed(() => {
return d.toLocaleDateString()
})
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
const linuxCommands = computed(() => `# Download the agent
curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64
chmod +x corrosion-host-agent-linux-amd64
# Start with your license key
export LICENSE_ID="${licenseKey.value}"
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
./corrosion-host-agent-linux-amd64`)
# Write /etc/corrosion/agent.toml (see config block below), then run:
sudo mkdir -p /etc/corrosion
sudo ./corrosion-host-agent-linux-amd64 --config /etc/corrosion/agent.toml`)
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
# Download the agent
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe" -OutFile "corrosion-host-agent-windows-amd64.exe"
# Start with your license key
$env:LICENSE_ID="${licenseKey.value}"
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
.\\corrosion-host-agent-windows-amd64.exe`)
# Write C:\\ProgramData\\Corrosion\\agent.toml (see config block below), then run:
New-Item -ItemType Directory -Force -Path "C:\\ProgramData\\Corrosion"
.\\corrosion-host-agent-windows-amd64.exe --config "C:\\ProgramData\\Corrosion\\agent.toml"`)
const agentTomlConfig = computed(() => {
const c = agentCreds.value
const licenseId = c?.license_id ?? 'YOUR-LICENSE-ID'
const natsUrl = c?.nats_url ?? 'nats://nats.corrosionmgmt.com:4222'
const natsUser = c?.nats_user ?? 'YOUR-LICENSE-ID'
const natsPassword = c ? (showCreds.value ? c.nats_password : '••••••••') : 'YOUR-AGENT-TOKEN'
return `[agent]
license_id = "${licenseId}"
nats_url = "${natsUrl}"
nats_user = "${natsUser}"
nats_password = "${natsPassword}"
heartbeat_seconds = 60
[[instance]]
id = "rust-main"
game = "rust"
root = "/opt/rustserver"
label = "My Server"`
})
// Returns the raw (unmasked) TOML for clipboard — always use actual password if available
const agentTomlConfigRaw = computed(() => {
const c = agentCreds.value
const licenseId = c?.license_id ?? 'YOUR-LICENSE-ID'
const natsUrl = c?.nats_url ?? 'nats://nats.corrosionmgmt.com:4222'
const natsUser = c?.nats_user ?? 'YOUR-LICENSE-ID'
const natsPassword = c?.nats_password ?? 'YOUR-AGENT-TOKEN'
return `[agent]
license_id = "${licenseId}"
nats_url = "${natsUrl}"
nats_user = "${natsUser}"
nats_password = "${natsPassword}"
heartbeat_seconds = 60
[[instance]]
id = "rust-main"
game = "rust"
root = "/opt/rustserver"
label = "My Server"`
})
async function copySetupCommands() {
try {
@@ -133,6 +211,16 @@ async function copySetupCommands() {
}
}
async function copyTomlConfig() {
try {
await navigator.clipboard.writeText(agentTomlConfigRaw.value)
tomlCopied.value = true
setTimeout(() => { tomlCopied.value = false }, 2000)
} catch {
// Clipboard API unavailable
}
}
async function startDeploy() {
if (!deployForm.value.rcon_password || deployForm.value.rcon_password.length < 6) return
deployLoading.value = true
@@ -238,20 +326,82 @@ async function saveConfig() {
}
async function serverAction(action: 'start' | 'stop' | 'restart') {
if (!currentInstance.value) {
toast.error('No game instance to control — connect the host agent first')
return
}
actionLoading.value = action
try {
if (action === 'start') await server.startServer()
else if (action === 'stop') await server.stopServer()
else await server.restartServer()
await server.fetchServer()
toast.success(`Server ${action} command sent`)
} catch {
toast.error(`Failed to ${action} server`)
const res = await instancesStore.lifecycle(action)
if ((res as { status?: string }).status === 'error') {
toast.error(String((res as { message?: string }).message ?? `Failed to ${action}`))
} else {
toast.success(`${currentInstance.value?.agent_instance_id ?? 'Instance'}: ${action} ok`)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : `Failed to ${action} server`)
} finally {
actionLoading.value = null
}
}
async function refreshInstanceStatus() {
if (!currentInstance.value) return
actionLoading.value = 'status'
try {
await instancesStore.lifecycle('status')
} catch {
/* status best-effort */
} finally {
actionLoading.value = null
}
}
// ---- Config file editor (reads/writes via the jailed agent file manager) ----
const cfgPath = ref('')
const cfgContent = ref('')
const cfgLoaded = ref(false)
const cfgLoading = ref(false)
const cfgSaving = ref(false)
const cfgError = ref<string | null>(null)
// A reasonable default config-file hint per game (operator can change it).
const cfgHint = computed(() => profile.value.primaryConfigFile ?? '')
async function loadConfigFile() {
const path = (cfgPath.value || cfgHint.value).trim()
if (!path || !currentInstance.value) return
cfgPath.value = path
cfgLoading.value = true
cfgError.value = null
try {
cfgContent.value = await instancesStore.readFile(path)
cfgLoaded.value = true
} catch (e) {
// Not-found is fine — present an empty editor to create it.
cfgContent.value = ''
cfgLoaded.value = true
cfgError.value = e instanceof Error ? e.message : 'File not found — saving will create it'
} finally {
cfgLoading.value = false
}
}
async function saveConfigFile() {
const path = cfgPath.value.trim()
if (!path || !currentInstance.value) return
cfgSaving.value = true
try {
await instancesStore.writeFile(path, cfgContent.value)
cfgError.value = null
toast.success(`Saved ${path}`)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to save file')
} finally {
cfgSaving.value = false
}
}
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
if (!server.config) return
const newValue = !server.config[field]
@@ -296,6 +446,17 @@ const connStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
onMounted(async () => {
await server.fetchServer()
loadFormFromConfig()
// Load the fleet's instances; prefer one matching the active game.
const game = activeGame.value === 'all' ? undefined : activeGame.value
await instancesStore.fetchInstances(game)
// Fetch agent credentials for the TOML config block (leave null on error — honest fallback)
try {
const creds = await useApi().get<AgentCreds | null>('/servers/agent-credentials')
agentCreds.value = creds
} catch {
agentCreds.value = null
}
const ws = useWebSocket()
ws.subscribe((msg) => {
@@ -360,31 +521,93 @@ onMounted(async () => {
</div>
</Panel>
<!-- Controls -->
<Panel title="Controls">
<div class="sv__controls">
<Button
variant="outline"
icon="play"
:loading="actionLoading === 'start'"
:disabled="server.connection?.connection_status === 'connected' || actionLoading !== null"
@click="serverAction('start')"
>Start server</Button>
<Button
variant="danger-soft"
icon="power"
:loading="actionLoading === 'stop'"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
@click="serverAction('stop')"
>Stop server</Button>
<Button
variant="secondary"
icon="refresh-cw"
:loading="actionLoading === 'restart'"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
@click="serverAction('restart')"
>Restart server</Button>
</div>
<!-- Game instance real per-instance state + lifecycle -->
<Panel title="Game instance">
<template #actions>
<Badge :tone="instanceStateTone" :dot="true" :pulse="instanceRunning">{{ instanceStateLabel }}</Badge>
</template>
<!-- No instance yet -->
<EmptyState
v-if="!currentInstance"
icon="server"
title="No game instance connected"
:description="'Install the host agent and add a ' + profile.label + ' instance to its config to manage it here.'"
/>
<template v-else>
<!-- Instance selector when more than one -->
<div v-if="instancesStore.instances.length > 1" class="sv__instance-pick sv__mb">
<span class="sv__field-label">Instance</span>
<select
class="sv__select"
:value="instancesStore.currentId ?? ''"
@change="instancesStore.select(($event.target as HTMLSelectElement).value)"
>
<option v-for="i in instancesStore.instances" :key="i.id" :value="i.id">
{{ i.label || i.agent_instance_id }} ({{ i.game }}) · {{ i.host_hostname }}
</option>
</select>
</div>
<!-- Instance facts -->
<div class="sv__grid4 sv__mb">
<div class="sv__field">
<div class="sv__field-label">Instance</div>
<div class="sv__field-val sv__field-val--mono">{{ currentInstance.agent_instance_id }}</div>
</div>
<div class="sv__field">
<div class="sv__field-label">State</div>
<div class="sv__field-val sv__field-val--inline">
<StatusDot :tone="instanceStateTone" :pulse="instanceRunning" />
<span>{{ instanceStateLabel }}</span>
</div>
</div>
<div class="sv__field">
<div class="sv__field-label">Uptime</div>
<div class="sv__field-val sv__field-val--mono">{{ fmtUptime(currentInstance.uptime_seconds) }}</div>
</div>
<div class="sv__field">
<div class="sv__field-label">Host</div>
<div class="sv__field-val sv__field-val--mono">{{ currentInstance.host_hostname }}</div>
</div>
</div>
<!-- Lifecycle controls gated on real instance state -->
<div class="sv__controls">
<Button
variant="outline"
icon="play"
:loading="actionLoading === 'start'"
:disabled="instanceRunning || !instanceManaged || actionLoading !== null"
@click="serverAction('start')"
>Start</Button>
<Button
variant="danger-soft"
icon="power"
:loading="actionLoading === 'stop'"
:disabled="!instanceRunning || actionLoading !== null"
@click="serverAction('stop')"
>Stop</Button>
<Button
variant="secondary"
icon="refresh-cw"
:loading="actionLoading === 'restart'"
:disabled="!instanceManaged || actionLoading !== null"
@click="serverAction('restart')"
>Restart</Button>
<Button
variant="ghost"
icon="refresh-cw"
:loading="actionLoading === 'status'"
:disabled="actionLoading !== null"
@click="refreshInstanceStatus"
>Refresh</Button>
</div>
<Alert v-if="!instanceManaged" tone="info" class="sv__mt-sm">
This instance is telemetry-only add an <code>executable</code> to its agent config to enable start/stop.
</Alert>
</template>
</Panel>
<!-- Host agent -->
@@ -463,10 +686,9 @@ onMounted(async () => {
<p class="sv__cmt"># Download the agent</p>
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
<p>chmod +x corrosion-host-agent-linux-amd64</p>
<p class="sv__cmt sv__mt">&#x23; Start with your license key</p>
<p>export LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
<p>export NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
<p>./corrosion-host-agent-linux-amd64</p>
<p class="sv__cmt sv__mt">&#x23; Write /etc/corrosion/agent.toml (see config block below), then run:</p>
<p>sudo mkdir -p /etc/corrosion</p>
<p>sudo ./corrosion-host-agent-linux-amd64 --config <span class="sv__accent">/etc/corrosion/agent.toml</span></p>
</div>
<!-- Windows commands -->
@@ -474,11 +696,38 @@ onMounted(async () => {
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
<p class="sv__cmt"># Download the agent</p>
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
<p class="sv__cmt sv__mt">&#x23; Start with your license key</p>
<p>$env:LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
<p>$env:NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
<p>.\corrosion-host-agent-windows-amd64.exe</p>
<p class="sv__cmt sv__mt">&#x23; Write C:\ProgramData\Corrosion\agent.toml (see config block below), then run:</p>
<p>New-Item -ItemType Directory -Force -Path <span class="sv__accent">"C:\ProgramData\Corrosion"</span></p>
<p>.\corrosion-host-agent-windows-amd64.exe --config <span class="sv__accent">"C:\ProgramData\Corrosion\agent.toml"</span></p>
</div>
<!-- Agent configuration (agent.toml) -->
<div class="sv__section-head sv__mt">
<Icon name="file-text" :size="14" />
<span>Agent configuration (agent.toml)</span>
</div>
<div class="sv__setup-head">
<div class="sv__toml-reveal">
<Button
variant="ghost"
size="sm"
:icon="showCreds ? 'eye-off' : 'eye'"
@click="showCreds = !showCreds"
>{{ showCreds ? 'Hide credentials' : 'Reveal credentials' }}</Button>
</div>
<Button
variant="secondary"
size="sm"
:icon="tomlCopied ? 'check' : 'copy'"
@click="copyTomlConfig"
>{{ tomlCopied ? 'Copied' : 'Copy' }}</Button>
</div>
<div class="sv__codeblock">
<pre class="sv__pre">{{ agentTomlConfig }}</pre>
</div>
<Alert v-if="!agentCreds" tone="warn" class="sv__mt">
Could not load credentials from server. Copy this config and replace the placeholders with values from your Corrosion dashboard settings.
</Alert>
</Panel>
<!-- Deploy Server Rust only (SteamCMD path). Other games use docker-compose or external tooling. -->
@@ -778,15 +1027,21 @@ onMounted(async () => {
<div class="sv__field-label">Max players</div>
<div class="sv__field-val sv__field-val--mono">{{ server.config?.max_players ?? '—' }}</div>
</div>
<div class="sv__field">
<!-- Rust-only: world size and seed are Facepunch/procgen concepts -->
<div v-if="isRust" class="sv__field">
<div class="sv__field-label">World size</div>
<div class="sv__field-val sv__field-val--mono">{{ server.config?.world_size ?? '—' }}</div>
</div>
<div class="sv__field">
<div v-if="isRust" class="sv__field">
<div class="sv__field-label">Current seed</div>
<div class="sv__field-val sv__field-val--mono">{{ server.config?.current_seed ?? '—' }}</div>
</div>
</div>
<!-- Non-Rust: game-specific settings live in config files on the host -->
<Alert v-if="!editMode && !isRust" tone="neutral" class="sv__mt">
Game-specific settings for {{ profile.label }} live in config files on the host — manage them in the
<Button variant="ghost" size="sm" icon="folder" @click="$router.push('/files')">File Manager</Button>
</Alert>
<!-- Edit mode -->
<form v-else @submit.prevent="saveConfig" class="sv__form">
@@ -803,7 +1058,9 @@ onMounted(async () => {
type="number"
:mono="true"
/>
<!-- Rust-only: world size and seed are Facepunch/procgen concepts -->
<Input
v-if="isRust"
:model-value="String(form.world_size)"
@update:model-value="v => { form.world_size = Number(v) }"
label="World size"
@@ -811,6 +1068,7 @@ onMounted(async () => {
:mono="true"
/>
<Input
v-if="isRust"
:model-value="String(form.current_seed)"
@update:model-value="v => { form.current_seed = Number(v) }"
label="Current seed"
@@ -818,10 +1076,53 @@ onMounted(async () => {
:mono="true"
class="sv__col-span2"
/>
<!-- Non-Rust: redirect to file manager for game-specific config -->
<Alert v-if="!isRust" tone="neutral" class="sv__col-span2">
Game-specific settings for {{ profile.label }} live in config files on the host — manage them in the
<Button variant="ghost" size="sm" icon="folder" @click="$router.push('/files')">File Manager</Button>
</Alert>
</div>
</form>
</Panel>
<!-- Config file editor — reads/writes via the jailed agent file manager -->
<Panel v-if="currentInstance" title="Configuration file" subtitle="Edit a config file directly on the host (jailed to the instance)">
<div class="sv__cfg-row sv__mb-sm">
<Input
v-model="cfgPath"
:placeholder="cfgHint || 'path/relative/to/instance/root'"
class="sv__cfg-path"
:mono="true"
/>
<Button
variant="secondary"
icon="folder-open"
:loading="cfgLoading"
:disabled="(!cfgPath && !cfgHint) || cfgLoading"
@click="loadConfigFile"
>Load</Button>
<Button
v-if="cfgLoaded"
icon="check"
:loading="cfgSaving"
:disabled="!cfgPath || cfgSaving"
@click="saveConfigFile"
>Save</Button>
</div>
<Alert v-if="cfgError" tone="info" class="sv__mb-sm">{{ cfgError }}</Alert>
<textarea
v-if="cfgLoaded"
v-model="cfgContent"
class="sv__cfg-editor"
spellcheck="false"
rows="16"
></textarea>
<p v-else class="sv__cfg-hint">
Load <code>{{ cfgHint || 'a config file' }}</code> to view and edit it. Changes are written
straight to the host through the agent — jailed to this instance's directory.
</p>
</Panel>
<!-- Automation -->
<Panel title="Automation">
<div class="sv__toggles">
@@ -907,6 +1208,36 @@ onMounted(async () => {
/* Controls */
.sv__controls { display: flex; flex-wrap: wrap; gap: 10px; }
.sv__mt-sm { margin-top: 12px; }
.sv__instance-pick { display: flex; align-items: center; gap: 12px; }
.sv__cfg-row { display: flex; gap: 10px; align-items: center; }
.sv__cfg-path { flex: 1; }
.sv__cfg-editor {
width: 100%;
min-height: 320px;
background: var(--surface-base);
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 14px 16px;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
resize: vertical;
}
.sv__cfg-hint { margin: 0; font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.55; }
.sv__select {
background: var(--surface-base);
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 8px 12px;
font-size: var(--text-sm);
font-family: var(--font-mono);
min-width: 280px;
}
/* Section head (label inside panel body) */
.sv__section-head {
@@ -931,6 +1262,12 @@ onMounted(async () => {
/* Setup head */
.sv__setup-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
/* TOML reveal row */
.sv__toml-reveal { display: flex; align-items: center; }
/* Pre inside codeblock — preserve whitespace, no extra margin */
.sv__pre { margin: 0; white-space: pre; }
/* Code block */
.sv__codeblock {
background: var(--surface-inset); border-radius: var(--radius-md);

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env node
// Generate corrosion-nats authorization config from the licenses table.
//
// Per-license isolation without auth-callout: each license maps to a NATS user
// (user = license UUID, password = HMAC-SHA256(license_id, NATS_TOKEN_SECRET))
// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX
// for request-reply). The backend uses a privileged internal user.
//
// STAGING (NATS_AUTH_STAGE env) — defaults to "enforce" (secure by default):
// "enforce" (default) — no anonymous; unauthenticated connections rejected.
// "open" — EXPLICIT opt-in for a brief migration window. Maps
// anonymous to a HARMLESS namespace (corrosion.unclaimed.>),
// NEVER full access, so a stale "open" deploy cannot
// read or forge real tenant (corrosion.{uuid}.>) traffic.
//
// REPLY SUBJECTS: per-license users are scoped to corrosion.{license}.> ONLY —
// no _INBOX grant (that would let one license read another's request-reply
// responses). Backend→agent request-reply MUST therefore use a reply subject
// inside the license namespace, e.g. corrosion.{license}.reply.<id>, not the
// default global _INBOX. The agent simply responds to msg.reply, so no agent
// change is needed — the constraint is on the requester (the internal user has
// full > and is unaffected).
//
// Usage:
// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \
// NATS_TOKEN_SECRET=... NATS_AUTH_STAGE=open node scripts/generate-nats-auth.mjs > docker/nats-auth.conf
//
// Re-run and reload NATS (`docker exec corrosion-nats nats-server --signal reload`)
// whenever licenses change.
import { createRequire } from 'node:module';
import { createHmac } from 'node:crypto';
const require = createRequire(new URL('../backend-nest/x.js', import.meta.url));
const { Client } = require('pg');
const {
DATABASE_URL,
NATS_INTERNAL_USER,
NATS_INTERNAL_PASSWORD,
NATS_TOKEN_SECRET,
NATS_AUTH_STAGE = 'enforce',
} = process.env;
for (const [k, v] of Object.entries({ DATABASE_URL, NATS_INTERNAL_USER, NATS_INTERNAL_PASSWORD, NATS_TOKEN_SECRET })) {
if (!v) { console.error(`Missing required env: ${k}`); process.exit(2); }
}
/** Per-license agent password — must match the backend's derivation. */
export function licensePassword(licenseId, secret) {
return createHmac('sha256', secret).update(licenseId).digest('hex');
}
const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const main = async () => {
const pg = new Client({ connectionString: DATABASE_URL });
await pg.connect();
const { rows } = await pg.query('SELECT id FROM licenses ORDER BY created_at');
await pg.end();
const lines = [];
lines.push('# GENERATED by scripts/generate-nats-auth.mjs — do not edit by hand.');
lines.push(`# stage=${NATS_AUTH_STAGE} licenses=${rows.length}`);
lines.push('authorization {');
lines.push(' users: [');
// Privileged internal user — the backend (full corrosion.> + _INBOX + _SYS).
lines.push(` { user: "${esc(NATS_INTERNAL_USER)}", password: "${esc(NATS_INTERNAL_PASSWORD)}", permissions: { publish: ">", subscribe: ">" } }`);
// Per-license scoped users — corrosion.{id}.> ONLY. No _INBOX grant:
// replies ride the license namespace (see header). This is the whole
// point — one license can never touch another's subjects.
for (const { id } of rows) {
const pw = licensePassword(id, NATS_TOKEN_SECRET);
const scope = `corrosion.${id}.>`;
lines.push(
` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` +
`publish: { allow: ["${scope}"] }, ` +
`subscribe: { allow: ["${scope}"] } } }`,
);
}
if (NATS_AUTH_STAGE === 'open') {
// EXPLICIT migration opt-in only. Anonymous gets a HARMLESS namespace —
// never real tenant subjects — so a stale "open" deploy leaks nothing.
lines.push(' { user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }');
}
lines.push(' ]');
lines.push('}');
// no_auth_user is a TOP-LEVEL field, NOT inside authorization { } — nesting
// it makes nats-server reject the whole config ("unknown field no_auth_user").
if (NATS_AUTH_STAGE === 'open') {
lines.push('no_auth_user: "anonymous"');
}
process.stdout.write(lines.join('\n') + '\n');
};
main().catch((e) => { console.error(e); process.exit(1); });