feat(api): per-instance file bridge — list/read/write via the new agent file manager
GET /api/instances/:id/files (list) + /file (read), PUT /file (write) —
tenant-guarded, routed through requestScoped to the per-instance
corrosion.{license}.{instance}.files.cmd using the new agent's {op,path}
protocol (jailed to the instance root, symlink-safe). files.view /
files.manage perms. Foundation for the per-game config editor and for
fixing the legacy VueFinder File Manager (which still speaks the retired
Go fm_* protocol on the wrong subject and is broken under per-license
auth — separate reconciliation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Post, Body, Param } from '@nestjs/common';
|
import { Controller, Post, Get, Put, Body, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
@@ -31,4 +31,37 @@ export class InstancesController {
|
|||||||
) {
|
) {
|
||||||
return this.instances.rcon(licenseId, id, body.command);
|
return this.instances.rcon(licenseId, id, body.command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/files')
|
||||||
|
@RequirePermission('files.view')
|
||||||
|
@ApiOperation({ summary: 'List a directory in the instance (jailed to its root)' })
|
||||||
|
async listFiles(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('path') path?: string,
|
||||||
|
) {
|
||||||
|
return this.instances.listFiles(licenseId, id, path ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/file')
|
||||||
|
@RequirePermission('files.view')
|
||||||
|
@ApiOperation({ summary: 'Read a text file from the instance (jailed, 5 MiB cap)' })
|
||||||
|
async readFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('path') path: string,
|
||||||
|
) {
|
||||||
|
return this.instances.readFile(licenseId, id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/file')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Write a text file in the instance (jailed)' })
|
||||||
|
async writeFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; content: string },
|
||||||
|
) {
|
||||||
|
return this.instances.writeFile(licenseId, id, body.path, body.content ?? '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,55 @@ export class InstancesService {
|
|||||||
// RCON can take longer than a lifecycle ack — give it more headroom.
|
// RCON can take longer than a lifecycle ack — give it more headroom.
|
||||||
return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000);
|
return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// File access — jailed to the instance root by the agent's file manager.
|
||||||
|
// The agent protocol (corrosion-host-agent/src/filemanager.rs):
|
||||||
|
// { op: list|read|write|delete|rename|mkdir|mkfile|move|copy, path, ... }
|
||||||
|
// reply: { status: 'success'|'error', data?, message? }
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private filesSubject(inst: GameInstance, licenseId: string): string {
|
||||||
|
return `corrosion.${licenseId}.${inst.agent_instance_id}.files.cmd`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fileOp(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ status: string; data?: unknown; message?: string }> {
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const res = await this.nats.requestScoped<{ status: string; data?: unknown; message?: string }>(
|
||||||
|
licenseId,
|
||||||
|
this.filesSubject(inst, licenseId),
|
||||||
|
payload,
|
||||||
|
12_000,
|
||||||
|
);
|
||||||
|
if (res?.status === 'error') {
|
||||||
|
throw new BadRequestException(res.message ?? 'File operation failed');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(licenseId: string, instanceId: string, path = ''): Promise<unknown> {
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'list', path });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'read', path });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'write', path, content });
|
||||||
|
return res.data ?? { status: 'success' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user