feat(api): instance command bridge + agent credentials endpoint
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 21s

Backend layer wiring the panel to the host agent's per-instance command
channel (the unblocker for the Server-page rework):
- NatsService.requestScoped(): request-reply with a LICENSE-SCOPED reply
  subject (corrosion.{license}.reply.<id>) so per-license-scoped agents
  (no _INBOX permission) can actually reply — the design from the NATS
  auth work, now exercised.
- InstancesModule: POST /api/instances/:id/lifecycle {action} (start/
  stop/restart/status/steam_update, server.manage) and POST :id/rcon
  {command} (server.console). Tenant-guarded via game_instances.
- GET /api/servers/agent-credentials: derives the agent's NATS user/
  password (HMAC) so a customer can configure their agent — closes the
  post-auth setup gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 13:05:22 -04:00
parent 99433a09d1
commit 6f31c41dc3
8 changed files with 182 additions and 0 deletions

View File

@@ -46,6 +46,7 @@ import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module'; import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
import { EarlyAccessModule } from './modules/early-access/early-access.module'; import { EarlyAccessModule } from './modules/early-access/early-access.module';
import { FleetModule } from './modules/fleet/fleet.module'; import { FleetModule } from './modules/fleet/fleet.module';
import { InstancesModule } from './modules/instances/instances.module';
// Shared Services // Shared Services
import { NatsService } from './services/nats.service'; import { NatsService } from './services/nats.service';
@@ -135,6 +136,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
RaidableBasesModule, RaidableBasesModule,
EarlyAccessModule, EarlyAccessModule,
FleetModule, FleetModule,
InstancesModule,
], ],
providers: [ providers: [
// Global guards (order matters: auth first, then license, then permissions) // Global guards (order matters: auth first, then license, then permissions)

View File

@@ -6,6 +6,8 @@ export default () => ({
}, },
nats: { nats: {
url: process.env.NATS_URL || 'nats://localhost:4222', url: process.env.NATS_URL || 'nats://localhost:4222',
// Public broker address shown to agents in setup instructions.
publicUrl: process.env.NATS_PUBLIC_URL || 'nats://nats.corrosionmgmt.com:4222',
// Privileged internal credentials for the backend's own NATS connection // Privileged internal credentials for the backend's own NATS connection
// (full corrosion.> access). Empty = anonymous (transition period). // (full corrosion.> access). Empty = anonymous (transition period).
internalUser: process.env.NATS_INTERNAL_USER || '', internalUser: process.env.NATS_INTERNAL_USER || '',

View File

@@ -0,0 +1,34 @@
import { Controller, Post, Body, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { InstancesService, LifecycleFunc } from './instances.service';
@ApiTags('instances')
@ApiBearerAuth()
@Controller('instances')
export class InstancesController {
constructor(private readonly instances: InstancesService) {}
@Post(':id/lifecycle')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Send a lifecycle command to a game instance (start/stop/restart/status/steam_update)' })
async lifecycle(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { action: LifecycleFunc },
) {
return this.instances.lifecycle(licenseId, id, body.action);
}
@Post(':id/rcon')
@RequirePermission('server.console')
@ApiOperation({ summary: 'Send an RCON/console command to a game instance' })
async rcon(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { command: string },
) {
return this.instances.rcon(licenseId, id, body.command);
}
}

View File

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

View File

@@ -0,0 +1,49 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NatsService } from '../../services/nats.service';
import { GameInstance } from '../../entities/game-instance.entity';
/** Lifecycle funcs the agent's {instance}.cmd handler accepts. */
const LIFECYCLE_FUNCS = ['start', 'stop', 'restart', 'status', 'steam_update'] as const;
export type LifecycleFunc = (typeof LIFECYCLE_FUNCS)[number];
@Injectable()
export class InstancesService {
private readonly logger = new Logger(InstancesService.name);
constructor(
private readonly nats: NatsService,
@InjectRepository(GameInstance)
private readonly instanceRepo: Repository<GameInstance>,
) {}
/** Resolve an instance the caller's license actually owns (tenant guard). */
private async resolveInstance(licenseId: string, instanceId: string): Promise<GameInstance> {
const inst = await this.instanceRepo.findOne({
where: { id: instanceId, license_id: licenseId },
});
if (!inst) throw new NotFoundException('Instance not found');
return inst;
}
async lifecycle(licenseId: string, instanceId: string, func: LifecycleFunc): Promise<unknown> {
if (!LIFECYCLE_FUNCS.includes(func)) {
throw new BadRequestException(`Unsupported action '${func}'`);
}
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
this.logger.log(`instance ${inst.agent_instance_id}: ${func}`);
return this.nats.requestScoped(licenseId, subject, { func });
}
async rcon(licenseId: string, instanceId: string, command: string): Promise<unknown> {
if (!command || !command.trim()) {
throw new BadRequestException('command is required');
}
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
// RCON can take longer than a lifecycle ack — give it more headroom.
return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000);
}
}

View File

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

View File

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

View File

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