7 Commits

Author SHA1 Message Date
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
Vantz Stockwell
700dc2254d fix(host-agent): SECURITY — file manager copy/list no longer follow symlinks out of the jail
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 1m21s
Build Host Agent (Rust) / build (push) Successful in 1m34s
CI / integration (push) Has been cancelled
Automated security review (HIGH) caught a jail-escape my own review
missed: copy_recursive used fs::metadata (follows symlinks). A symlink
inside the jail pointing to e.g. /etc, then a 'copy' of its parent dir,
would dereference it and pull external content INTO the jail where it
could be read — a read-escape exfiltration. jail() validates only the
top-level src/dest; the recursive walk reintroduced the escape.

Fix: copy_recursive uses symlink_metadata and refuses any symlink
('symlinks are not followed across the jail boundary'). list() likewise
switched to symlink_metadata so it reports the link, never the
dereferenced target's size/type (info leak). Two regression tests added:
copy-symlink-exfil (asserts no external content lands inside) and
list-no-deref. 44/44 tests green. Rolled forward to alpha.4 (vulnerable
alpha.3 superseded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:57:08 -04:00
Vantz Stockwell
7fdca2cd4f chore(host-agent): bump to 2.0.0-alpha.3 (RCON + supervision + SteamCMD + 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 1m26s
Build Host Agent (Rust) / build (push) Successful in 1m35s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:52:05 -04:00
Vantz Stockwell
18f978dde1 feat(host-agent): Phase 1c — SteamCMD update + jailed file manager
steam_update func runs SteamCMD per game (rust/conan/soulmask app-ids;
dune rejected), streaming stdout to {instance}.steam_status. Jailed
file manager on {instance}.files.cmd: list/read/write/delete/rename/
mkdir/mkfile/move/copy, all confined to instance root via two-stage
lexical-normalize + canonicalize (defeats ../ traversal AND symlink
escape — incl chained symlinks). Replaces the Go agent's UNJAILED
legacy files API (retired, not ported). 5MiB read cap.

42/42 tests green: 24 filemanager incl 7 jail-escape attempts
(dotdot, deep dotdot, absolute, symlink-inside, direct symlink,
chained symlink), 5 steamcmd app-id (cfg-gated win/linux soulmask).
Jail logic reviewed line-by-line: Path::starts_with is component-wise
(no sibling-prefix bypass), non-existent suffix components can't be
symlinks, leading .. normalizes to / and fails the prefix check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:51:46 -04:00
36 changed files with 2933 additions and 56 deletions

View File

@@ -447,3 +447,5 @@ 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.

View File

@@ -45,6 +45,7 @@ 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';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -52,6 +53,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 +98,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 +134,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
FleetModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -6,6 +6,13 @@ export default () => ({
},
nats: {
url: process.env.NATS_URL || 'nats://localhost: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,19 @@
import { Controller, Get } 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);
}
}

View File

@@ -0,0 +1,14 @@
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';
@Module({
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance])],
controllers: [FleetController],
providers: [FleetService],
exports: [FleetService],
})
export class FleetModule {}

View File

@@ -0,0 +1,134 @@
import { Injectable } 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';
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>,
) {}
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

@@ -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,24 @@ 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;
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 +112,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 +216,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 +241,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 +249,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

@@ -13,8 +13,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}`);
}

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

@@ -264,7 +264,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "corrosion-host-agent"
version = "2.0.0-alpha.2"
version = "2.0.0-alpha.5"
dependencies = [
"anyhow",
"async-nats",
@@ -276,6 +276,7 @@ dependencies = [
"serde",
"serde_json",
"sysinfo",
"tempfile",
"tokio",
"tokio-tungstenite",
"tokio-util",
@@ -446,6 +447,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -458,6 +465,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -576,6 +589,28 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -710,6 +745,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@@ -738,7 +779,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@@ -770,12 +813,24 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -832,7 +887,7 @@ dependencies = [
"data-encoding",
"ed25519",
"ed25519-dalek",
"getrandom",
"getrandom 0.2.17",
"log",
"rand",
"signatory",
@@ -982,6 +1037,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1000,6 +1065,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
@@ -1027,7 +1098,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.17",
]
[[package]]
@@ -1096,7 +1167,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -1111,6 +1182,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
@@ -1455,6 +1539,19 @@ dependencies = [
"windows",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -1731,6 +1828,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -1785,6 +1888,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.123"
@@ -1830,6 +1951,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -2055,6 +2210,100 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.3"

View File

@@ -1,6 +1,6 @@
[package]
name = "corrosion-host-agent"
version = "2.0.0-alpha.2"
version = "2.0.0-alpha.5"
edition = "2021"
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
license = "UNLICENSED"
@@ -30,6 +30,9 @@ tokio-tungstenite = "0.24"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"
# Size-optimized release: single static binary living next to RAM-heavy game
# servers. Panic stays 'unwind' so a panicking task surfaces through its
# JoinHandle instead of killing the whole agent.

View File

@@ -110,9 +110,29 @@ conan/soulmask; explicit `kind` override available in the instance's
Errors reply `{ "status": "error", "message": ... }` — including start on an
unmanaged instance, double start, missing rcon config, and unknown funcs.
Planned funcs: `steam_update`, `oxide_install` (rust), plus
game-adapter-specific commands (Dune: docker lifecycle, RabbitMQ bus
commands, Coriolis reset).
Also implemented: `steam_update` `{ "func": "steam_update" }` runs
SteamCMD for the instance's game (app ids: rust 258550, conan 443030,
soulmask 3017310/3017300; dune rejects — Docker images, no SteamCMD),
streaming progress lines to `corrosion.{license}.{instance}.steam_status`
and replying on completion.
Planned funcs: `oxide_install` (rust), plus game-adapter-specific
commands (Dune: docker lifecycle, RabbitMQ bus commands, Coriolis reset).
### `corrosion.{license_id}.{instance_id}.steam_status` (agent → backend, publish) — LIVE
Per-line SteamCMD stdout during a `steam_update`, so the panel can show
live update progress. Payload: `{ "timestamp", "instance_id", "line" }`.
### `corrosion.{license_id}.{instance_id}.files.cmd` (backend → agent, request-reply) — LIVE
Jailed file manager, confined to the instance `root` (two-stage check:
lexical normalize + canonicalize, defeating `../` traversal and symlink
escape). Request `{ "op": "list|read|write|delete|rename|mkdir|mkfile|move|copy",
"path": "rel/path", "dest"?, "content"?, "name"? }`; reply
`{ "status": "success", "data": ... }` or `{ "status": "error", "message": ... }`.
`read` caps at 5 MiB. Replaces the Go agent's UNJAILED legacy files API,
which is retired and will not be ported.
### `corrosion.{license_id}.{instance_id}.status` (agent → backend, publish) — LIVE

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"
@@ -46,6 +50,16 @@ password = "changeme"
# password = "changeme"
# # kind = "source" # inferred automatically for soulmask
# SteamCMD update settings — optional sub-table for any instance.
# Absent = defaults: steamcmd binary resolved via PATH, validate = false.
#
# [instance.steamcmd]
# steamcmd_path = "/opt/steamcmd/steamcmd.sh" # omit to use PATH
# validate = true # enable file-hash check pass
#
# Dune instances do not use SteamCMD (Docker images); the steam_update func
# will return a clear error if invoked on a dune instance.
[prober]
interval_seconds = 300

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

@@ -11,6 +11,7 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::rcon::RconConfig;
use crate::steamcmd::SteamcmdConfig;
/// Instance ids share the NATS subject namespace with host-level segments.
const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"];
@@ -33,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")]
@@ -65,6 +72,10 @@ pub struct InstanceConfig {
/// Protocol defaults to WebRcon for rust, Source for conan/soulmask.
#[serde(default)]
pub rcon: Option<RconConfig>,
/// SteamCMD update settings. Absent = defaults apply (steamcmd on PATH,
/// validate = false).
#[serde(default)]
pub steamcmd: Option<SteamcmdConfig>,
}
impl InstanceConfig {
@@ -117,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>,
@@ -162,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();
@@ -191,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

@@ -0,0 +1,544 @@
//! Jailed file manager for game-server install directories.
//!
//! Every path operation is confined to the instance `root` — the directory
//! declared as `root` in `[[instance]]` config. A two-stage check (lexical
//! Clean + `std::fs::canonicalize`) prevents both `../..` traversals and
//! symlink-based escapes: even if an attacker plants a symlink inside the root
//! that points outside it, `canonicalize` resolves the target and the prefix
//! check catches the escape.
//!
//! The NATS request/reply contract mirrors the Go companion agent's jailed file
//! manager (see `companion-agent/internal/filemanager/`) but uses a simpler
//! flat JSON envelope rather than the VueFinder storage-path protocol — the
//! Rust agent is the replacement, and the panel's backend talks to whichever
//! agent is present.
//!
//! Subject: `corrosion.{license}.{instance}.files.cmd`
//! Request: `{"op":"list"|"read"|"write"|"delete"|"rename"|"mkdir"|"mkfile"|"move"|"copy",
//! "path":"rel/path", "dest"?:"...", "content"?:"...", "name"?:"..."}`
//! Response: `{"status":"success","data":...}` or `{"status":"error","message":"..."}`
use anyhow::{bail, Context};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// Maximum size for a `read` operation (5 MiB). Larger files must be
/// transferred through a dedicated download endpoint, not the file manager.
const MAX_READ_SIZE: u64 = 5 * 1024 * 1024;
// ---------------------------------------------------------------------------
// Wire types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct FileRequest {
pub op: String,
/// Relative path within the instance root (the "subject" of the operation).
#[serde(default)]
pub path: String,
/// Destination for `rename`, `move`, `copy` — relative to instance root.
#[serde(default)]
pub dest: Option<String>,
/// Text content for `write`.
#[serde(default)]
pub content: Option<String>,
/// Bare filename for `mkdir` and `mkfile`.
#[serde(default)]
pub name: Option<String>,
}
/// A single directory entry returned by `list`.
#[derive(Debug, Serialize)]
pub struct FileEntry {
pub name: String,
/// Path relative to the instance root, using forward slashes.
pub path: String,
pub is_dir: bool,
/// File size in bytes. Zero for directories.
pub size: u64,
/// RFC 3339 modification timestamp.
pub modified: String,
}
// ---------------------------------------------------------------------------
// Jail helper — the security core of this module
// ---------------------------------------------------------------------------
/// Resolve `rel` against `root`, then canonicalize to reject any form of
/// escape including `../..` traversals and symlinks that point outside root.
///
/// For paths that do not yet exist (e.g. write targets), we canonicalize the
/// nearest existing ancestor and then re-join the remaining components, which
/// are lexically-clean because they went through `std::path::Path` building.
///
/// Returns the absolute, canonicalized path if it is within `root`.
pub fn jail(root: &Path, rel: &str) -> anyhow::Result<PathBuf> {
// Canonicalize root once to get a stable prefix for comparison.
// We do this on every call rather than caching so the function stays
// pure and testable without Agent state.
let canon_root = fs::canonicalize(root)
.with_context(|| format!("canonicalize instance root '{}'", root.display()))?;
// Build the candidate absolute path. We use Path joining so that an
// absolute `rel` (e.g. "/etc/passwd") replaces the root entirely — we
// detect and reject that case immediately.
let candidate = if rel.is_empty() || rel == "." {
root.to_path_buf()
} else {
let rel_path = Path::new(rel);
if rel_path.is_absolute() {
bail!(
"absolute path '{}' is not allowed; supply a path relative to the instance root",
rel
);
}
root.join(rel_path)
};
// Normalize lexically first (removes `..` / `.` without filesystem access).
// This is a defence-in-depth step; the authoritative check is below.
let lexical = normalize_lexical(&candidate);
// Canonicalize: resolve symlinks and `..` via the kernel.
// For a not-yet-existing path we walk up to the nearest existing ancestor.
let canon = canonicalize_lenient(&lexical)?;
// Authoritative prefix check: the resolved path must be equal to or a
// child of the canonicalized root.
if canon != canon_root && !canon.starts_with(&canon_root) {
bail!(
"path '{}' resolves to '{}' which is outside the instance root '{}'",
rel,
canon.display(),
canon_root.display()
);
}
Ok(canon)
}
/// Canonicalize a path that may not fully exist yet by walking up to the
/// nearest existing ancestor, canonicalizing it, then re-joining the remaining
/// (lexically-clean) suffix.
fn canonicalize_lenient(path: &Path) -> anyhow::Result<PathBuf> {
// Fast path: path already exists.
if let Ok(c) = fs::canonicalize(path) {
return Ok(c);
}
// Walk up until we find an ancestor that exists.
let mut existing = path.to_path_buf();
let mut suffix: Vec<std::ffi::OsString> = Vec::new();
loop {
match fs::canonicalize(&existing) {
Ok(canon) => {
// Re-attach the non-existing suffix.
let mut result = canon;
for component in suffix.iter().rev() {
result = result.join(component);
}
return Ok(result);
}
Err(_) => {
let file_name = match existing.file_name() {
Some(n) => n.to_os_string(),
None => bail!("cannot resolve path '{}'", path.display()),
};
suffix.push(file_name);
existing = match existing.parent() {
Some(p) => p.to_path_buf(),
None => bail!("cannot resolve path '{}'", path.display()),
};
}
}
}
}
/// Lexically normalize a path (remove `.` and `..` components) without
/// touching the filesystem. This mirrors `filepath.Clean` in Go.
fn normalize_lexical(path: &Path) -> PathBuf {
let mut components: Vec<std::path::Component> = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
// Only pop a normal component — we cannot pop a root prefix.
if matches!(components.last(), Some(std::path::Component::Normal(_))) {
components.pop();
} else {
components.push(component);
}
}
other => components.push(other),
}
}
components.iter().collect()
}
// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------
/// List the contents of a directory. Returns an entry per item, sorted
/// (directories first, then files, both alphabetical).
pub fn list(root: &Path, rel: &str) -> anyhow::Result<Vec<FileEntry>> {
let abs = jail(root, rel)?;
// Use the canonicalized root as the prefix for relative path computation so
// that symlinked root paths (e.g. macOS /var → /private/var) don't cause
// strip_prefix to fail and fall back to leaking the absolute path.
let canon_root = fs::canonicalize(root)
.with_context(|| format!("canonicalize root '{}'", root.display()))?;
let rd = fs::read_dir(&abs)
.with_context(|| format!("read_dir '{}'", abs.display()))?;
let mut entries: Vec<FileEntry> = Vec::new();
for item in rd {
let item = item.with_context(|| format!("reading directory entry in '{}'", abs.display()))?;
// symlink_metadata (lstat): report the link itself, never the target —
// following it would leak the size/type/existence of files outside the
// jail. A symlink lists as a zero-ish-size non-dir entry.
let meta = fs::symlink_metadata(item.path())
.with_context(|| format!("stat '{}'", item.path().display()))?;
let name = item.file_name().to_string_lossy().into_owned();
let is_dir = meta.is_dir();
let size = if is_dir { 0 } else { meta.len() };
// Build the relative path from the canonicalized root.
let entry_abs = item.path();
let entry_rel = entry_abs
.strip_prefix(&canon_root)
.unwrap_or(&entry_abs)
.to_string_lossy()
.replace('\\', "/");
let modified = meta
.modified()
.ok()
.map(|t| {
let dt: DateTime<Utc> = t.into();
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
})
.unwrap_or_default();
entries.push(FileEntry { name, path: entry_rel, is_dir, size, modified });
}
// Stable sort: dirs first, then alphabetical within each group.
entries.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
/// Read a text file. Capped at `MAX_READ_SIZE` bytes.
pub fn read(root: &Path, rel: &str) -> anyhow::Result<String> {
let abs = jail(root, rel)?;
let meta = fs::metadata(&abs)
.with_context(|| format!("stat '{}'", abs.display()))?;
if meta.is_dir() {
bail!("'{}' is a directory, not a file", rel);
}
if meta.len() > MAX_READ_SIZE {
bail!(
"file '{}' is {} bytes which exceeds the {} byte read limit",
rel,
meta.len(),
MAX_READ_SIZE
);
}
fs::read_to_string(&abs).with_context(|| format!("read '{}'", abs.display()))
}
/// Write (create or overwrite) a file. Parent directories are created as
/// needed.
pub fn write(root: &Path, rel: &str, content: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
fs::write(&abs, content.as_bytes())
.with_context(|| format!("write '{}'", abs.display()))
}
/// Delete a file or directory tree.
pub fn delete(root: &Path, rel: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
let meta = fs::metadata(&abs)
.with_context(|| format!("stat '{}'", abs.display()))?;
if meta.is_dir() {
fs::remove_dir_all(&abs).with_context(|| format!("remove_dir_all '{}'", abs.display()))
} else {
fs::remove_file(&abs).with_context(|| format!("remove_file '{}'", abs.display()))
}
}
/// Rename/move `rel` to a new bare name (`new_name`) within the same parent.
/// `new_name` must not contain path separators.
pub fn rename(root: &Path, rel: &str, new_name: &str) -> anyhow::Result<()> {
if new_name.is_empty() || new_name == "." || new_name == ".." {
bail!("new_name '{}' is not a valid filename", new_name);
}
if new_name.contains('/') || new_name.contains('\\') {
bail!("new_name '{}' must not contain path separators", new_name);
}
let src_abs = jail(root, rel)?;
// Construct the destination relative path by replacing the filename part
// of `rel` with `new_name`. This keeps everything in relative-path space
// so we never hand an absolute path to `jail`.
let src_rel = Path::new(rel);
let dest_rel = match src_rel.parent() {
Some(parent) if parent != Path::new("") => {
parent.join(new_name).to_string_lossy().replace('\\', "/")
}
_ => new_name.to_string(),
};
let dest_abs = jail(root, &dest_rel)?;
fs::rename(&src_abs, &dest_abs)
.with_context(|| format!("rename '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}
/// Create a directory (and any missing parents) at `rel`.
pub fn mkdir(root: &Path, rel: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
fs::create_dir_all(&abs).with_context(|| format!("mkdir '{}'", abs.display()))
}
/// Create an empty file at `rel`. Fails if it already exists.
pub fn mkfile(root: &Path, rel: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
let _ = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&abs)
.with_context(|| format!("mkfile '{}'", abs.display()))?;
Ok(())
}
/// Move `src` to `dest` (both relative to root).
pub fn move_path(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
let src_abs = jail(root, src)?;
let dest_abs = jail(root, dest)?;
if let Some(parent) = dest_abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
fs::rename(&src_abs, &dest_abs).or_else(|_| {
// Cross-device move: copy then delete.
copy_recursive(&src_abs, &dest_abs)?;
fs::remove_dir_all(&src_abs)
.with_context(|| format!("remove source '{}' after cross-device move", src_abs.display()))
}).with_context(|| format!("move '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}
/// Copy `src` to `dest` (both relative to root).
pub fn copy(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
let src_abs = jail(root, src)?;
let dest_abs = jail(root, dest)?;
if let Some(parent) = dest_abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
copy_recursive(&src_abs, &dest_abs)
.with_context(|| format!("copy '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}
/// Recursive copy helper.
///
/// SECURITY: uses `symlink_metadata` (does NOT follow symlinks) and refuses to
/// copy any symlink. `jail()` only validates the top-level src/dest; a symlink
/// *inside* a copied directory that points outside the jail would, if followed,
/// pull external content (e.g. `/etc`) into the jail where it could then be
/// read — a jail-escape exfiltration. Refusing symlinks closes that path.
fn copy_recursive(src: &Path, dest: &Path) -> anyhow::Result<()> {
let meta = fs::symlink_metadata(src)
.with_context(|| format!("stat source '{}'", src.display()))?;
if meta.file_type().is_symlink() {
bail!(
"refusing to copy symlink '{}' — symlinks are not followed across the jail boundary",
src.display()
);
}
if meta.is_dir() {
fs::create_dir_all(dest)
.with_context(|| format!("create_dir_all '{}'", dest.display()))?;
for entry in fs::read_dir(src)
.with_context(|| format!("read_dir '{}'", src.display()))?
{
let entry = entry?;
copy_recursive(&entry.path(), &dest.join(entry.file_name()))?;
}
} else {
fs::copy(src, dest)
.with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// NATS request dispatch
// ---------------------------------------------------------------------------
/// Dispatch a `FileRequest` against `root` and return a JSON `serde_json::Value`
/// ready for the NATS reply.
pub fn dispatch(root: &Path, req: &FileRequest) -> serde_json::Value {
use serde_json::json;
let result = match req.op.as_str() {
"list" => {
list(root, &req.path).map(|entries| json!({ "entries": entries }))
}
"read" => {
read(root, &req.path).map(|content| json!({ "content": content }))
}
"write" => {
let content = req.content.as_deref().unwrap_or("");
write(root, &req.path, content).map(|_| json!(null))
}
"delete" => {
delete(root, &req.path).map(|_| json!(null))
}
"rename" => {
let new_name = req.name.as_deref().unwrap_or("");
rename(root, &req.path, new_name).map(|_| json!(null))
}
"mkdir" => {
mkdir(root, &req.path).map(|_| json!(null))
}
"mkfile" => {
mkfile(root, &req.path).map(|_| json!(null))
}
"move" => {
let dest = req.dest.as_deref().unwrap_or("");
move_path(root, &req.path, dest).map(|_| json!(null))
}
"copy" => {
let dest = req.dest.as_deref().unwrap_or("");
copy(root, &req.path, dest).map(|_| json!(null))
}
other => Err(anyhow::anyhow!(
"unknown op '{}' (supported: list, read, write, delete, rename, mkdir, mkfile, move, copy)",
other
)),
};
match result {
Ok(data) => json!({ "status": "success", "data": data }),
Err(e) => {
tracing::warn!("filemanager op='{}' path='{}': {e:#}", req.op, req.path);
json!({ "status": "error", "message": format!("{e:#}") })
}
}
}
/// Subscribe to `corrosion.{license}.{instance}.files.cmd` and serve file
/// manager requests for `instance_id` jailed to `root`.
///
/// This function runs until the agent's cancellation token fires or the NATS
/// subscription ends. It is spawned once per instance in `main.rs`.
pub async fn run(
agent: std::sync::Arc<crate::agent::Agent>,
instance_id: String,
root: PathBuf,
) -> anyhow::Result<()> {
use futures::StreamExt;
let subject = crate::subjects::instance_files_cmd(&agent.cfg.license_id, &instance_id);
let mut sub = agent.nats.subscribe(subject.clone()).await?;
tracing::info!("file manager handler listening on {subject}");
let cancel = agent.shutdown.clone();
loop {
tokio::select! {
msg = sub.next() => {
match msg {
Some(msg) => {
let agent = agent.clone();
let root = root.clone();
let instance_id = instance_id.clone();
tokio::spawn(async move { handle(agent, &instance_id, &root, msg).await });
}
None => {
tracing::warn!("file manager subscription ended for '{instance_id}'");
break;
}
}
}
_ = cancel.cancelled() => {
tracing::info!("file manager handler stopping for '{instance_id}'");
break;
}
}
}
Ok(())
}
async fn handle(
agent: std::sync::Arc<crate::agent::Agent>,
instance_id: &str,
root: &Path,
msg: async_nats::Message,
) {
let Some(reply) = msg.reply.clone() else {
tracing::warn!("file manager message without reply subject ignored (instance '{instance_id}')");
return;
};
let response = match serde_json::from_slice::<FileRequest>(&msg.payload) {
Ok(req) => {
// Blocking fs calls — offload from the async executor.
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || dispatch(&root, &req))
.await
.unwrap_or_else(|e| {
serde_json::json!({ "status": "error", "message": format!("internal error: {e}") })
})
}
Err(e) => {
serde_json::json!({ "status": "error", "message": format!("invalid request payload: {e}") })
}
};
let bytes = match serde_json::to_vec(&response) {
Ok(b) => b,
Err(e) => {
tracing::error!("file manager response serialize failed: {e}");
return;
}
};
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
tracing::warn!("file manager response publish failed: {e}");
}
}

View File

@@ -15,6 +15,7 @@ use std::sync::Arc;
use crate::agent::Agent;
use crate::process::ProcessSupervisor;
use crate::subjects;
use crate::steamcmd;
#[derive(Debug, Deserialize)]
struct InstanceCommand {
@@ -175,10 +176,84 @@ async fn dispatch(
}),
};
}
"steam_update" => {
// Look up instance config for game name, root, and optional steamcmd
// settings. The supervisor only carries process-control state, not
// the full config, so we reach into agent.cfg.instances here as the
// rcon dispatch does.
let inst_cfg = agent.cfg.instances.iter().find(|i| i.id == sup.instance_id);
let Some(inst_cfg) = inst_cfg else {
return json!({
"status": "error",
"func": "steam_update",
"instance_id": sup.instance_id,
"message": format!("no config found for instance '{}'", sup.instance_id),
});
};
let game = inst_cfg.game.as_str();
let root = inst_cfg.root.clone();
// Resolve steamcmd path and validate flag from config or use defaults.
let (steamcmd_path, validate) = match inst_cfg.steamcmd.as_ref() {
Some(s) => {
let path = s
.steamcmd_path
.as_ref()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "steamcmd".to_string());
(path, s.validate)
}
None => ("steamcmd".to_string(), false),
};
let license = agent.cfg.license_id.clone();
let instance_id = sup.instance_id.clone();
let nats = agent.nats.clone();
// Publish each progress line to the steam_status subject.
let on_progress = move |line: &str| {
let subject = subjects::instance_steam_status(&license, &instance_id);
let event = json!({
"timestamp": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
"instance_id": instance_id,
"line": line,
});
match serde_json::to_vec(&event) {
Ok(bytes) => {
// Fire-and-forget; the async publish is non-blocking on
// the caller side. We create a mini-runtime task via
// a oneshot since on_progress is Fn (not async).
let nats = nats.clone();
tokio::spawn(async move {
if let Err(e) = nats.publish(subject, bytes.into()).await {
tracing::warn!("steam_status publish failed: {e}");
}
});
}
Err(e) => tracing::error!("steam_status serialize failed: {e}"),
}
};
return match steamcmd::update(game, &root, &steamcmd_path, validate, on_progress).await {
Ok(()) => json!({
"status": "success",
"func": "steam_update",
"instance_id": sup.instance_id,
}),
Err(e) => json!({
"status": "error",
"func": "steam_update",
"instance_id": sup.instance_id,
"message": format!("{e:#}"),
}),
};
}
other => {
return json!({
"status": "error",
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon)"),
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"),
});
}
};

View File

@@ -4,11 +4,13 @@
pub mod agent;
pub mod bus;
pub mod config;
pub mod filemanager;
pub mod hostcmd;
pub mod instancecmd;
pub mod prober;
pub mod process;
pub mod rcon;
pub mod steamcmd;
pub mod subjects;
pub mod telemetry;
pub mod version;

View File

@@ -5,7 +5,8 @@
//! game adapters arrive in Phase 1+ (see PROTOCOL.md).
use corrosion_host_agent::{
agent, bus, config, hostcmd, instancecmd, prober, process, subjects, telemetry, version,
agent, bus, config, filemanager, hostcmd, instancecmd, prober, process, subjects, telemetry,
version,
};
use anyhow::{Context, Result};
@@ -117,7 +118,7 @@ async fn run(settings: config::Settings) -> Result<()> {
}
}));
}
for sup in agent.supervisors.values() {
for (instance_id, sup) in &agent.supervisors {
{
let agent = agent.clone();
let sup = sup.clone();
@@ -131,6 +132,24 @@ async fn run(settings: config::Settings) -> Result<()> {
agent.clone(),
sup.clone(),
)));
// File manager: one handler task per instance, jailed to root.
{
let agent = agent.clone();
let inst_cfg = agent
.cfg
.instances
.iter()
.find(|i| &i.id == instance_id)
.cloned();
if let Some(cfg) = inst_cfg {
let id = instance_id.clone();
handles.push(tokio::spawn(async move {
if let Err(e) = filemanager::run(agent, id, cfg.root).await {
tracing::error!("file manager handler failed: {e:#}");
}
}));
}
}
}
wait_for_shutdown_signal().await;

View File

@@ -0,0 +1,126 @@
//! SteamCMD update integration for process-managed game instances.
//!
//! Wraps the `steamcmd` binary to perform an `+app_update` for a given game
//! instance, streaming stdout lines to a caller-supplied progress callback so
//! the panel can display live update output. The agent already runs a task per
//! command in a separate `tokio::spawn`, so the blocking-until-done semantics
//! here are intentional — the NATS reply is sent only when SteamCMD exits.
//!
//! Dune is Docker-image-based and explicitly has no SteamCMD integration — any
//! attempt to invoke `update` on a Dune instance returns a clear error rather
//! than a silent no-op.
use std::path::Path;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
/// Return the Steam app ID for a given game name, or `None` for Dune (Docker).
///
/// Soulmask returns the Windows or Linux server app ID depending on the compile
/// target so this function is `#[cfg]`-gated at the platform level.
pub fn app_id_for_game(game: &str) -> Option<u32> {
match game {
"rust" => Some(258550),
"conan" => Some(443030),
"soulmask" => {
#[cfg(windows)]
{
Some(3017310)
}
#[cfg(not(windows))]
{
Some(3017300)
}
}
// Dune uses Docker images — SteamCMD has no role here.
"dune" => None,
_ => None,
}
}
/// Configuration controlling SteamCMD behaviour for one instance.
/// Serialised as `[instance.steamcmd]` in agent.toml.
#[derive(Debug, Clone, serde::Deserialize, Default)]
pub struct SteamcmdConfig {
/// Absolute or relative path to the `steamcmd` binary.
/// Defaults to `"steamcmd"` (resolved via `PATH`) when absent.
#[serde(default)]
pub steamcmd_path: Option<std::path::PathBuf>,
/// Whether to pass `validate` to `+app_update`. Adds a file-hash check
/// pass that catches corruption at the cost of a longer update time.
#[serde(default)]
pub validate: bool,
}
/// Run a SteamCMD update for `game` into `install_dir`.
///
/// - `steamcmd_path`: path to the binary (or `"steamcmd"` to use PATH).
/// - `validate`: appends `validate` to the `+app_update` call.
/// - `on_progress`: receives each stdout line as it arrives so callers can
/// forward progress to the panel in real time.
///
/// Returns `Ok(())` on a zero exit code, otherwise an error describing the
/// failure. Dune is rejected before any process is spawned.
pub async fn update(
game: &str,
install_dir: &Path,
steamcmd_path: &str,
validate: bool,
on_progress: impl Fn(&str),
) -> anyhow::Result<()> {
use anyhow::Context;
let app_id = app_id_for_game(game).ok_or_else(|| {
anyhow::anyhow!(
"dune uses Docker images, not SteamCMD — cannot run app_update for game '{game}'"
)
})?;
let install_dir_str = install_dir
.to_str()
.with_context(|| format!("install_dir '{}' is not valid UTF-8", install_dir.display()))?;
let mut args: Vec<String> = vec![
"+force_install_dir".to_string(),
install_dir_str.to_string(),
"+login".to_string(),
"anonymous".to_string(),
"+app_update".to_string(),
app_id.to_string(),
];
if validate {
args.push("validate".to_string());
}
args.push("+quit".to_string());
tracing::info!(
"steamcmd: starting update for game={game} app_id={app_id} install_dir={} validate={validate}",
install_dir.display()
);
let mut child = Command::new(steamcmd_path)
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| format!("spawning steamcmd binary '{steamcmd_path}'"))?;
let stdout = child.stdout.take().expect("stdout was piped");
let mut lines = BufReader::new(stdout).lines();
while let Some(line) = lines.next_line().await.context("reading steamcmd stdout")? {
tracing::debug!("steamcmd: {line}");
on_progress(&line);
}
let status = child.wait().await.context("waiting for steamcmd to exit")?;
if status.success() {
tracing::info!("steamcmd: update completed successfully for game={game}");
Ok(())
} else {
let code = status.code().unwrap_or(-1);
anyhow::bail!("steamcmd exited with non-zero status {code} for game={game}")
}
}

View File

@@ -26,3 +26,14 @@ pub fn instance_cmd(license: &str, instance: &str) -> String {
pub fn instance_status(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.status")
}
/// Per-instance SteamCMD progress stream. Lines from `steamcmd` stdout are
/// published here so the panel can display live update output.
pub fn instance_steam_status(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.steam_status")
}
/// Per-instance file manager command channel (request-reply).
pub fn instance_files_cmd(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.files.cmd")
}

View File

@@ -0,0 +1,461 @@
//! Integration tests for the jailed file manager.
//!
//! Each test runs in a real tempdir on the host filesystem. The jail-escape
//! tests are the security-critical section: any path that resolves outside the
//! instance root MUST be rejected regardless of how the escape is attempted.
//!
//! Coverage:
//! - Functional: list, write, read roundtrip, mkdir, rename, delete
//! - Security: dotdot traversal, absolute path injection, symlink escape
//! (POSIX symlinks only — `#[cfg(unix)]`)
use corrosion_host_agent::filemanager;
use std::path::Path;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Create a temporary directory and return its path. The directory is
/// automatically cleaned up when the `TempDir` is dropped.
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
// ---------------------------------------------------------------------------
// Functional tests
// ---------------------------------------------------------------------------
#[test]
fn write_read_roundtrip() {
let dir = tempdir();
let root = dir.path();
let content = "hello from the file manager\nline 2\n";
filemanager::write(root, "test.txt", content).expect("write should succeed");
let got = filemanager::read(root, "test.txt").expect("read should succeed");
assert_eq!(got, content);
}
#[test]
fn list_returns_written_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "server.cfg", "hostname MyServer\n").expect("write");
let entries = filemanager::list(root, "").expect("list root");
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"server.cfg"), "expected 'server.cfg' in listing, got {names:?}");
}
#[test]
fn list_empty_root_is_empty() {
let dir = tempdir();
let entries = filemanager::list(dir.path(), "").expect("list empty root");
assert!(entries.is_empty(), "fresh tempdir should have no entries");
}
#[test]
fn mkdir_creates_directory() {
let dir = tempdir();
let root = dir.path();
filemanager::mkdir(root, "cfg/custom").expect("mkdir should succeed");
assert!(root.join("cfg/custom").is_dir(), "directory should exist after mkdir");
}
#[test]
fn mkdir_creates_nested_dirs() {
let dir = tempdir();
let root = dir.path();
filemanager::mkdir(root, "a/b/c/d").expect("mkdir nested");
assert!(root.join("a/b/c/d").is_dir());
}
#[test]
fn write_creates_parent_dirs() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "subdir/deep/file.txt", "data").expect("write with auto-mkdir");
let content = filemanager::read(root, "subdir/deep/file.txt").expect("read");
assert_eq!(content, "data");
}
#[test]
fn rename_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "old.txt", "content").expect("write");
filemanager::rename(root, "old.txt", "new.txt").expect("rename");
assert!(!root.join("old.txt").exists(), "old.txt should be gone");
assert!(root.join("new.txt").exists(), "new.txt should exist");
let content = filemanager::read(root, "new.txt").expect("read renamed");
assert_eq!(content, "content");
}
#[test]
fn rename_rejects_separator_in_new_name() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "file.txt", "data").expect("write");
let err = filemanager::rename(root, "file.txt", "subdir/escape.txt")
.expect_err("rename with path separator must fail");
assert!(
err.to_string().contains("separator"),
"error should mention separator: {err}"
);
}
#[test]
fn delete_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "todelete.txt", "bye").expect("write");
assert!(root.join("todelete.txt").exists());
filemanager::delete(root, "todelete.txt").expect("delete");
assert!(!root.join("todelete.txt").exists());
}
#[test]
fn delete_directory_recursive() {
let dir = tempdir();
let root = dir.path();
filemanager::mkdir(root, "tree/sub").expect("mkdir");
filemanager::write(root, "tree/sub/file.txt", "x").expect("write");
assert!(root.join("tree").is_dir());
filemanager::delete(root, "tree").expect("delete tree");
assert!(!root.join("tree").exists(), "directory tree should be deleted");
}
#[test]
fn mkfile_creates_empty_file() {
let dir = tempdir();
let root = dir.path();
filemanager::mkfile(root, "empty.txt").expect("mkfile");
let content = filemanager::read(root, "empty.txt").expect("read empty file");
assert_eq!(content, "");
}
#[test]
fn copy_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "source.txt", "original").expect("write source");
filemanager::copy(root, "source.txt", "dest.txt").expect("copy");
let src = filemanager::read(root, "source.txt").expect("read source after copy");
let dst = filemanager::read(root, "dest.txt").expect("read destination");
assert_eq!(src, "original");
assert_eq!(dst, "original");
}
#[test]
fn move_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "moveme.txt", "payload").expect("write");
filemanager::move_path(root, "moveme.txt", "moved.txt").expect("move");
assert!(!root.join("moveme.txt").exists(), "source should be gone");
let content = filemanager::read(root, "moved.txt").expect("read after move");
assert_eq!(content, "payload");
}
#[test]
fn list_entry_fields_are_populated() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "check.txt", "abcde").expect("write");
filemanager::mkdir(root, "subdir").expect("mkdir");
let entries = filemanager::list(root, "").expect("list");
// Dirs sort before files.
let dir_entry = entries.iter().find(|e| e.name == "subdir").expect("subdir entry");
assert!(dir_entry.is_dir);
assert_eq!(dir_entry.size, 0);
assert!(!dir_entry.modified.is_empty(), "modified should be set");
let file_entry = entries.iter().find(|e| e.name == "check.txt").expect("file entry");
assert!(!file_entry.is_dir);
assert_eq!(file_entry.size, 5, "size should match byte count");
// path should be relative and use forward slashes.
assert!(!file_entry.path.starts_with('/'), "path should be relative");
assert!(!file_entry.path.contains('\\'), "path should use forward slashes");
}
// ---------------------------------------------------------------------------
// Security: jail-escape tests
// CRITICAL — these are the whole point of the jail abstraction.
// ---------------------------------------------------------------------------
/// `../../etc/passwd` must never resolve outside the instance root.
#[test]
fn jail_rejects_dotdot_traversal() {
let dir = tempdir();
let root = dir.path();
let err = filemanager::read(root, "../../etc/passwd")
.expect_err("dotdot traversal must be rejected");
// Verify the error is security-related and not just "file not found".
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention jail escape for dotdot traversal, got: {msg}"
);
}
/// A deeply nested `../` chain must also be stopped.
#[test]
fn jail_rejects_deep_dotdot_traversal() {
let dir = tempdir();
let root = dir.path();
let err = filemanager::read(root, "a/b/c/../../../../../../../../etc/shadow")
.expect_err("deep dotdot traversal must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape") || msg.contains("absolute"),
"error should mention jail escape for deep traversal, got: {msg}"
);
}
/// An absolute path (e.g. `/etc/passwd`) must be rejected immediately — it
/// completely bypasses relative joining and should never be accepted.
#[test]
fn jail_rejects_absolute_path() {
let dir = tempdir();
let root = dir.path();
let err = filemanager::read(root, "/etc/passwd")
.expect_err("absolute path must be rejected");
let msg = err.to_string();
assert!(
msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention the absolute-path rejection, got: {msg}"
);
}
/// An absolute path to a Windows-style location must also be rejected.
#[test]
fn jail_rejects_absolute_windows_style_path() {
let dir = tempdir();
let root = dir.path();
// On POSIX this is just treated as an absolute path starting with `/`.
// The test is intentionally platform-portable: any absolute path is bad.
let err = filemanager::read(root, "/tmp/evil")
.expect_err("absolute /tmp/evil must be rejected");
let msg = err.to_string();
assert!(
msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"got: {msg}"
);
}
/// A symlink inside the root that points to a path outside the root must not
/// be followed. This is the critical symlink-escape vector.
#[cfg(unix)]
#[test]
fn jail_rejects_symlink_escape() {
let dir = tempdir();
let root = dir.path();
// Create a directory outside the root to be the symlink target.
let outside = tempdir();
let outside_file = outside.path().join("secret.txt");
std::fs::write(&outside_file, "secret data").expect("write outside file");
// Plant a symlink inside the root pointing to the outside directory.
let link_path = root.join("evil_link");
std::os::unix::fs::symlink(outside.path(), &link_path)
.expect("create symlink inside root");
// Attempt to read through the symlink.
let err = filemanager::read(root, "evil_link/secret.txt")
.expect_err("symlink escape must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention jail escape for symlink traversal, got: {msg}"
);
}
/// A symlink directly inside the root pointing to a file outside must be
/// rejected even when the path looks like a normal relative reference.
#[cfg(unix)]
#[test]
fn jail_rejects_symlink_pointing_directly_outside() {
let dir = tempdir();
let root = dir.path();
// Symlink to /etc/passwd itself (or any outside path that exists or not).
let link_path = root.join("passwd_link");
std::os::unix::fs::symlink(Path::new("/etc/passwd"), &link_path)
.expect("create symlink to /etc/passwd");
let err = filemanager::read(root, "passwd_link")
.expect_err("direct symlink outside root must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention jail escape, got: {msg}"
);
}
/// A symlink chain (symlink → symlink → outside) must also be caught.
#[cfg(unix)]
#[test]
fn jail_rejects_chained_symlink_escape() {
let dir = tempdir();
let root = dir.path();
let outside = tempdir();
// Chain: root/link1 → root/link2 → outside/
let link2_path = root.join("link2");
std::os::unix::fs::symlink(outside.path(), &link2_path)
.expect("create link2");
let link1_path = root.join("link1");
std::os::unix::fs::symlink(&link2_path, &link1_path)
.expect("create link1");
let err = filemanager::read(root, "link1")
.expect_err("chained symlink escape must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"chained symlink should be caught, got: {msg}"
);
}
/// SECURITY REGRESSION: copying a directory that contains a symlink pointing
/// OUTSIDE the jail must NOT dereference it and pull external content inside.
/// jail() validates only the top-level src/dest; the recursive copy must
/// refuse symlinks itself or it becomes a read-escape exfiltration path.
#[cfg(unix)]
#[test]
fn copy_refuses_to_follow_symlink_out_of_jail() {
let dir = tempdir();
let root = dir.path();
let outside = tempdir();
std::fs::write(outside.path().join("secret.txt"), "TOP SECRET")
.expect("write external secret");
// A directory inside the jail containing a symlink to the outside dir.
std::fs::create_dir(root.join("src")).expect("mkdir src");
std::os::unix::fs::symlink(outside.path(), root.join("src").join("escape"))
.expect("plant symlink to outside");
// Attempt to copy src -> dest (both inside the jail).
let err = filemanager::copy(root, "src", "dest")
.expect_err("copy must refuse the embedded symlink");
assert!(
format!("{err:#}").contains("symlink"),
"error should name the refused symlink, got: {err:#}"
);
// The external secret must NOT have landed inside the jail.
assert!(
!root.join("dest").join("escape").join("secret.txt").exists(),
"external content leaked into the jail via symlink-following copy",
);
}
/// `list` must report a symlink as the link itself, never the dereferenced
/// target — otherwise it leaks the size/type of files outside the jail.
#[cfg(unix)]
#[test]
fn list_does_not_dereference_symlink_metadata() {
let dir = tempdir();
let root = dir.path();
std::os::unix::fs::symlink(Path::new("/etc/passwd"), root.join("leak"))
.expect("plant symlink");
let entries = filemanager::list(root, "").expect("list root");
let leak = entries.iter().find(|e| e.name == "leak").expect("symlink listed");
// /etc/passwd is a regular file; if we followed the link, is_dir would
// reflect the target. We must report the link, which is not a directory,
// and must NOT expose the target's byte size.
assert!(!leak.is_dir, "symlink must not be reported as a directory");
let target_size = std::fs::metadata("/etc/passwd").map(|m| m.len()).unwrap_or(0);
assert!(
leak.size != target_size || target_size == 0,
"list leaked the symlink target's size ({target_size} bytes)"
);
}
// ---------------------------------------------------------------------------
// Dispatch layer tests
// ---------------------------------------------------------------------------
#[test]
fn dispatch_list_returns_success() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "a.txt", "a").expect("write");
let req = filemanager::FileRequest {
op: "list".to_string(),
path: String::new(),
dest: None,
content: None,
name: None,
};
let resp = filemanager::dispatch(root, &req);
assert_eq!(resp["status"], "success");
assert!(resp["data"]["entries"].is_array());
}
#[test]
fn dispatch_unknown_op_returns_error() {
let dir = tempdir();
let req = filemanager::FileRequest {
op: "explode".to_string(),
path: String::new(),
dest: None,
content: None,
name: None,
};
let resp = filemanager::dispatch(dir.path(), &req);
assert_eq!(resp["status"], "error");
assert!(resp["message"].as_str().unwrap().contains("unknown op"));
}
#[test]
fn dispatch_escape_attempt_returns_error_not_panic() {
let dir = tempdir();
let req = filemanager::FileRequest {
op: "read".to_string(),
path: "../../etc/passwd".to_string(),
dest: None,
content: None,
name: None,
};
let resp = filemanager::dispatch(dir.path(), &req);
// Must return an error response, not panic or expose the file.
assert_eq!(resp["status"], "error", "escape attempt should return error status");
assert!(
resp["message"].as_str().is_some(),
"error response must have a message"
);
}

View File

@@ -0,0 +1,45 @@
//! Unit tests for the SteamCMD module.
//!
//! Tests cover app ID resolution for all four supported games, including the
//! platform-specific Soulmask split, and verify that Dune correctly returns
//! `None` (it uses Docker images, not SteamCMD).
use corrosion_host_agent::steamcmd::app_id_for_game;
#[test]
fn rust_has_correct_app_id() {
assert_eq!(app_id_for_game("rust"), Some(258550));
}
#[test]
fn conan_has_correct_app_id() {
assert_eq!(app_id_for_game("conan"), Some(443030));
}
/// Soulmask returns the Windows server app ID on Windows builds, the Linux
/// dedicated server app ID on all other targets.
#[test]
#[cfg(windows)]
fn soulmask_windows_app_id() {
assert_eq!(app_id_for_game("soulmask"), Some(3017310));
}
#[test]
#[cfg(not(windows))]
fn soulmask_linux_app_id() {
assert_eq!(app_id_for_game("soulmask"), Some(3017300));
}
/// Dune uses Docker images — SteamCMD integration is explicitly unsupported.
#[test]
fn dune_has_no_app_id() {
assert_eq!(app_id_for_game("dune"), None);
}
/// Unknown games also produce None; callers should treat this the same as
/// Dune (no SteamCMD support).
#[test]
fn unknown_game_returns_none() {
assert_eq!(app_id_for_game("minecraft"), None);
assert_eq!(app_id_for_game(""), None);
}

View File

@@ -20,6 +20,7 @@ fn managed_instance(executable: &str, args: &[&str]) -> InstanceConfig {
args: args.iter().map(|s| s.to_string()).collect(),
working_dir: None,
rcon: None,
steamcmd: None,
}
}

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"

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

@@ -0,0 +1,12 @@
# SAFE OPEN DEFAULT — anonymous full access, no secrets. Same behavior as the
# pre-auth broker so fresh deploys and the repo stay valid.
#
# Regenerated on deploy by scripts/generate-nats-auth.mjs with the privileged
# internal user + per-license scoped users (those carry secrets and must NOT be
# committed — mark the host copy with `git update-index --assume-unchanged`).
authorization {
users: [
{ user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }
]
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

@@ -124,6 +124,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 +148,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] },
{
@@ -211,7 +212,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
{
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',
@@ -256,7 +257,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
{
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',
@@ -299,6 +300,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
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,87 @@
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
}
}
return {
hosts,
summary,
loading,
error,
fetchFleet,
}
})

View File

@@ -0,0 +1,467 @@
<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 } from 'vue'
import { useRouter } from 'vue-router'
import { useFleetStore } from '@/stores/fleet'
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()
onMounted(() => {
fleet.fetchFleet()
})
// ---------------------------------------------------------------------------
// Derived state
// ---------------------------------------------------------------------------
const hasHosts = computed(() => fleet.hosts.length > 0)
/** 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>
</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

@@ -0,0 +1,85 @@
#!/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):
// "open" (default) — defines a full-access `anonymous` user and sets
// no_auth_user, so unauthenticated clients still work.
// Non-breaking; lets you verify real creds first.
// "enforce" — omits no_auth_user; anonymous connections are rejected.
//
// 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 = 'open',
} = 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.
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}", "_INBOX.>"] }, ` +
`subscribe: { allow: ["${scope}", "_INBOX.>"] } } }`,
);
}
if (NATS_AUTH_STAGE === 'open') {
// Transition: unauthenticated clients map to a full-access user so nothing
// breaks while real credentials roll out. Remove for enforcement.
lines.push(' { user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }');
}
lines.push(' ]');
if (NATS_AUTH_STAGE === 'open') {
lines.push(' no_auth_user: "anonymous"');
}
lines.push('}');
process.stdout.write(lines.join('\n') + '\n');
};
main().catch((e) => { console.error(e); process.exit(1); });