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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { LootProfile } from '../../entities/loot-profile.entity'; 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 { CreateLootProfileDto } from './dto/create-loot-profile.dto';
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto'; import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
import { ImportLootProfileDto } from './dto/import-loot-profile.dto'; import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
@@ -15,7 +15,7 @@ export class LootService {
constructor( constructor(
@InjectRepository(LootProfile) @InjectRepository(LootProfile)
private readonly lootRepo: Repository<LootProfile>, private readonly lootRepo: Repository<LootProfile>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List profiles for a license (summaries — no JSONB) */ /** List profiles for a license (summaries — no JSONB) */
@@ -114,37 +114,22 @@ export class LootService {
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2); const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
try { try {
// Write LootTables.json via file manager NATS // Write LootTables.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/data/BetterLoot/LootTables.json',
func: 'fm_save', lootTablesJson,
path: 'server://oxide/data/BetterLoot/LootTables.json',
content: lootTablesJson,
},
30000,
); );
// Write LootGroups.json via file manager NATS // Write LootGroups.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/data/BetterLoot/LootGroups.json',
func: 'fm_save', lootGroupsJson,
path: 'server://oxide/data/BetterLoot/LootGroups.json',
content: lootGroupsJson,
},
30000,
); );
// Reload BetterLoot plugin via RCON // Reload BetterLoot plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterLoot');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterLoot',
timestamp: new Date().toISOString(),
},
);
// Mark this profile as active, deactivate others // Mark this profile as active, deactivate others
await this.lootRepo.update({ license_id: licenseId }, { is_active: false }); 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 { Repository } from 'typeorm';
import { PlayerAction } from '../../entities/player-action.entity'; import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.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'; import { PlayerActionDto } from './dto/player-action.dto';
export interface Player { export interface Player {
@@ -23,7 +23,7 @@ export class PlayersService {
private readonly actionRepo: Repository<PlayerAction>, private readonly actionRepo: Repository<PlayerAction>,
@InjectRepository(PlayerSession) @InjectRepository(PlayerSession)
private readonly sessionRepo: Repository<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); 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') { if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
await this.natsService.sendServerCommand(licenseId, dto.action_type, { const rconCmd = this.buildRconCommand(dto);
steam_id: dto.steam_id, await this.instancesService.rconForLicense(licenseId, rconCmd);
reason: dto.reason,
duration_minutes: dto.duration_minutes,
});
} }
return { success: true }; 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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { PluginRegistry } from '../../entities/plugin-registry.entity'; import { PluginRegistry } from '../../entities/plugin-registry.entity';
import { InstallPluginDto } from './dto/install-plugin.dto'; import { InstallPluginDto } from './dto/install-plugin.dto';
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto'; import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
interface UmodCacheEntry { interface UmodCacheEntry {
data: unknown; data: unknown;
@@ -20,7 +20,7 @@ export class PluginsService {
constructor( constructor(
@InjectRepository(PluginRegistry) @InjectRepository(PluginRegistry)
private readonly pluginRegistryRepo: Repository<PluginRegistry>, private readonly pluginRegistryRepo: Repository<PluginRegistry>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
async getPlugins(licenseId: string): Promise<PluginRegistry[]> { async getPlugins(licenseId: string): Promise<PluginRegistry[]> {
@@ -43,30 +43,11 @@ export class PluginsService {
throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`); throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`);
} }
const plugin = this.pluginRegistryRepo.create({ // One-click uMod install via agent is not yet implemented.
license_id: licenseId, // Fail fast — do not persist a DB record for a plugin that won't be deployed.
plugin_name: dto.plugin_name, throw new ServiceUnavailableException(
umod_slug: dto.umod_slug, 'One-click uMod install is coming soon — download the .cs and use Upload for now.',
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;
} }
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> { 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.pluginRegistryRepo.delete({ id: pluginId, license_id: licenseId });
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, { await this.instancesService.rconForLicense(licenseId, `oxide.unload ${plugin.plugin_name}`);
action: 'unload', await this.instancesService.deleteFileForLicense(licenseId, `oxide/plugins/${plugin.plugin_name}.cs`);
plugin_name: plugin.plugin_name,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin uninstall dispatched for ${plugin.plugin_name} on license ${licenseId}`); 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`); throw new NotFoundException(`Plugin ${pluginId} not found`);
} }
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, { await this.instancesService.rconForLicense(licenseId, `oxide.reload ${plugin.plugin_name}`);
action: 'reload',
plugin_name: plugin.plugin_name,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`); this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`);
return { reloaded: true, plugin_name: plugin.plugin_name }; return { reloaded: true, plugin_name: plugin.plugin_name };
@@ -215,19 +189,14 @@ export class PluginsService {
const saved = await this.pluginRegistryRepo.save(plugin); const saved = await this.pluginRegistryRepo.save(plugin);
// Dispatch to companion agent via NATS // Deploy .cs file to server via host agent
try { try {
const content = file.buffer.toString('base64'); const content = file.buffer.toString('utf8');
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, { await this.instancesService.writeFileForLicense(licenseId, `oxide/plugins/${originalName}`, content);
action: 'plugin_upload', this.logger.log(`Plugin upload deployed: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
filename: originalName,
content,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
} catch (err) { } catch (err) {
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`); 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, NATS delivery is best-effort // Don't fail the request — plugin record is saved, file delivery is best-effort
} }
return saved; return saved;

View File

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

View File

@@ -10,7 +10,7 @@ import { LessThanOrEqual, Repository } from 'typeorm';
import { ScheduledTask } from '../../entities/scheduled-task.entity'; import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto'; import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-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`. */ /** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null { function nextCronDate(expr: string, after: Date): Date | null {
@@ -61,7 +61,7 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
constructor( constructor(
@InjectRepository(ScheduledTask) @InjectRepository(ScheduledTask)
private taskRepository: Repository<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) { switch (task_type) {
case 'restart': case 'restart':
await this.natsService.sendServerCommand(license_id, 'restart', { await this.instancesService.lifecycleForLicense(license_id, 'restart');
source: 'scheduler',
task_id: task.id,
});
break; break;
case 'announcement': { case 'announcement': {
const message = (task_config?.message as string) ?? 'Scheduled announcement'; const message = (task_config?.message as string) ?? 'Scheduled announcement';
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, { await this.instancesService.rconForLicense(license_id, `say ${message}`);
action: 'command',
command: `say ${message}`,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break; break;
} }
@@ -184,25 +175,13 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
this.logger.warn(`Task ${task.id} has no command configured — skipping`); this.logger.warn(`Task ${task.id} has no command configured — skipping`);
return; return;
} }
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, { await this.instancesService.rconForLicense(license_id, command);
action: 'command',
command,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break; break;
} }
case 'plugin_reload': { case 'plugin_reload': {
const plugin_name = (task_config?.plugin_name as string) ?? ''; const plugin_name = (task_config?.plugin_name as string) ?? '';
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, { await this.instancesService.rconForLicense(license_id, `oxide.reload ${plugin_name}`);
action: 'reload',
plugin_name,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break; 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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity'; import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity'; import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service'; import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { UpdateServerConfigDto } from './dto/update-config.dto'; import { UpdateServerConfigDto } from './dto/update-config.dto';
import { DeployServerDto } from './dto/deploy-server.dto'; import { DeployServerDto } from './dto/deploy-server.dto';
@@ -17,6 +18,7 @@ export class ServersService {
@InjectRepository(ServerConfig) @InjectRepository(ServerConfig)
private readonly configRepo: Repository<ServerConfig>, private readonly configRepo: Repository<ServerConfig>,
private readonly natsService: NatsService, 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) { async sendCommand(licenseId: string, command: string) {
try { try {
await this.natsService.sendServerCommand(licenseId, 'command', { command }); await this.instancesService.rconForLicense(licenseId, command);
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`); this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
} catch (err) { } catch (err) {
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`); 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) { async startServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'start'); await this.instancesService.lifecycleForLicense(licenseId, 'start');
return { message: 'Start command sent' }; return { message: 'Start command sent' };
} }
/** /**
* Stop the server via NATS * Stop the server via the host agent
*/ */
async stopServer(licenseId: string) { async stopServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'stop'); await this.instancesService.lifecycleForLicense(licenseId, 'stop');
return { message: 'Stop command sent' }; return { message: 'Stop command sent' };
} }
/** /**
* Restart the server via NATS * Restart the server via the host agent
*/ */
async restartServer(licenseId: string) { async restartServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'restart'); await this.instancesService.lifecycleForLicense(licenseId, 'restart');
return { message: 'Restart command sent' }; 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) { async deployServer(_licenseId: string, _dto: DeployServerDto) {
await this.natsService.sendDeployCommand(licenseId, { ...dto }); throw new ServiceUnavailableException(
return { message: 'Deployment started' }; '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) { async installOxide(_licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId); throw new ServiceUnavailableException(
return { message: 'Oxide installation started' }; '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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { TeleportConfig } from '../../entities/teleport-config.entity'; 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 { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto'; import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
@@ -13,7 +13,7 @@ export class TeleportService {
constructor( constructor(
@InjectRepository(TeleportConfig) @InjectRepository(TeleportConfig)
private readonly teleportRepo: Repository<TeleportConfig>, private readonly teleportRepo: Repository<TeleportConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class TeleportService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write NTeleportation.json via file manager NATS // Write NTeleportation.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/NTeleportation.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/NTeleportation.json',
content: jsonString,
},
30000,
); );
// Reload NTeleportation plugin via RCON // Reload NTeleportation plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload NTeleportation');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload NTeleportation',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false }); 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 */ /** Import NTeleportation.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read NTeleportation.json from server via file manager NATS // Read NTeleportation.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/NTeleportation.json',
func: 'fm_preview',
path: 'server://oxide/config/NTeleportation.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class TeleportService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

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

View File

@@ -287,7 +287,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "corrosion-host-agent" name = "corrosion-host-agent"
version = "2.0.0-alpha.9" version = "2.0.0-alpha.10"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-nats", "async-nats",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "corrosion-host-agent" name = "corrosion-host-agent"
version = "2.0.0-alpha.9" version = "2.0.0-alpha.10"
edition = "2021" edition = "2021"
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers" description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
license = "UNLICENSED" license = "UNLICENSED"

View File

@@ -16,6 +16,7 @@ use crate::agent::Agent;
use crate::subjects; use crate::subjects;
use crate::steamcmd; use crate::steamcmd;
use crate::supervisor::Supervisor; use crate::supervisor::Supervisor;
use crate::wipe;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct InstanceCommand { struct InstanceCommand {
@@ -23,6 +24,19 @@ struct InstanceCommand {
/// Payload for funcs that carry a text argument (e.g. rcon). /// Payload for funcs that carry a text argument (e.g. rcon).
#[serde(default)] #[serde(default)]
command: Option<String>, command: Option<String>,
/// Wipe type: "map" | "blueprint" | "full" — required for func="wipe".
#[serde(default)]
wipe_type: Option<wipe::WipeType>,
/// Whether to back up wipe targets before deleting (func="wipe").
#[serde(default)]
backup: bool,
/// Label for the backup subdirectory (func="wipe"). Defaults to "wipe-backup".
#[serde(default = "default_backup_label")]
backup_label: String,
}
fn default_backup_label() -> String {
"wipe-backup".to_string()
} }
/// Forward every supervisor state change as a status event. /// Forward every supervisor state change as a status event.
@@ -252,10 +266,79 @@ async fn dispatch(
}), }),
}; };
} }
"wipe" => {
let inst_cfg = agent.cfg.instances.iter().find(|i| i.id == sup.instance_id());
let Some(inst_cfg) = inst_cfg else {
return json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": format!("no config found for instance '{}'", sup.instance_id()),
});
};
let Some(wipe_type) = cmd.wipe_type.clone() else {
return json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": "wipe func requires a 'wipe_type' field (\"map\", \"blueprint\", or \"full\")",
});
};
let root = inst_cfg.root.clone();
let instance_id = sup.instance_id().to_string();
let wipe_req = wipe::WipeRequest {
wipe_type,
backup: cmd.backup,
backup_label: cmd.backup_label.clone(),
};
// Stop the server best-effort before wiping; proceed even if stop fails
// (the server may already be down).
if let Err(e) = sup.clone().stop().await {
tracing::warn!("wipe: stop instance '{}' failed (proceeding anyway): {e:#}", instance_id);
}
// Run the blocking I/O on the blocking thread pool.
let result = tokio::task::spawn_blocking(move || wipe::execute(&root, &wipe_req)).await;
// Restart best-effort regardless of wipe outcome.
if let Err(e) = sup.clone().start().await {
tracing::warn!("wipe: restart instance '{}' failed: {e:#}", instance_id);
}
return match result {
Ok(Ok(wr)) => {
let wipe_type_str = format!("{:?}", wr.wipe_type).to_lowercase();
json!({
"status": "success",
"func": "wipe",
"instance_id": sup.instance_id(),
"wipe_type": wipe_type_str,
"deleted_count": wr.deleted_count,
})
}
Ok(Err(e)) => json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": format!("{e:#}"),
}),
Err(e) => json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": format!("internal error: {e}"),
}),
};
}
other => { other => {
return json!({ return json!({
"status": "error", "status": "error",
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"), "message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update, wipe)"),
}); });
} }
}; };

View File

@@ -17,3 +17,4 @@ pub mod supervisor;
pub mod telemetry; pub mod telemetry;
pub mod update; pub mod update;
pub mod version; pub mod version;
pub mod wipe;

View File

@@ -0,0 +1,412 @@
//! Jailed wipe engine for Rust (and compatible) game server instances.
//!
//! Three wipe types are supported, each a strict superset of the previous:
//!
//! | Type | What is deleted |
//! |-------------|------------------------------------------------------------------|
//! | `map` | `*.map`, `*.sav` under `<root>/server/<identity>/` |
//! | `blueprint` | map wipe + `*.blueprints.*.db` / `.blueprints.*` under save dir |
//! | `full` | blueprint wipe + `oxide/data/` contents + player state DB files |
//!
//! Identity discovery: rather than require the identity in the payload, we walk
//! `<root>/server/*/` looking for files that match each wipe type's patterns.
//! This handles any identity name without configuration churn.
//!
//! **Safety**: every path operated on is validated inside the canonicalized
//! instance root with the same two-stage (lexical + canonicalize) jail used by
//! `filemanager.rs`. We use `symlink_metadata` (lstat) everywhere we walk
//! directories — symlinks are never followed across the boundary (Lesson 26).
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use crate::filemanager::jail;
// ---------------------------------------------------------------------------
// Public API types
// ---------------------------------------------------------------------------
/// The scope of data to erase.
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WipeType {
/// Delete procedural map + save files only.
Map,
/// Map wipe + player blueprint databases.
Blueprint,
/// Blueprint wipe + oxide/data + all player state DBs.
Full,
}
/// Parameters parsed from the NATS command payload.
#[derive(Debug, serde::Deserialize)]
pub struct WipeRequest {
/// Scope of the wipe.
pub wipe_type: WipeType,
/// Copy files to `.corrosion-backups/<backup_label>/` before deleting.
#[serde(default)]
pub backup: bool,
/// Label used as the backup subdirectory name. Defaults to `"wipe-backup"`.
#[serde(default = "default_backup_label")]
pub backup_label: String,
}
fn default_backup_label() -> String {
"wipe-backup".to_string()
}
/// Result of a successful wipe operation.
#[derive(Debug)]
pub struct WipeResult {
pub deleted_count: usize,
pub wipe_type: WipeType,
}
// ---------------------------------------------------------------------------
// Core wipe logic (sync — suitable for `spawn_blocking`)
// ---------------------------------------------------------------------------
/// Execute a wipe of `wipe_type` inside `root`, optionally backing up first.
///
/// Does NOT touch the supervisor lifecycle — the caller (instancecmd dispatch)
/// must stop the server before calling this and restart it afterwards.
///
/// Returns a `WipeResult` describing what was deleted. Missing directories are
/// treated as zero-deleted, not as errors, so a fresh server never returns Err
/// just because `server/*/` doesn't exist yet.
pub fn execute(root: &Path, req: &WipeRequest) -> Result<WipeResult> {
// Canonicalize root once; every subsequent path check goes through `jail()`.
let canon_root = fs::canonicalize(root)
.with_context(|| format!("canonicalize instance root '{}'", root.display()))?;
// Collect every path to delete based on wipe type.
let targets = collect_targets(&canon_root, &req.wipe_type)?;
// Backup before any deletion when requested.
if req.backup && !targets.is_empty() {
let backup_dir = jail(root, &format!(".corrosion-backups/{}", req.backup_label))?;
fs::create_dir_all(&backup_dir)
.with_context(|| format!("create backup dir '{}'", backup_dir.display()))?;
for path in &targets {
backup_one(&canon_root, path, &backup_dir)?;
}
}
// Delete.
let mut deleted_count = 0usize;
for path in &targets {
// Final safety check: confirm inside root before deletion.
if path != &canon_root && !path.starts_with(&canon_root) {
anyhow::bail!(
"wipe safety: path '{}' is outside instance root '{}' — aborting",
path.display(),
canon_root.display()
);
}
match delete_path(path) {
Ok(n) => deleted_count += n,
Err(e) => tracing::warn!("wipe: skipping '{}': {e:#}", path.display()),
}
}
tracing::info!(
"wipe complete: type={:?} deleted={} root={}",
req.wipe_type,
deleted_count,
root.display()
);
Ok(WipeResult {
deleted_count,
wipe_type: req.wipe_type.clone(),
})
}
// ---------------------------------------------------------------------------
// Target collection
// ---------------------------------------------------------------------------
/// Walk the Rust server tree under `canon_root` and return every path (file or
/// dir) that should be deleted for the given wipe type.
///
/// Layout assumed:
/// ```text
/// <root>/
/// server/
/// <identity>/ -- any name; we walk all subdirs
/// *.map
/// *.sav
/// player.blueprints.*.db (and *.blueprints.* variants)
/// player.deaths.*.db
/// player.identities.*.db
/// player.states.*.db
/// *.db (full wipe)
/// oxide/
/// data/ -- cleared for full wipe (dir contents, not dir itself)
/// ```
fn collect_targets(canon_root: &Path, wipe_type: &WipeType) -> Result<Vec<PathBuf>> {
let mut targets: Vec<PathBuf> = Vec::new();
// --- server/<identity>/ ---
let server_dir = canon_root.join("server");
if is_real_dir(&server_dir) {
for identity_entry in read_dir_safe(&server_dir)? {
let identity_meta = fs::symlink_metadata(&identity_entry)
.with_context(|| format!("stat '{}'", identity_entry.display()))?;
// Never follow symlinks across the boundary.
if identity_meta.file_type().is_symlink() {
tracing::debug!("wipe: skipping symlink '{}'", identity_entry.display());
continue;
}
if !identity_meta.is_dir() {
continue;
}
collect_save_targets(canon_root, &identity_entry, wipe_type, &mut targets)?;
}
}
// --- oxide/data/ (full wipe only) ---
if *wipe_type == WipeType::Full {
let oxide_data = canon_root.join("oxide").join("data");
if is_real_dir(&oxide_data) {
// Delete directory *contents*, not the directory itself.
for entry in read_dir_safe(&oxide_data)? {
let meta = fs::symlink_metadata(&entry)
.with_context(|| format!("stat '{}'", entry.display()))?;
if meta.file_type().is_symlink() {
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
continue;
}
// Jail-check every entry before adding.
ensure_inside(canon_root, &entry)?;
targets.push(entry);
}
}
}
Ok(targets)
}
/// Collect files from one `<root>/server/<identity>/` directory.
fn collect_save_targets(
canon_root: &Path,
identity_dir: &Path,
wipe_type: &WipeType,
out: &mut Vec<PathBuf>,
) -> Result<()> {
for entry in read_dir_safe(identity_dir)? {
let meta = fs::symlink_metadata(&entry)
.with_context(|| format!("stat '{}'", entry.display()))?;
// Never follow symlinks.
if meta.file_type().is_symlink() {
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
continue;
}
ensure_inside(canon_root, &entry)?;
let file_name = entry
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let keep = match wipe_type {
WipeType::Map => !is_map_file(&file_name) && !is_sav_file(&file_name),
WipeType::Blueprint => {
!is_map_file(&file_name)
&& !is_sav_file(&file_name)
&& !is_blueprint_file(&file_name)
}
WipeType::Full => {
!is_map_file(&file_name)
&& !is_sav_file(&file_name)
&& !is_blueprint_file(&file_name)
&& !is_player_state_file(&file_name)
&& !is_generic_db_file(&file_name)
}
};
if !keep {
out.push(entry);
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Pattern matchers
// ---------------------------------------------------------------------------
fn is_map_file(name: &str) -> bool {
name.ends_with(".map")
}
fn is_sav_file(name: &str) -> bool {
name.ends_with(".sav")
}
fn is_blueprint_file(name: &str) -> bool {
// Matches both `player.blueprints.*.db` and `.blueprints.*` variants.
name.contains(".blueprints.")
}
fn is_player_state_file(name: &str) -> bool {
name.contains("player.deaths.")
|| name.contains("player.identities.")
|| name.contains("player.states.")
}
fn is_generic_db_file(name: &str) -> bool {
name.ends_with(".db")
}
// ---------------------------------------------------------------------------
// Deletion
// ---------------------------------------------------------------------------
/// Delete a single path (file or directory tree). Returns count of top-level
/// items removed (1 for a file, 1 for a directory tree). Missing paths return
/// 0 — the server may be fresh.
fn delete_path(path: &Path) -> Result<usize> {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e).with_context(|| format!("stat '{}'", path.display())),
};
if meta.file_type().is_symlink() {
// Delete the symlink itself — never follow it.
fs::remove_file(path).with_context(|| format!("remove symlink '{}'", path.display()))?;
return Ok(1);
}
if meta.is_dir() {
fs::remove_dir_all(path)
.with_context(|| format!("remove_dir_all '{}'", path.display()))?;
} else {
fs::remove_file(path)
.with_context(|| format!("remove_file '{}'", path.display()))?;
}
Ok(1)
}
// ---------------------------------------------------------------------------
// Backup
// ---------------------------------------------------------------------------
/// Copy one path (file or directory) into `backup_dir`, preserving the last
/// component of the path name. Symlinks are skipped — we never follow them.
fn backup_one(canon_root: &Path, src: &Path, backup_dir: &Path) -> Result<()> {
let meta = match fs::symlink_metadata(src) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e).with_context(|| format!("stat backup src '{}'", src.display())),
};
if meta.file_type().is_symlink() {
tracing::debug!("wipe backup: skipping symlink '{}'", src.display());
return Ok(());
}
let name = match src.file_name() {
Some(n) => n,
None => return Ok(()),
};
// Preserve relative path from root inside the backup directory to avoid
// name collisions when multiple identity dirs have a `proc.map`.
let rel = src
.strip_prefix(canon_root)
.unwrap_or_else(|_| src)
.parent()
.unwrap_or_else(|| Path::new(""));
let dest = backup_dir.join(rel).join(name);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("backup: create_dir_all '{}'", parent.display()))?;
}
copy_recursive_safe(src, &dest)?;
Ok(())
}
/// Recursive copy that uses `symlink_metadata` (lstat) and refuses to follow
/// any symlink — mirrors the same guard in `filemanager::copy_recursive`.
fn copy_recursive_safe(src: &Path, dest: &Path) -> Result<()> {
let meta = fs::symlink_metadata(src)
.with_context(|| format!("stat source '{}'", src.display()))?;
if meta.file_type().is_symlink() {
anyhow::bail!(
"refusing to copy symlink '{}' during backup — symlinks are not followed",
src.display()
);
}
if meta.is_dir() {
fs::create_dir_all(dest)
.with_context(|| format!("create_dir_all '{}'", dest.display()))?;
for entry in fs::read_dir(src)
.with_context(|| format!("read_dir '{}'", src.display()))?
{
let entry = entry?;
copy_recursive_safe(&entry.path(), &dest.join(entry.file_name()))?;
}
} else {
fs::copy(src, dest)
.with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Returns `true` if `path` exists, is a directory, and is not a symlink.
fn is_real_dir(path: &Path) -> bool {
match fs::symlink_metadata(path) {
Ok(m) => m.is_dir() && !m.file_type().is_symlink(),
Err(_) => false,
}
}
/// Read a directory and return the absolute paths of its entries.
/// Uses lstat internally via `read_dir` (entry paths; metadata is lstat'd
/// separately by callers).
fn read_dir_safe(dir: &Path) -> Result<Vec<PathBuf>> {
let mut entries = Vec::new();
let rd = match fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(e) => return Err(e).with_context(|| format!("read_dir '{}'", dir.display())),
};
for item in rd {
let item = item.with_context(|| format!("read dir entry in '{}'", dir.display()))?;
entries.push(item.path());
}
Ok(entries)
}
/// Assert that `path` is strictly inside (or equal to) `canon_root`.
/// This is the final safety fence before any destructive or backup operation.
fn ensure_inside(canon_root: &Path, path: &Path) -> Result<()> {
// Canonicalize the path if it exists; otherwise use it as-is (it's
// derived from read_dir, which already returns absolute paths rooted
// under canon_root in normal operation).
let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if resolved != canon_root && !resolved.starts_with(canon_root) {
anyhow::bail!(
"wipe safety: path '{}' is outside instance root '{}' — aborting",
path.display(),
canon_root.display()
);
}
Ok(())
}

View File

@@ -0,0 +1,298 @@
//! Integration tests for the wipe engine.
//!
//! Builds a temp directory tree that mirrors a Rust dedicated server layout
//! and verifies each wipe type's targeting, the symlink-safety guarantee,
//! backup behaviour, and graceful handling of missing directories.
//!
//! Symlink tests are POSIX-only (Unix creates symlinks; Windows needs elevated
//! privileges or Developer Mode, so we skip there).
#![cfg(unix)]
use corrosion_host_agent::wipe::{execute, WipeRequest, WipeType};
use std::path::Path;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helper: build a fake Rust server tree
//
// Layout:
// <root>/
// server/
// myserver/
// proc.map
// proc.sav
// player.blueprints.1234.db
// player.deaths.1234.db
// player.identities.1234.db
// player.states.1234.db
// players.db
// keepme.txt ← must survive every wipe
// oxide/
// data/
// killfeed.json
// another.json
// server_readme.txt ← must survive every wipe
// ---------------------------------------------------------------------------
fn make_server_tree() -> TempDir {
let dir = tempfile::tempdir().expect("create tempdir");
let root = dir.path();
let save_dir = root.join("server").join("myserver");
std::fs::create_dir_all(&save_dir).expect("create save dir");
std::fs::create_dir_all(root.join("oxide").join("data")).expect("create oxide/data");
// Save files
write_file(&save_dir.join("proc.map"), b"map data");
write_file(&save_dir.join("proc.sav"), b"sav data");
write_file(&save_dir.join("player.blueprints.1234.db"), b"bp data");
write_file(&save_dir.join("player.deaths.1234.db"), b"deaths");
write_file(&save_dir.join("player.identities.1234.db"), b"identities");
write_file(&save_dir.join("player.states.1234.db"), b"states");
write_file(&save_dir.join("players.db"), b"player db");
// Innocent file — must never be deleted.
write_file(&save_dir.join("keepme.txt"), b"keep me");
// oxide/data contents
write_file(&root.join("oxide").join("data").join("killfeed.json"), b"{}");
write_file(&root.join("oxide").join("data").join("another.json"), b"{}");
// File at root level — must survive.
write_file(&root.join("server_readme.txt"), b"readme");
dir
}
fn write_file(path: &Path, content: &[u8]) {
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
fn wipe_req(wipe_type: WipeType) -> WipeRequest {
WipeRequest {
wipe_type,
backup: false,
backup_label: "test-backup".to_string(),
}
}
fn exists(root: &Path, rel: &str) -> bool {
root.join(rel).exists()
}
// ---------------------------------------------------------------------------
// Map wipe: only *.map and *.sav deleted
// ---------------------------------------------------------------------------
#[test]
fn map_wipe_deletes_map_and_sav_only() {
let dir = make_server_tree();
let root = dir.path();
let result = execute(root, &wipe_req(WipeType::Map)).expect("map wipe should succeed");
// Deleted
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone");
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone");
// Preserved
assert!(exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must survive map wipe");
assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive map wipe");
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive");
assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive map wipe");
assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive");
assert_eq!(result.deleted_count, 2);
assert_eq!(result.wipe_type, WipeType::Map);
}
// ---------------------------------------------------------------------------
// Blueprint wipe: map/sav + blueprints deleted
// ---------------------------------------------------------------------------
#[test]
fn blueprint_wipe_includes_map_files() {
let dir = make_server_tree();
let root = dir.path();
let result = execute(root, &wipe_req(WipeType::Blueprint)).expect("blueprint wipe should succeed");
// Deleted
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone");
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone");
assert!(!exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must be gone");
// Preserved
assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive blueprint wipe");
assert!(exists(root, "server/myserver/player.identities.1234.db"), "identities must survive");
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive");
assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive blueprint wipe");
assert_eq!(result.deleted_count, 3);
assert_eq!(result.wipe_type, WipeType::Blueprint);
}
// ---------------------------------------------------------------------------
// Full wipe: everything including player state + oxide/data
// ---------------------------------------------------------------------------
#[test]
fn full_wipe_clears_all_game_data() {
let dir = make_server_tree();
let root = dir.path();
let result = execute(root, &wipe_req(WipeType::Full)).expect("full wipe should succeed");
// All save-dir game files deleted
assert!(!exists(root, "server/myserver/proc.map"));
assert!(!exists(root, "server/myserver/proc.sav"));
assert!(!exists(root, "server/myserver/player.blueprints.1234.db"));
assert!(!exists(root, "server/myserver/player.deaths.1234.db"));
assert!(!exists(root, "server/myserver/player.identities.1234.db"));
assert!(!exists(root, "server/myserver/player.states.1234.db"));
assert!(!exists(root, "server/myserver/players.db"));
// oxide/data contents deleted (directory itself preserved)
assert!(!exists(root, "oxide/data/killfeed.json"), "killfeed.json must be gone");
assert!(!exists(root, "oxide/data/another.json"), "another.json must be gone");
assert!(exists(root, "oxide/data"), "oxide/data directory itself must remain");
// Never-touched files preserved
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive full wipe");
assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive full wipe");
// 7 save-dir files + 2 oxide/data files = 9
assert_eq!(result.deleted_count, 9);
assert_eq!(result.wipe_type, WipeType::Full);
}
// ---------------------------------------------------------------------------
// Missing directories: no error on fresh server
// ---------------------------------------------------------------------------
#[test]
fn missing_server_dir_does_not_error() {
let dir = tempfile::tempdir().expect("tempdir");
// Completely empty root — no server/ or oxide/ directories.
let result = execute(dir.path(), &wipe_req(WipeType::Full));
assert!(result.is_ok(), "empty root must not error: {:?}", result);
assert_eq!(result.unwrap().deleted_count, 0);
}
#[test]
fn missing_oxide_data_does_not_error() {
let dir = tempfile::tempdir().expect("tempdir");
// Has server dir but no oxide/data.
let save_dir = dir.path().join("server").join("myserver");
std::fs::create_dir_all(&save_dir).expect("mkdir");
write_file(&save_dir.join("proc.map"), b"map");
let result = execute(dir.path(), &wipe_req(WipeType::Full));
assert!(result.is_ok(), "missing oxide/data must not error: {:?}", result);
}
// ---------------------------------------------------------------------------
// Symlink safety: symlink inside root pointing outside must NOT be followed
// ---------------------------------------------------------------------------
#[test]
fn symlink_in_save_dir_is_not_deleted_via_follow() {
let dir = make_server_tree();
let root = dir.path();
// Create an external directory with sensitive data.
let outside = tempfile::tempdir().expect("outside tempdir");
write_file(&outside.path().join("secret.txt"), b"TOP SECRET");
// Plant a symlink inside the save dir pointing to the external directory.
let save_dir = root.join("server").join("myserver");
let link = save_dir.join("evil_link");
std::os::unix::fs::symlink(outside.path(), &link).expect("plant symlink");
// Perform a full wipe — should not follow the symlink or touch secret.txt
let result = execute(root, &wipe_req(WipeType::Full));
assert!(result.is_ok(), "wipe with a symlink present must not error: {:?}", result);
// External data must be untouched.
assert!(
outside.path().join("secret.txt").exists(),
"external secret.txt must not be deleted via symlink follow"
);
}
#[test]
fn symlink_at_identity_dir_level_is_skipped() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir_all(root.join("server")).expect("mkdir server");
// The identity entry itself is a symlink to an external dir.
let outside = tempfile::tempdir().expect("outside tempdir");
write_file(&outside.path().join("proc.map"), b"map");
let link = root.join("server").join("evil_identity");
std::os::unix::fs::symlink(outside.path(), &link).expect("plant identity symlink");
let result = execute(root, &wipe_req(WipeType::Map));
assert!(result.is_ok(), "symlink identity dir must be skipped, not error: {:?}", result);
// The external proc.map must not have been deleted.
assert!(
outside.path().join("proc.map").exists(),
"external proc.map must not be deleted via identity symlink"
);
assert_eq!(result.unwrap().deleted_count, 0);
}
// ---------------------------------------------------------------------------
// Backup: files are copied before deletion
// ---------------------------------------------------------------------------
#[test]
fn backup_copies_targets_before_deletion() {
let dir = make_server_tree();
let root = dir.path();
let req = WipeRequest {
wipe_type: WipeType::Map,
backup: true,
backup_label: "before-map-wipe".to_string(),
};
let result = execute(root, &req).expect("map wipe with backup should succeed");
// The files should be gone from the save dir…
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be deleted");
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be deleted");
// …but must exist in the backup directory.
let backup_base = root.join(".corrosion-backups").join("before-map-wipe");
assert!(backup_base.exists(), "backup directory must be created");
// Walk the backup to find the backed-up files.
let backed_up = collect_files_recursively(&backup_base);
let has_map = backed_up.iter().any(|p| p.ends_with("proc.map"));
let has_sav = backed_up.iter().any(|p| p.ends_with("proc.sav"));
assert!(has_map, "proc.map must be in backup, found: {backed_up:?}");
assert!(has_sav, "proc.sav must be in backup, found: {backed_up:?}");
assert_eq!(result.deleted_count, 2);
}
/// Recursively collect all file *names* (just the last component) under `dir`.
fn collect_files_recursively(dir: &Path) -> Vec<String> {
let mut found = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
found.extend(collect_files_recursively(&path));
} else {
if let Some(name) = path.file_name() {
found.push(name.to_string_lossy().into_owned());
}
}
}
}
found
}