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

@@ -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`, {