feat(api): instance command bridge + agent credentials endpoint
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:
@@ -46,6 +46,7 @@ import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||
import { FleetModule } from './modules/fleet/fleet.module';
|
||||
import { InstancesModule } from './modules/instances/instances.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -135,6 +136,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
RaidableBasesModule,
|
||||
EarlyAccessModule,
|
||||
FleetModule,
|
||||
InstancesModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
@@ -6,6 +6,8 @@ export default () => ({
|
||||
},
|
||||
nats: {
|
||||
url: process.env.NATS_URL || 'nats://localhost:4222',
|
||||
// Public broker address shown to agents in setup instructions.
|
||||
publicUrl: process.env.NATS_PUBLIC_URL || 'nats://nats.corrosionmgmt.com:4222',
|
||||
// Privileged internal credentials for the backend's own NATS connection
|
||||
// (full corrosion.> access). Empty = anonymous (transition period).
|
||||
internalUser: process.env.NATS_INTERNAL_USER || '',
|
||||
|
||||
34
backend-nest/src/modules/instances/instances.controller.ts
Normal file
34
backend-nest/src/modules/instances/instances.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
backend-nest/src/modules/instances/instances.module.ts
Normal file
13
backend-nest/src/modules/instances/instances.module.ts
Normal 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 {}
|
||||
49
backend-nest/src/modules/instances/instances.service.ts
Normal file
49
backend-nest/src/modules/instances/instances.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ export class ServersController {
|
||||
return await this.serversService.getServer(licenseId);
|
||||
}
|
||||
|
||||
@Get('agent-credentials')
|
||||
@RequirePermission('server.manage')
|
||||
@ApiOperation({ summary: 'NATS credentials for this license\'s host agent' })
|
||||
async getAgentCredentials(@CurrentTenant() licenseId: string) {
|
||||
return await this.serversService.getAgentCredentials(licenseId);
|
||||
}
|
||||
|
||||
@Put('config')
|
||||
@RequirePermission('server.manage')
|
||||
@ApiOperation({ summary: 'Update server configuration' })
|
||||
|
||||
@@ -19,6 +19,15 @@ export class ServersService {
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* NATS credentials the customer puts in their host agent's config so it can
|
||||
* authenticate to the per-license-scoped broker. Returns null if the broker
|
||||
* isn't enforcing auth yet (NATS_TOKEN_SECRET unset).
|
||||
*/
|
||||
async getAgentCredentials(licenseId: string) {
|
||||
return this.natsService.getAgentCredentials(licenseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server connection and config for a license.
|
||||
* Returns null fields if no server has been set up yet.
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { connect, NatsConnection, StringCodec, Subscription } from 'nats';
|
||||
import { createHmac, randomUUID } from 'crypto';
|
||||
|
||||
export interface AgentCredentials {
|
||||
license_id: string;
|
||||
nats_user: string;
|
||||
nats_password: string;
|
||||
nats_url: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NatsService implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -67,6 +75,64 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-reply to a host-agent subject with a LICENSE-SCOPED reply subject.
|
||||
*
|
||||
* Per-license agent users are confined to corrosion.{license}.> and have no
|
||||
* _INBOX permission, so the agent cannot publish a reply to the default
|
||||
* global inbox. The reply must live inside the license namespace
|
||||
* (corrosion.{license}.reply.<id>); the privileged backend subscribes there.
|
||||
* See corrosion-host-agent/PROTOCOL.md ("Reply-subject rule").
|
||||
*/
|
||||
async requestScoped<T = unknown>(
|
||||
licenseId: string,
|
||||
subject: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeoutMs = 8000,
|
||||
): Promise<T> {
|
||||
if (!this.nc) {
|
||||
throw new Error('NATS unavailable — agent is not reachable');
|
||||
}
|
||||
const replySubject = `corrosion.${licenseId}.reply.${randomUUID()}`;
|
||||
const nc = this.nc;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
nc.subscribe(replySubject, {
|
||||
max: 1,
|
||||
timeout: timeoutMs,
|
||||
callback: (err, msg) => {
|
||||
if (err) {
|
||||
reject(new Error(`agent did not respond within ${timeoutMs}ms`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(this.sc.decode(msg.data)) as T);
|
||||
} catch {
|
||||
resolve(this.sc.decode(msg.data) as unknown as T);
|
||||
}
|
||||
},
|
||||
});
|
||||
nc.publish(subject, this.sc.encode(JSON.stringify(payload)), { reply: replySubject });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a license's agent NATS credentials. Password is
|
||||
* HMAC-SHA256(license_id, NATS_TOKEN_SECRET) — must match the broker config
|
||||
* generated by scripts/generate-nats-auth.mjs. Returns null if the secret
|
||||
* isn't configured (broker not yet enforcing auth).
|
||||
*/
|
||||
getAgentCredentials(licenseId: string): AgentCredentials | null {
|
||||
const secret = this.config.get<string>('nats.tokenSecret');
|
||||
if (!secret) return null;
|
||||
const password = createHmac('sha256', secret).update(licenseId).digest('hex');
|
||||
return {
|
||||
license_id: licenseId,
|
||||
nats_user: licenseId,
|
||||
nats_password: password,
|
||||
nats_url: this.config.get<string>('nats.publicUrl') || 'nats://nats.corrosionmgmt.com:4222',
|
||||
};
|
||||
}
|
||||
|
||||
/** Publish a command to a specific license's server */
|
||||
async sendServerCommand(licenseId: string, action: string, payload: Record<string, unknown> = {}): Promise<void> {
|
||||
await this.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
|
||||
Reference in New Issue
Block a user