Compare commits
5 Commits
agent-v2.0
...
agent-v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cff51ce5 | ||
|
|
7a07d600e7 | ||
|
|
4a4ae7a5d4 | ||
|
|
930f655bf5 | ||
|
|
700dc2254d |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
74
backend-nest/src/entities/agent-host.entity.ts
Normal file
74
backend-nest/src/entities/agent-host.entity.ts
Normal 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;
|
||||
}
|
||||
59
backend-nest/src/entities/game-instance.entity.ts
Normal file
59
backend-nest/src/entities/game-instance.entity.ts
Normal 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;
|
||||
}
|
||||
38
backend-nest/src/entities/instance-cluster.entity.ts
Normal file
38
backend-nest/src/entities/instance-cluster.entity.ts
Normal 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;
|
||||
}
|
||||
38
backend-nest/src/entities/instance-stats.entity.ts
Normal file
38
backend-nest/src/entities/instance-stats.entity.ts
Normal 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;
|
||||
}
|
||||
19
backend-nest/src/modules/fleet/fleet.controller.ts
Normal file
19
backend-nest/src/modules/fleet/fleet.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/fleet/fleet.module.ts
Normal file
14
backend-nest/src/modules/fleet/fleet.module.ts
Normal 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 {}
|
||||
134
backend-nest/src/modules/fleet/fleet.service.ts
Normal file
134
backend-nest/src/modules/fleet/fleet.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
102
backend/migrations/022_fleet_model.sql
Normal file
102
backend/migrations/022_fleet_model.sql
Normal 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);
|
||||
2
corrosion-host-agent/Cargo.lock
generated
2
corrosion-host-agent/Cargo.lock
generated
@@ -264,7 +264,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "corrosion-host-agent"
|
||||
version = "2.0.0-alpha.3"
|
||||
version = "2.0.0-alpha.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "corrosion-host-agent"
|
||||
version = "2.0.0-alpha.3"
|
||||
version = "2.0.0-alpha.5"
|
||||
edition = "2021"
|
||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||
license = "UNLICENSED"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ pub struct AgentSection {
|
||||
pub license_id: Option<String>,
|
||||
pub nats_url: Option<String>,
|
||||
pub nats_token: Option<String>,
|
||||
/// NATS username for per-license auth. Defaults to license_id when a
|
||||
/// password is set but no user is given.
|
||||
pub nats_user: Option<String>,
|
||||
/// NATS password (the per-license token). When set, the agent authenticates
|
||||
/// with user+password instead of a bare token.
|
||||
pub nats_password: Option<String>,
|
||||
#[serde(default = "default_heartbeat_seconds")]
|
||||
pub heartbeat_seconds: u64,
|
||||
#[serde(default = "default_log_level")]
|
||||
@@ -122,6 +128,8 @@ pub struct Settings {
|
||||
pub license_id: String,
|
||||
pub nats_url: String,
|
||||
pub nats_token: Option<String>,
|
||||
pub nats_user: Option<String>,
|
||||
pub nats_password: Option<String>,
|
||||
pub heartbeat_seconds: u64,
|
||||
pub log_level: String,
|
||||
pub instances: Vec<InstanceConfig>,
|
||||
@@ -167,6 +175,16 @@ fn resolve(file: ConfigFile) -> Result<Settings> {
|
||||
.filter(|v| !v.is_empty())
|
||||
.or(file.agent.nats_token);
|
||||
|
||||
let nats_user = std::env::var("CORROSION_NATS_USER")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.or(file.agent.nats_user);
|
||||
|
||||
let nats_password = std::env::var("CORROSION_NATS_PASSWORD")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.or(file.agent.nats_password);
|
||||
|
||||
validate_subject_segment("license_id", &license_id)?;
|
||||
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
@@ -196,6 +214,8 @@ fn resolve(file: ConfigFile) -> Result<Settings> {
|
||||
license_id,
|
||||
nats_url,
|
||||
nats_token,
|
||||
nats_user,
|
||||
nats_password,
|
||||
heartbeat_seconds: file.agent.heartbeat_seconds,
|
||||
log_level: file.agent.log_level,
|
||||
instances: file.instances,
|
||||
|
||||
@@ -198,7 +198,11 @@ pub fn list(root: &Path, rel: &str) -> anyhow::Result<Vec<FileEntry>> {
|
||||
let mut entries: Vec<FileEntry> = Vec::new();
|
||||
for item in rd {
|
||||
let item = item.with_context(|| format!("reading directory entry in '{}'", abs.display()))?;
|
||||
let meta = item.metadata().with_context(|| format!("stat '{}'", item.path().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();
|
||||
@@ -367,11 +371,24 @@ pub fn copy(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
|
||||
.with_context(|| format!("copy '{}' -> '{}'", src_abs.display(), dest_abs.display()))
|
||||
}
|
||||
|
||||
/// Recursive copy helper (mirrors Go's `copyRecursive`).
|
||||
/// 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::metadata(src)
|
||||
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()))?;
|
||||
|
||||
@@ -347,6 +347,62 @@ fn jail_rejects_chained_symlink_escape() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
12
docker/nats-auth.conf
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
87
frontend/src/stores/fleet.ts
Normal file
87
frontend/src/stores/fleet.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
467
frontend/src/views/admin/FleetView.vue
Normal file
467
frontend/src/views/admin/FleetView.vue
Normal 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>
|
||||
85
scripts/generate-nats-auth.mjs
Normal file
85
scripts/generate-nats-auth.mjs
Normal 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); });
|
||||
Reference in New Issue
Block a user