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>
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
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 {
|
|
private readonly logger = new Logger(NatsService.name);
|
|
private nc: NatsConnection | null = null;
|
|
private sc = StringCodec();
|
|
|
|
constructor(private config: ConfigService) {}
|
|
|
|
async onModuleInit() {
|
|
try {
|
|
const url = this.config.get<string>('nats.url') || 'nats://localhost:4222';
|
|
const user = this.config.get<string>('nats.internalUser');
|
|
const pass = this.config.get<string>('nats.internalPassword');
|
|
// Authenticate with the privileged internal user when configured;
|
|
// otherwise connect anonymously (broker hasn't enforced auth yet).
|
|
const opts = user && pass ? { servers: url, user, pass } : { servers: url };
|
|
this.nc = await connect(opts);
|
|
this.logger.log(`Connected to NATS at ${url}${user ? ` as ${user}` : ' (anonymous)'}`);
|
|
} catch (err) {
|
|
this.logger.warn(`NATS connection failed — running in offline mode: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async onModuleDestroy() {
|
|
if (this.nc) {
|
|
await this.nc.drain();
|
|
}
|
|
}
|
|
|
|
async publish(subject: string, data: Record<string, unknown>): Promise<void> {
|
|
if (!this.nc) {
|
|
this.logger.debug(`[OFFLINE] Would publish to ${subject}: ${JSON.stringify(data)}`);
|
|
return;
|
|
}
|
|
this.nc.publish(subject, this.sc.encode(JSON.stringify(data)));
|
|
}
|
|
|
|
async request(subject: string, data: Record<string, unknown>, timeout = 5000): Promise<unknown> {
|
|
if (!this.nc) {
|
|
this.logger.debug(`[OFFLINE] Would request ${subject}: ${JSON.stringify(data)}`);
|
|
return null;
|
|
}
|
|
const msg = await this.nc.request(subject, this.sc.encode(JSON.stringify(data)), { timeout });
|
|
return JSON.parse(this.sc.decode(msg.data));
|
|
}
|
|
|
|
subscribe(subject: string, callback: (data: unknown, subject: string) => void): Subscription | null {
|
|
if (!this.nc) {
|
|
this.logger.debug(`[OFFLINE] Would subscribe to ${subject}`);
|
|
return null;
|
|
}
|
|
const sub = this.nc.subscribe(subject);
|
|
(async () => {
|
|
for await (const msg of sub) {
|
|
try {
|
|
const parsed = JSON.parse(this.sc.decode(msg.data));
|
|
callback(parsed, msg.subject);
|
|
} catch {
|
|
callback(this.sc.decode(msg.data), msg.subject);
|
|
}
|
|
}
|
|
})();
|
|
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`, {
|
|
action,
|
|
...payload,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
/** Publish a deploy command to a specific license's companion agent */
|
|
async sendDeployCommand(licenseId: string, config: Record<string, unknown>): Promise<void> {
|
|
await this.publish(`corrosion.${licenseId}.cmd.deploy`, {
|
|
action: 'deploy',
|
|
config,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
/** Publish an Oxide install command to a specific license's companion agent */
|
|
async sendOxideInstallCommand(licenseId: string): Promise<void> {
|
|
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
|
|
action: 'install_oxide',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|