diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index 8b1df41..cc154cf 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -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) diff --git a/backend-nest/src/config/configuration.ts b/backend-nest/src/config/configuration.ts index a642d63..89eb5e4 100644 --- a/backend-nest/src/config/configuration.ts +++ b/backend-nest/src/config/configuration.ts @@ -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 || '', diff --git a/backend-nest/src/modules/instances/instances.controller.ts b/backend-nest/src/modules/instances/instances.controller.ts new file mode 100644 index 0000000..bb63b6a --- /dev/null +++ b/backend-nest/src/modules/instances/instances.controller.ts @@ -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); + } +} diff --git a/backend-nest/src/modules/instances/instances.module.ts b/backend-nest/src/modules/instances/instances.module.ts new file mode 100644 index 0000000..4107976 --- /dev/null +++ b/backend-nest/src/modules/instances/instances.module.ts @@ -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 {} diff --git a/backend-nest/src/modules/instances/instances.service.ts b/backend-nest/src/modules/instances/instances.service.ts new file mode 100644 index 0000000..4ffb77b --- /dev/null +++ b/backend-nest/src/modules/instances/instances.service.ts @@ -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, + ) {} + + /** Resolve an instance the caller's license actually owns (tenant guard). */ + private async resolveInstance(licenseId: string, instanceId: string): Promise { + 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 { + 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 { + 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); + } +} diff --git a/backend-nest/src/modules/servers/servers.controller.ts b/backend-nest/src/modules/servers/servers.controller.ts index 457aedd..58aaeef 100644 --- a/backend-nest/src/modules/servers/servers.controller.ts +++ b/backend-nest/src/modules/servers/servers.controller.ts @@ -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' }) diff --git a/backend-nest/src/modules/servers/servers.service.ts b/backend-nest/src/modules/servers/servers.service.ts index da20754..0715148 100644 --- a/backend-nest/src/modules/servers/servers.service.ts +++ b/backend-nest/src/modules/servers/servers.service.ts @@ -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. diff --git a/backend-nest/src/services/nats.service.ts b/backend-nest/src/services/nats.service.ts index 0fa1e70..f736c45 100644 --- a/backend-nest/src/services/nats.service.ts +++ b/backend-nest/src/services/nats.service.ts @@ -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.); the privileged backend subscribes there. + * See corrosion-host-agent/PROTOCOL.md ("Reply-subject rule"). + */ + async requestScoped( + licenseId: string, + subject: string, + payload: Record, + timeoutMs = 8000, + ): Promise { + 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((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('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('nats.publicUrl') || 'nats://nats.corrosionmgmt.com:4222', + }; + } + /** Publish a command to a specific license's server */ async sendServerCommand(licenseId: string, action: string, payload: Record = {}): Promise { await this.publish(`corrosion.${licenseId}.cmd.server`, {