feat: wire the panel command surface to the live Rust agent + wipe handler
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 1m35s
Build Host Agent (Rust) / build (push) Successful in 1m48s
CI / integration (push) Successful in 23s

The legacy Go agent was never deployed, so the entire backend command surface
published to a dead cmd.server/cmd.wipe/files.cmd void. Route it all to the
Rust agent's instance-scoped subjects.

Agent (corrosion-host-agent, alpha.10):
- New src/wipe.rs + 'wipe' func on {instance}.cmd: stop -> delete game files by
  type (map/blueprint/full, with optional backup) -> restart. Jailed to the
  instance root, symlink-safe (lstat, no cross-boundary follow — Lesson 26).
  8 tests incl. jail-escape + symlink-skip proofs. Agent suite 64 tests green.

Backend (NestJS):
- InstancesService is now @Global with license-scoped convenience wrappers
  (lifecycleForLicense/rconForLicense/writeFileForLicense/readFileForLicense/
  deleteFileForLicense/wipeForLicense) + resolveDefaultInstance (license ->
  primary instance).
- Routed to the agent: servers start/stop/restart/command; players kick/banid/
  unban via RCON; schedules restart/announce/command/plugin-reload; wipes ->
  wipeForLicense (real wipe now); plugins reload/unload/upload via rcon+file
  ops; all 9 plugin-config module applies -> writeFileForLicense + oxide.reload
  rcon, imports -> readFileForLicense (server:// prefix stripped).
- Honestly gated (need agent funcs not yet built): server deploy-from-panel,
  Oxide install, one-click uMod install -> 503 coming-soon instead of dead
  publishes.

Backend tsc green; agent cargo test green (64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 22:30:18 -04:00
parent 6f783bfac8
commit 440474290b
22 changed files with 1105 additions and 405 deletions

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
@@ -13,7 +13,7 @@ export class AutoDoorsService {
constructor(
@InjectRepository(AutoDoorsConfig)
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class AutoDoorsService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write AutoDoors.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/AutoDoors.json',
content: jsonString,
},
30000,
// Write AutoDoors.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/AutoDoors.json',
jsonString,
);
// Reload AutoDoors plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload AutoDoors',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload AutoDoors');
// Mark this config as active, deactivate others
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class AutoDoorsService {
/** Import AutoDoors.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read AutoDoors.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/AutoDoors.json',
},
30000,
// Read AutoDoors.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/AutoDoors.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class AutoDoorsService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
@@ -13,7 +13,7 @@ export class BetterChatService {
constructor(
@InjectRepository(BetterChatConfig)
private readonly repo: Repository<BetterChatConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class BetterChatService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write BetterChat.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/BetterChat.json',
content: jsonString,
},
30000,
// Write BetterChat.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/BetterChat.json',
jsonString,
);
// Reload BetterChat plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterChat',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterChat');
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class BetterChatService {
/** Import BetterChat.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read BetterChat.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/BetterChat.json',
},
30000,
// Read BetterChat.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/BetterChat.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class BetterChatService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
@@ -13,7 +13,7 @@ export class FurnaceSplitterService {
constructor(
@InjectRepository(FurnaceSplitterConfig)
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class FurnaceSplitterService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write FurnaceSplitter.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/FurnaceSplitter.json',
content: jsonString,
},
30000,
// Write FurnaceSplitter.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/FurnaceSplitter.json',
jsonString,
);
// Reload FurnaceSplitter plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload FurnaceSplitter',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload FurnaceSplitter');
// Mark this config as active, deactivate others
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class FurnaceSplitterService {
/** Import FurnaceSplitter.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read FurnaceSplitter.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/FurnaceSplitter.json',
},
30000,
// Read FurnaceSplitter.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/FurnaceSplitter.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class FurnaceSplitterService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
@@ -13,7 +13,7 @@ export class GatherService {
constructor(
@InjectRepository(GatherConfig)
private readonly gatherRepo: Repository<GatherConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class GatherService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write GatherManager.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/GatherManager.json',
content: jsonString,
},
30000,
// Write GatherManager.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/GatherManager.json',
jsonString,
);
// Reload GatherManager plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload GatherManager',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload GatherManager');
// Mark this config as active, deactivate others
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class GatherService {
/** Import GatherManager.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read GatherManager.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/GatherManager.json',
},
30000,
// Read GatherManager.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/GatherManager.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class GatherService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -1,13 +1,18 @@
import { Module } from '@nestjs/common';
import { Global, 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';
// Global so the legacy single-server services (servers/players/schedules/wipes/
// plugins + the 9 plugin-config modules) can inject InstancesService to route
// commands at the now-only Rust agent without each importing this module.
@Global()
@Module({
imports: [TypeOrmModule.forFeature([GameInstance])],
controllers: [InstancesController],
providers: [InstancesService, NatsService],
exports: [InstancesService],
})
export class InstancesModule {}

View File

@@ -142,4 +142,82 @@ export class InstancesService {
if (!path || !dest) throw new BadRequestException('path and dest are required');
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
}
/**
* Wipe an instance's game data via the agent's jailed wipe handler: stop →
* delete files per wipe_type (map/blueprint/full) → restart. Long timeout
* because the agent does all three steps before replying.
*/
async wipe(
licenseId: string,
instanceId: string,
wipeType: 'map' | 'blueprint' | 'full',
backup = true,
): Promise<unknown> {
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
this.logger.log(`instance ${inst.agent_instance_id}: wipe (${wipeType})`);
return this.nats.requestScoped(
licenseId,
subject,
{ func: 'wipe', wipe_type: wipeType, backup },
120_000,
);
}
// -------------------------------------------------------------------------
// License-scoped convenience wrappers. Legacy single-server services
// (servers/players/schedules/wipes/plugins + the 9 plugin-config modules)
// predate the instance model and carry only a licenseId. These resolve the
// license's primary instance, then dispatch to the agent — replacing the old
// publishes to the now-defunct `cmd.server` subject.
// -------------------------------------------------------------------------
/** The license's primary (oldest) instance. Throws if none is connected. */
async resolveDefaultInstance(licenseId: string): Promise<GameInstance> {
const inst = await this.instanceRepo.findOne({
where: { license_id: licenseId },
order: { created_at: 'ASC' },
});
if (!inst) {
throw new NotFoundException(
'No game instance is connected for this license yet — install and start the host agent first.',
);
}
return inst;
}
async lifecycleForLicense(licenseId: string, func: LifecycleFunc): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.lifecycle(licenseId, inst.id, func);
}
async rconForLicense(licenseId: string, command: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.rcon(licenseId, inst.id, command);
}
async writeFileForLicense(licenseId: string, path: string, content: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.writeFile(licenseId, inst.id, path, content);
}
async readFileForLicense(licenseId: string, path: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.readFile(licenseId, inst.id, path);
}
async deleteFileForLicense(licenseId: string, path: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.deleteFile(licenseId, inst.id, path);
}
async wipeForLicense(
licenseId: string,
wipeType: 'map' | 'blueprint' | 'full',
backup = true,
): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.wipe(licenseId, inst.id, wipeType, backup);
}
}

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
@@ -13,7 +13,7 @@ export class KitsService {
constructor(
@InjectRepository(KitsConfig)
private readonly kitsRepo: Repository<KitsConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class KitsService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write Kits.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/Kits.json',
content: jsonString,
},
30000,
// Write Kits.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/Kits.json',
jsonString,
);
// Reload Kits plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload Kits',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload Kits');
// Mark this config as active, deactivate others
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class KitsService {
/** Import Kits.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read Kits.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/Kits.json',
},
30000,
// Read Kits.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/Kits.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class KitsService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { LootProfile } from '../../entities/loot-profile.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
@@ -15,7 +15,7 @@ export class LootService {
constructor(
@InjectRepository(LootProfile)
private readonly lootRepo: Repository<LootProfile>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List profiles for a license (summaries — no JSONB) */
@@ -114,37 +114,22 @@ export class LootService {
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
try {
// Write LootTables.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/data/BetterLoot/LootTables.json',
content: lootTablesJson,
},
30000,
// Write LootTables.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/data/BetterLoot/LootTables.json',
lootTablesJson,
);
// Write LootGroups.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/data/BetterLoot/LootGroups.json',
content: lootGroupsJson,
},
30000,
// Write LootGroups.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/data/BetterLoot/LootGroups.json',
lootGroupsJson,
);
// Reload BetterLoot plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterLoot',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterLoot');
// Mark this profile as active, deactivate others
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });

View File

@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { PlayerActionDto } from './dto/player-action.dto';
export interface Player {
@@ -23,7 +23,7 @@ export class PlayersService {
private readonly actionRepo: Repository<PlayerAction>,
@InjectRepository(PlayerSession)
private readonly sessionRepo: Repository<PlayerSession>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/**
@@ -132,15 +132,26 @@ export class PlayersService {
await this.actionRepo.save(action);
// Forward kick, ban, and unban to the game server via NATS
// Forward kick, ban, and unban to the game server via RCON
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
steam_id: dto.steam_id,
reason: dto.reason,
duration_minutes: dto.duration_minutes,
});
const rconCmd = this.buildRconCommand(dto);
await this.instancesService.rconForLicense(licenseId, rconCmd);
}
return { success: true };
}
private buildRconCommand(dto: PlayerActionDto): string {
switch (dto.action_type) {
case 'kick':
return `kick ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
case 'ban':
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
return `banid ${dto.steam_id} ${dto.reason ?? 'banned'} ${dto.duration_minutes ? dto.duration_minutes * 60 : 0}`;
case 'unban':
return `unban ${dto.steam_id}`;
default:
return '';
}
}
}

View File

@@ -1,10 +1,10 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException, ServiceUnavailableException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PluginRegistry } from '../../entities/plugin-registry.entity';
import { InstallPluginDto } from './dto/install-plugin.dto';
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
interface UmodCacheEntry {
data: unknown;
@@ -20,7 +20,7 @@ export class PluginsService {
constructor(
@InjectRepository(PluginRegistry)
private readonly pluginRegistryRepo: Repository<PluginRegistry>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
async getPlugins(licenseId: string): Promise<PluginRegistry[]> {
@@ -43,30 +43,11 @@ export class PluginsService {
throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`);
}
const plugin = this.pluginRegistryRepo.create({
license_id: licenseId,
plugin_name: dto.plugin_name,
umod_slug: dto.umod_slug,
source: dto.source || 'manual',
is_installed: true,
is_loaded: false,
});
const saved = await this.pluginRegistryRepo.save(plugin);
try {
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
action: 'plugin_install',
plugin_name: dto.plugin_name,
umod_slug: dto.umod_slug,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`);
} catch (err) {
this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`);
}
return saved;
// One-click uMod install via agent is not yet implemented.
// Fail fast — do not persist a DB record for a plugin that won't be deployed.
throw new ServiceUnavailableException(
'One-click uMod install is coming soon — download the .cs and use Upload for now.',
);
}
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
@@ -80,11 +61,8 @@ export class PluginsService {
await this.pluginRegistryRepo.delete({ id: pluginId, license_id: licenseId });
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, {
action: 'unload',
plugin_name: plugin.plugin_name,
timestamp: new Date().toISOString(),
});
await this.instancesService.rconForLicense(licenseId, `oxide.unload ${plugin.plugin_name}`);
await this.instancesService.deleteFileForLicense(licenseId, `oxide/plugins/${plugin.plugin_name}.cs`);
this.logger.log(`Plugin uninstall dispatched for ${plugin.plugin_name} on license ${licenseId}`);
}
@@ -100,11 +78,7 @@ export class PluginsService {
throw new NotFoundException(`Plugin ${pluginId} not found`);
}
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, {
action: 'reload',
plugin_name: plugin.plugin_name,
timestamp: new Date().toISOString(),
});
await this.instancesService.rconForLicense(licenseId, `oxide.reload ${plugin.plugin_name}`);
this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`);
return { reloaded: true, plugin_name: plugin.plugin_name };
@@ -215,19 +189,14 @@ export class PluginsService {
const saved = await this.pluginRegistryRepo.save(plugin);
// Dispatch to companion agent via NATS
// Deploy .cs file to server via host agent
try {
const content = file.buffer.toString('base64');
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
action: 'plugin_upload',
filename: originalName,
content,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
const content = file.buffer.toString('utf8');
await this.instancesService.writeFileForLicense(licenseId, `oxide/plugins/${originalName}`, content);
this.logger.log(`Plugin upload deployed: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
} catch (err) {
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
// Don't fail the request — plugin record is saved, NATS delivery is best-effort
this.logger.error(`File write failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
// Don't fail the request — plugin record is saved, file delivery is best-effort
}
return saved;

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
@@ -13,7 +13,7 @@ export class RaidableBasesService {
constructor(
@InjectRepository(RaidableBasesConfig)
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class RaidableBasesService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write RaidableBases.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/RaidableBases.json',
content: jsonString,
},
30000,
// Write RaidableBases.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/RaidableBases.json',
jsonString,
);
// Reload RaidableBases plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload RaidableBases',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload RaidableBases');
// Mark this config as active, deactivate others
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class RaidableBasesService {
/** Import RaidableBases.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read RaidableBases.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/RaidableBases.json',
},
30000,
// Read RaidableBases.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/RaidableBases.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class RaidableBasesService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -10,7 +10,7 @@ import { LessThanOrEqual, Repository } from 'typeorm';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
/** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null {
@@ -61,7 +61,7 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
constructor(
@InjectRepository(ScheduledTask)
private taskRepository: Repository<ScheduledTask>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
// ---------------------------------------------------------------------------
@@ -160,21 +160,12 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
switch (task_type) {
case 'restart':
await this.natsService.sendServerCommand(license_id, 'restart', {
source: 'scheduler',
task_id: task.id,
});
await this.instancesService.lifecycleForLicense(license_id, 'restart');
break;
case 'announcement': {
const message = (task_config?.message as string) ?? 'Scheduled announcement';
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
action: 'command',
command: `say ${message}`,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
await this.instancesService.rconForLicense(license_id, `say ${message}`);
break;
}
@@ -184,25 +175,13 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
return;
}
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
action: 'command',
command,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
await this.instancesService.rconForLicense(license_id, command);
break;
}
case 'plugin_reload': {
const plugin_name = (task_config?.plugin_name as string) ?? '';
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
action: 'reload',
plugin_name,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
await this.instancesService.rconForLicense(license_id, `oxide.reload ${plugin_name}`);
break;
}

View File

@@ -1,9 +1,10 @@
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, InternalServerErrorException, ServiceUnavailableException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { DeployServerDto } from './dto/deploy-server.dto';
@@ -17,6 +18,7 @@ export class ServersService {
@InjectRepository(ServerConfig)
private readonly configRepo: Repository<ServerConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/**
@@ -68,11 +70,11 @@ export class ServersService {
}
/**
* Send a console command to the server via NATS
* Send a console command to the server via the host agent (RCON)
*/
async sendCommand(licenseId: string, command: string) {
try {
await this.natsService.sendServerCommand(licenseId, 'command', { command });
await this.instancesService.rconForLicense(licenseId, command);
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
} catch (err) {
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
@@ -82,42 +84,45 @@ export class ServersService {
}
/**
* Start the server via NATS
* Start the server via the host agent
*/
async startServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'start');
await this.instancesService.lifecycleForLicense(licenseId, 'start');
return { message: 'Start command sent' };
}
/**
* Stop the server via NATS
* Stop the server via the host agent
*/
async stopServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'stop');
await this.instancesService.lifecycleForLicense(licenseId, 'stop');
return { message: 'Stop command sent' };
}
/**
* Restart the server via NATS
* Restart the server via the host agent
*/
async restartServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'restart');
await this.instancesService.lifecycleForLicense(licenseId, 'restart');
return { message: 'Restart command sent' };
}
/**
* Deploy Rust server via companion agent
* Deploy Rust server — not yet supported via host agent.
* Install the server manually and point the host agent at it.
*/
async deployServer(licenseId: string, dto: DeployServerDto) {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
async deployServer(_licenseId: string, _dto: DeployServerDto) {
throw new ServiceUnavailableException(
'Server deployment from the panel is coming soon — install the server and point the host agent at it for now.',
);
}
/**
* Install Oxide/uMod via companion agent
* Install Oxide/uMod — not yet supported via host agent.
*/
async installOxide(licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId);
return { message: 'Oxide installation started' };
async installOxide(_licenseId: string) {
throw new ServiceUnavailableException(
'Oxide install from the panel is coming soon — install Oxide/uMod on the server for now.',
);
}
}

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
@@ -13,7 +13,7 @@ export class TeleportService {
constructor(
@InjectRepository(TeleportConfig)
private readonly teleportRepo: Repository<TeleportConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class TeleportService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write NTeleportation.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/NTeleportation.json',
content: jsonString,
},
30000,
// Write NTeleportation.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/NTeleportation.json',
jsonString,
);
// Reload NTeleportation plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload NTeleportation',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload NTeleportation');
// Mark this config as active, deactivate others
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class TeleportService {
/** Import NTeleportation.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read NTeleportation.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/NTeleportation.json',
},
30000,
// Read NTeleportation.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/NTeleportation.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class TeleportService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
@@ -13,7 +13,7 @@ export class TimedExecuteService {
constructor(
@InjectRepository(TimedExecuteConfig)
private readonly repo: Repository<TimedExecuteConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class TimedExecuteService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write TimedExecute.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/TimedExecute.json',
content: jsonString,
},
30000,
// Write TimedExecute.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/TimedExecute.json',
jsonString,
);
// Reload TimedExecute plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload TimedExecute',
timestamp: new Date().toISOString(),
},
);
await this.instancesService.rconForLicense(licenseId, 'oxide.reload TimedExecute');
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class TimedExecuteService {
/** Import TimedExecute.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read TimedExecute.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/TimedExecute.json',
},
30000,
// Read TimedExecute.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/TimedExecute.json',
);
if (!response) {
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class TimedExecuteService {
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} else {
throw new HttpException(
'Unexpected response format from agent',

View File

@@ -8,7 +8,7 @@ import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { CreateScheduleDto } from './dto/create-schedule.dto';
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
@Injectable()
export class WipesService {
@@ -21,7 +21,7 @@ export class WipesService {
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
@InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
@@ -107,13 +107,7 @@ export class WipesService {
const saved = await this.wipeHistoryRepo.save(history);
await this.natsService.publish(`corrosion.${licenseId}.cmd.wipe`, {
wipe_history_id: saved.id,
wipe_type: dto.wipe_type,
wipe_profile_id: dto.wipe_profile_id ?? null,
trigger_type: 'manual',
timestamp: new Date().toISOString(),
});
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
return { wipe_history_id: saved.id };