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 { 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)
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
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);
|
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' })
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user