diff --git a/backend-nest/src/modules/autodoors/autodoors.service.ts b/backend-nest/src/modules/autodoors/autodoors.service.ts index e3ef573..20a8c45 100644 --- a/backend-nest/src/modules/autodoors/autodoors.service.ts +++ b/backend-nest/src/modules/autodoors/autodoors.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/betterchat/betterchat.service.ts b/backend-nest/src/modules/betterchat/betterchat.service.ts index 7999b1e..7eea97e 100644 --- a/backend-nest/src/modules/betterchat/betterchat.service.ts +++ b/backend-nest/src/modules/betterchat/betterchat.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts b/backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts index 72db115..5e798cb 100644 --- a/backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts +++ b/backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/gather/gather.service.ts b/backend-nest/src/modules/gather/gather.service.ts index ff8f48e..c01e364 100644 --- a/backend-nest/src/modules/gather/gather.service.ts +++ b/backend-nest/src/modules/gather/gather.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/instances/instances.module.ts b/backend-nest/src/modules/instances/instances.module.ts index 4107976..a512931 100644 --- a/backend-nest/src/modules/instances/instances.module.ts +++ b/backend-nest/src/modules/instances/instances.module.ts @@ -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 {} diff --git a/backend-nest/src/modules/instances/instances.service.ts b/backend-nest/src/modules/instances/instances.service.ts index 3780c12..3610d85 100644 --- a/backend-nest/src/modules/instances/instances.service.ts +++ b/backend-nest/src/modules/instances/instances.service.ts @@ -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 { + 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 { + 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 { + const inst = await this.resolveDefaultInstance(licenseId); + return this.lifecycle(licenseId, inst.id, func); + } + + async rconForLicense(licenseId: string, command: string): Promise { + const inst = await this.resolveDefaultInstance(licenseId); + return this.rcon(licenseId, inst.id, command); + } + + async writeFileForLicense(licenseId: string, path: string, content: string): Promise { + const inst = await this.resolveDefaultInstance(licenseId); + return this.writeFile(licenseId, inst.id, path, content); + } + + async readFileForLicense(licenseId: string, path: string): Promise { + const inst = await this.resolveDefaultInstance(licenseId); + return this.readFile(licenseId, inst.id, path); + } + + async deleteFileForLicense(licenseId: string, path: string): Promise { + const inst = await this.resolveDefaultInstance(licenseId); + return this.deleteFile(licenseId, inst.id, path); + } + + async wipeForLicense( + licenseId: string, + wipeType: 'map' | 'blueprint' | 'full', + backup = true, + ): Promise { + const inst = await this.resolveDefaultInstance(licenseId); + return this.wipe(licenseId, inst.id, wipeType, backup); + } } diff --git a/backend-nest/src/modules/kits/kits.service.ts b/backend-nest/src/modules/kits/kits.service.ts index 69e60c6..90fd8e6 100644 --- a/backend-nest/src/modules/kits/kits.service.ts +++ b/backend-nest/src/modules/kits/kits.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/loot/loot.service.ts b/backend-nest/src/modules/loot/loot.service.ts index ce043b5..021f903 100644 --- a/backend-nest/src/modules/loot/loot.service.ts +++ b/backend-nest/src/modules/loot/loot.service.ts @@ -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, - 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 }); diff --git a/backend-nest/src/modules/players/players.service.ts b/backend-nest/src/modules/players/players.service.ts index 77a66b6..003ef81 100644 --- a/backend-nest/src/modules/players/players.service.ts +++ b/backend-nest/src/modules/players/players.service.ts @@ -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, @InjectRepository(PlayerSession) private readonly sessionRepo: Repository, - 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 — 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 ''; + } + } } diff --git a/backend-nest/src/modules/plugins/plugins.service.ts b/backend-nest/src/modules/plugins/plugins.service.ts index b9fa643..429294b 100644 --- a/backend-nest/src/modules/plugins/plugins.service.ts +++ b/backend-nest/src/modules/plugins/plugins.service.ts @@ -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, - private readonly natsService: NatsService, + private readonly instancesService: InstancesService, ) {} async getPlugins(licenseId: string): Promise { @@ -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 { @@ -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; diff --git a/backend-nest/src/modules/raidablebases/raidablebases.service.ts b/backend-nest/src/modules/raidablebases/raidablebases.service.ts index 8fcaafa..7854b3d 100644 --- a/backend-nest/src/modules/raidablebases/raidablebases.service.ts +++ b/backend-nest/src/modules/raidablebases/raidablebases.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/schedules/schedules.service.ts b/backend-nest/src/modules/schedules/schedules.service.ts index 90184f4..24e4942 100644 --- a/backend-nest/src/modules/schedules/schedules.service.ts +++ b/backend-nest/src/modules/schedules/schedules.service.ts @@ -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, - 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; } diff --git a/backend-nest/src/modules/servers/servers.service.ts b/backend-nest/src/modules/servers/servers.service.ts index 0715148..2510d41 100644 --- a/backend-nest/src/modules/servers/servers.service.ts +++ b/backend-nest/src/modules/servers/servers.service.ts @@ -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, 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.', + ); } } diff --git a/backend-nest/src/modules/teleport/teleport.service.ts b/backend-nest/src/modules/teleport/teleport.service.ts index e90aa28..7af10af 100644 --- a/backend-nest/src/modules/teleport/teleport.service.ts +++ b/backend-nest/src/modules/teleport/teleport.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/timedexecute/timedexecute.service.ts b/backend-nest/src/modules/timedexecute/timedexecute.service.ts index d571d54..017f391 100644 --- a/backend-nest/src/modules/timedexecute/timedexecute.service.ts +++ b/backend-nest/src/modules/timedexecute/timedexecute.service.ts @@ -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, - 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; + const responseData = (result as any).content; let configData: Record; - 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', diff --git a/backend-nest/src/modules/wipes/wipes.service.ts b/backend-nest/src/modules/wipes/wipes.service.ts index 35f657c..73f5730 100644 --- a/backend-nest/src/modules/wipes/wipes.service.ts +++ b/backend-nest/src/modules/wipes/wipes.service.ts @@ -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, @InjectRepository(WipeHistory) private readonly wipeHistoryRepo: Repository, - private readonly natsService: NatsService, + private readonly instancesService: InstancesService, ) {} async getProfiles(licenseId: string): Promise { @@ -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 }; diff --git a/corrosion-host-agent/Cargo.lock b/corrosion-host-agent/Cargo.lock index 5a7d409..826f044 100644 --- a/corrosion-host-agent/Cargo.lock +++ b/corrosion-host-agent/Cargo.lock @@ -287,7 +287,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corrosion-host-agent" -version = "2.0.0-alpha.9" +version = "2.0.0-alpha.10" dependencies = [ "anyhow", "async-nats", diff --git a/corrosion-host-agent/Cargo.toml b/corrosion-host-agent/Cargo.toml index 20a220f..ee9a686 100644 --- a/corrosion-host-agent/Cargo.toml +++ b/corrosion-host-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corrosion-host-agent" -version = "2.0.0-alpha.9" +version = "2.0.0-alpha.10" edition = "2021" description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers" license = "UNLICENSED" diff --git a/corrosion-host-agent/src/instancecmd.rs b/corrosion-host-agent/src/instancecmd.rs index 9c5a224..1734963 100644 --- a/corrosion-host-agent/src/instancecmd.rs +++ b/corrosion-host-agent/src/instancecmd.rs @@ -16,6 +16,7 @@ use crate::agent::Agent; use crate::subjects; use crate::steamcmd; use crate::supervisor::Supervisor; +use crate::wipe; #[derive(Debug, Deserialize)] struct InstanceCommand { @@ -23,6 +24,19 @@ struct InstanceCommand { /// Payload for funcs that carry a text argument (e.g. rcon). #[serde(default)] command: Option, + /// Wipe type: "map" | "blueprint" | "full" — required for func="wipe". + #[serde(default)] + wipe_type: Option, + /// 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. @@ -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 => { return json!({ "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)"), }); } }; diff --git a/corrosion-host-agent/src/lib.rs b/corrosion-host-agent/src/lib.rs index e494ecc..803ad32 100644 --- a/corrosion-host-agent/src/lib.rs +++ b/corrosion-host-agent/src/lib.rs @@ -17,3 +17,4 @@ pub mod supervisor; pub mod telemetry; pub mod update; pub mod version; +pub mod wipe; diff --git a/corrosion-host-agent/src/wipe.rs b/corrosion-host-agent/src/wipe.rs new file mode 100644 index 0000000..28922aa --- /dev/null +++ b/corrosion-host-agent/src/wipe.rs @@ -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 `/server//` | +//! | `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 +//! `/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//` 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 { + // 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 +/// / +/// server/ +/// / -- 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> { + let mut targets: Vec = Vec::new(); + + // --- server// --- + 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 `/server//` directory. +fn collect_save_targets( + canon_root: &Path, + identity_dir: &Path, + wipe_type: &WipeType, + out: &mut Vec, +) -> 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 { + 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> { + 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(()) +} diff --git a/corrosion-host-agent/tests/wipe.rs b/corrosion-host-agent/tests/wipe.rs new file mode 100644 index 0000000..dd69eb7 --- /dev/null +++ b/corrosion-host-agent/tests/wipe.rs @@ -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: +// / +// 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 { + 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 +}